Merge branch 'apk-search' into apk-store

This commit is contained in:
saphid committed 2026-09-28 21:54:36 +10:00
commit 7e2228b15e
27 files changed
+1466 -14

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
# Store preview artwork
Recorded public artwork for offline UI verification, fetched 2026-09-28.
`urls.json` records each original URL. No unit test downloads these files.
- Open Brush banner, icon and screenshots: Icosa Foundation's public
`icosa-foundation/openbrush.app` website assets. Artwork remains credited to
its creators; used here to preview the Open Brush listing.
- Mindustry, AntennaPod and NewPipe icons/screenshots: their public F-Droid
listings. Corresponding projects use GPL licences; these images represent
those same apps in the store preview.
- Luanti, SuperTuxKart and other social previews: public GitHub-generated
repository preview images. Project names/logos belong to their owners.
`../store.json` contains illustrative listing metadata, including mock package
names, popularity, dates, version/size and compatibility fields. It is not a
catalogue or evidence that a particular release works on the Frame. `_demo.py`
is opt-in and cannot download APKs. `tests/search_preview.py` preloads these
recordings into the image cache and simulates installation without a headset.
Binary file not shown.

After

Width:  |  Height:  |  Size: 489 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 KiB

+16
View File
@@ -0,0 +1,16 @@
{
"brush-banner.jpg": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/bg.jpg",
"brush-icon.png": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/icon.png",
"brush-shot1.png": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/1.png",
"brush-shot2.webp": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/2.webp",
"brush-shot3.webp": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/3.webp",
"luanti.png": "https://opengraph.githubassets.com/1/luanti-org/luanti",
"kart.png": "https://opengraph.githubassets.com/1/supertuxkart/stk-code",
"luanti-icon.png": "https://raw.githubusercontent.com/luanti-org/luanti/master/textures/base/pack/logo.png",
"pod-icon.png": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/icon_w44b41PyuNt3pI7Gh8zYHJrWgu__3HT7YSWZtttfenk=.png",
"mindustry-icon.png": "https://f-droid.org/repo/io.anuke.mindustry/en-US/icon_Eno3XvqCZUcHRm3eMjiUleAxgzLopPe6-hkI7BHx1lU=.png",
"mindustry-shot.png": "https://f-droid.org/repo/io.anuke.mindustry/en-US/phoneScreenshots/1.png",
"pod-shot.png": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/phoneScreenshots/00.png",
"newpipe-icon.png": "https://f-droid.org/repo/org.schabi.newpipe/en-US/icon_OHy4y1W-fJCNhHHOBCM9V_cxZNJJgbcNkB-x7UDTY9Q=.png",
"newpipe-shot.png": "https://f-droid.org/repo/org.schabi.newpipe/en-US/phoneScreenshots/00.png"
}
+7
View File
@@ -0,0 +1,7 @@
[
{"source":"one","id":"brush","package":"org.brush","name":"Open Brush","free":true,"downloadable":true,"verified":true,"version":"1","version_code":1,"min_sdk":29,"abis":["arm64-v8a"],"vr":true,"updated":"2025-01-01"},
{"source":"two","id":"brush2","package":"org.brush","name":"Open Brush","free":true,"downloadable":true,"verified":false,"version":"2","version_code":2,"min_sdk":29,"abis":["arm64-v8a"],"vr":true,"updated":"2026-01-01"},
{"source":"one","id":"other","package":"org.other","name":"Open Brush","free":true,"downloadable":true,"min_sdk":31,"abis":["arm64-v8a"],"vr":true},
{"source":"one","id":"unknown","package":null,"name":"Pocket Radio!","free":true,"downloadable":false,"vr":false},
{"source":"two","id":"unknown2","package":null,"name":"pocket radio","free":true,"downloadable":false,"vr":false}
]
+182
View File
@@ -0,0 +1,182 @@
[
{
"id": "brush",
"package": "org.preview.brush",
"name": "Open Brush",
"summary": "Make the world your canvas. Paint, sculpt and create in a space without limits.",
"description": "Your imagination deserves more room. Open Brush turns the space around you into a canvas, with expressive brushes, vivid colors and light you can paint with.\n\nCreate something small, build something extraordinary, or just enjoy making your first mark in VR. This community-led painting app is free and open source.",
"developer": "Icosa Foundation",
"license": "Apache-2.0",
"free": true,
"downloadable": true,
"version": "2.32.29",
"version_code": 1,
"min_sdk": 26,
"abis": [
"arm64-v8a"
],
"vr": true,
"updated": "2026-09-27",
"size": 85000000,
"popularity": 100,
"images": {
"banner": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/bg.jpg",
"icon": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/icon.png",
"screenshots": [
"https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/1.png",
"https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/2.webp",
"https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/3.webp"
]
},
"engine": "Unity OpenXR",
"frame_tested": true,
"icon": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/icon.png",
"page": "https://openbrush.app"
},
{
"id": "mindustry",
"package": "org.preview.mindustry",
"name": "Mindustry",
"summary": "Build a factory. Defend your world.",
"description": "Build a factory. Defend your world.",
"developer": "Anuken",
"license": "GPL-3.0",
"free": true,
"downloadable": true,
"version": "1.2",
"version_code": 1,
"min_sdk": 26,
"abis": [
"arm64-v8a"
],
"vr": false,
"updated": "2026-09-26",
"size": 85000000,
"popularity": 92,
"images": {
"banner": "https://f-droid.org/repo/io.anuke.mindustry/en-US/phoneScreenshots/1.png",
"icon": "https://f-droid.org/repo/io.anuke.mindustry/en-US/icon_Eno3XvqCZUcHRm3eMjiUleAxgzLopPe6-hkI7BHx1lU=.png",
"screenshots": [
"https://f-droid.org/repo/io.anuke.mindustry/en-US/phoneScreenshots/1.png"
]
},
"icon": "https://f-droid.org/repo/io.anuke.mindustry/en-US/icon_Eno3XvqCZUcHRm3eMjiUleAxgzLopPe6-hkI7BHx1lU=.png"
},
{
"id": "luanti",
"package": "org.preview.luanti",
"name": "Luanti",
"summary": "A world of blocks. Endless possibilities.",
"description": "A world of blocks. Endless possibilities.",
"developer": "Luanti contributors",
"license": "LGPL-2.1",
"free": true,
"downloadable": true,
"version": "1.3",
"version_code": 1,
"min_sdk": 26,
"abis": [
"arm64-v8a"
],
"vr": false,
"updated": "2026-09-25",
"size": 85000000,
"popularity": 84,
"images": {
"banner": "https://opengraph.githubassets.com/1/luanti-org/luanti",
"icon": "https://raw.githubusercontent.com/luanti-org/luanti/master/textures/base/pack/logo.png",
"screenshots": [
"https://opengraph.githubassets.com/1/luanti-org/luanti"
]
},
"icon": "https://raw.githubusercontent.com/luanti-org/luanti/master/textures/base/pack/logo.png"
},
{
"id": "pod",
"package": "org.preview.pod",
"name": "AntennaPod",
"summary": "Your favorite stories, wherever you listen.",
"description": "Your favorite stories, wherever you listen.",
"developer": "AntennaPod contributors",
"license": "GPL-3.0",
"free": true,
"downloadable": true,
"version": "1.4",
"version_code": 1,
"min_sdk": 26,
"abis": [
"arm64-v8a"
],
"vr": false,
"updated": "2026-09-24",
"size": 85000000,
"popularity": 76,
"images": {
"banner": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/phoneScreenshots/00.png",
"icon": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/icon_w44b41PyuNt3pI7Gh8zYHJrWgu__3HT7YSWZtttfenk=.png",
"screenshots": [
"https://f-droid.org/repo/de.danoeh.antennapod/en-US/phoneScreenshots/00.png"
]
},
"icon": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/icon_w44b41PyuNt3pI7Gh8zYHJrWgu__3HT7YSWZtttfenk=.png"
},
{
"id": "newpipe",
"package": "org.preview.newpipe",
"name": "NewPipe",
"summary": "Your videos and music, without the distractions.",
"description": "Your videos and music, without the distractions.",
"developer": "Team NewPipe",
"license": "GPL-3.0",
"free": true,
"downloadable": true,
"version": "1.5",
"version_code": 1,
"min_sdk": 26,
"abis": [
"arm64-v8a"
],
"vr": false,
"updated": "2026-09-23",
"size": 85000000,
"popularity": 68,
"images": {
"banner": "https://f-droid.org/repo/org.schabi.newpipe/en-US/phoneScreenshots/00.png",
"icon": "https://f-droid.org/repo/org.schabi.newpipe/en-US/icon_OHy4y1W-fJCNhHHOBCM9V_cxZNJJgbcNkB-x7UDTY9Q=.png",
"screenshots": [
"https://f-droid.org/repo/org.schabi.newpipe/en-US/phoneScreenshots/00.png"
]
},
"icon": "https://f-droid.org/repo/org.schabi.newpipe/en-US/icon_OHy4y1W-fJCNhHHOBCM9V_cxZNJJgbcNkB-x7UDTY9Q=.png"
},
{
"id": "kart",
"package": "org.preview.kart",
"name": "SuperTuxKart",
"summary": "A little friendly competition. A lot of colorful chaos.",
"description": "A little friendly competition. A lot of colorful chaos.",
"developer": "SuperTuxKart Team",
"license": "GPL-3.0",
"free": true,
"downloadable": false,
"version": "1.6",
"version_code": 1,
"min_sdk": 26,
"abis": [
"arm64-v8a"
],
"vr": false,
"updated": "2026-09-22",
"size": 85000000,
"popularity": 60,
"images": {
"banner": "https://opengraph.githubassets.com/1/supertuxkart/stk-code",
"icon": null,
"screenshots": [
"https://opengraph.githubassets.com/1/supertuxkart/stk-code"
]
},
"icon": null,
"page": "https://supertuxkart.net"
}
]
+58
View File
@@ -0,0 +1,58 @@
"""Local store preview. No device access; installation progress is simulated.
FRAME_APK_SEARCH_DEMO=1 python3 tests/search_preview.py
"""
import json
import os
from pathlib import Path
import sys
import tempfile
import time
from urllib.parse import urlparse
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
import server
from apk_sources import _demo, _images, search
class Preview(server.Handler):
def do_GET(self):
path = urlparse(self.path).path
if path == '/api/android':
self.send_json({'apps': []})
return
if path == '/api/android/reports':
self.send_json({'reports': [], 'shared': False})
return
if path not in ('/', '/index.html', '/api/search', '/api/sources', '/api/sources/details', '/api/job', '/api/host') and not path.startswith('/source-image/'):
self.send_json({'error': 'Headset disconnected', 'offline': True}, 503)
return
super().do_GET()
def do_POST(self):
if not self.local_request():
return
if urlparse(self.path).path == '/api/sources/install':
body = json.loads(self.rfile.read(int(self.headers.get('Content-Length', 0))))
def work(report):
for percent in (12, 28, 43, 67, 89):
report('Downloading', percent)
time.sleep(2)
report('Installing', None)
time.sleep(3)
return {'package': 'org.preview.' + body['id'], 'message': 'Preview installation complete'}
self.send_json(server.start_job('Preview installation', work, progress=True))
return
if urlparse(self.path).path == '/api/sources':
super().do_POST()
return
self.send_json({'error': 'Device access disabled in store preview'}, 403)
if __name__ == '__main__':
if os.environ.get('FRAME_APK_SEARCH_DEMO') != '1':
sys.exit('Set FRAME_APK_SEARCH_DEMO=1')
for name, url in json.loads((_demo.FIXTURES / 'artwork' / 'urls.json').read_text()).items():
_images.remember(url, (_demo.FIXTURES / 'artwork' / name).read_bytes())
with tempfile.TemporaryDirectory(prefix='frame-store-preview-') as tmp:
search.settings_path = lambda: Path(tmp) / 'enabled.json'
server.ThreadingHTTPServer(('127.0.0.1', 8795), Preview).serve_forever()
+101
View File
@@ -0,0 +1,101 @@
import http.client
import json
from pathlib import Path
import socket
import sys
import threading
import unittest
from unittest.mock import patch, Mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
from apk_sources import _images, search, SourceError
import server
PNG = (Path(__file__).parent / 'fixtures/apk-search/artwork/brush-icon.png').read_bytes()
class ArtworkTests(unittest.TestCase):
def setUp(self):
with _images._lock:
_images._urls.clear()
_images._cache.clear()
def test_only_registered_source_images_are_fetchable(self):
with patch.object(_images, 'fetch') as fetch:
with self.assertRaisesRegex(SourceError, 'Unknown artwork'):
_images.image('https://example.com/arbitrary.png')
fetch.assert_not_called()
entry = {'images': {'icon': 'https://example.com/icon.png', 'banner': 'file:///tmp/private',
'screenshots': ['https://example.com/shot.png', 'javascript:alert(1)']}}
art = _images.artwork(entry)
self.assertTrue(art['icon'].startswith('/source-image/'))
self.assertIsNone(art['banner'])
self.assertEqual(len(art['screenshots']), 1)
with patch.object(_images, 'fetch', return_value=(PNG, 'image/png')) as fetch:
self.assertEqual(_images.image(art['icon'].split('/')[-1]), (PNG, 'image/png'))
_images.image(art['icon'].split('/')[-1])
fetch.assert_called_once_with('https://example.com/icon.png')
def test_rejects_credentials_ports_and_non_http(self):
for url in ['file:///tmp/a.png', 'data:image/png;base64,AAAA', 'http://user:pass@example.com/a.png',
'http://example.com:22/a.png', 'https://example.com:bad/a.png', '//example.com/a.png']:
self.assertIsNone(_images.register(url), url)
def test_blocks_private_loopback_and_mixed_dns_answers(self):
for ip in ['127.0.0.1', '10.0.0.1', '169.254.169.254', '::1', '192.168.1.1']:
with patch.object(socket, 'getaddrinfo', return_value=[(2,1,6,'',(ip,443))]), \
patch.object(socket, 'create_connection') as connect:
with self.assertRaisesRegex(SourceError, 'Private network'):
_images.fetch('https://example.com/private.png')
connect.assert_not_called()
def test_redirect_to_private_network_is_rejected(self):
response = Mock(status=302)
response.getheader.return_value = 'http://127.0.0.1/secret'
conn = Mock()
conn.getresponse.return_value = response
public = [(2,1,6,'',('93.184.216.34',80))]
private = [(2,1,6,'',('127.0.0.1',80))]
with patch.object(socket, 'getaddrinfo', side_effect=[public,private]), \
patch.object(socket, 'create_connection') as connect, \
patch.object(http.client, 'HTTPConnection', return_value=conn):
with self.assertRaisesRegex(SourceError, 'Private network'):
_images.fetch('http://example.com/a.png')
connect.assert_called_once_with(('93.184.216.34',80),timeout=10)
def test_non_images_and_oversized_images_are_rejected(self):
with self.assertRaises(SourceError):
_images.remember('https://example.com/a.svg', b'<svg onload="evil()"/>')
with self.assertRaisesRegex(SourceError, 'too large'):
_images.remember('https://example.com/a.png', PNG[:8] + b'x' * _images.MAX_IMAGE)
def test_handles_are_bounded(self):
for i in range(4100):
_images.register('https://example.com/%d.png' % i)
self.assertEqual(len(_images._urls),4096)
def test_plain_language_verdict_is_evidence_based(self):
self.assertEqual(search.verdict({})['label'], 'Not yet checked on the Frame')
self.assertEqual(search.verdict({'min_sdk':24,'abis':[]})['label'], 'Ready to try on the Frame')
self.assertEqual(search.verdict({'min_sdk':24,'abis':[],'frame_tested':True})['label'], 'Works on the Frame')
self.assertIn('newer Android', search.verdict({'min_sdk':31})['label'])
self.assertIn('Meta Quest services', search.verdict({'requires_meta_services':True})['label'])
self.assertEqual(search.verdict({'engine':'VrApi'})['tone'],'blocked')
self.assertNotEqual(search.verdict({'frame_tested':True,'min_sdk':31})['tone'],'works')
def test_image_endpoint_does_not_allow_arbitrary_urls(self):
httpd = server.ThreadingHTTPServer(('127.0.0.1',0),server.Handler)
threading.Thread(target=httpd.serve_forever,daemon=True).start()
try:
path = _images.register('https://example.com/app.png')
_images.remember('https://example.com/app.png',PNG)
for url, expected in [(path,200),('/source-image/unknown',404)]:
c=http.client.HTTPConnection('127.0.0.1',httpd.server_port)
c.request('GET',url)
r=c.getresponse();data=r.read();c.close()
self.assertEqual(r.status,expected)
if expected==200:
self.assertEqual(data,PNG)
self.assertEqual(r.getheader('Content-Type'),'image/png')
finally:
httpd.shutdown();httpd.server_close()
+207
View File
@@ -0,0 +1,207 @@
import http.client
import json
from pathlib import Path
import sys
import tempfile
import threading
import time
import types
import unittest
from unittest.mock import Mock, patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
from apk_sources import search, SourceError
import server
ENTRIES = json.loads((Path(__file__).parent / 'fixtures/apk-search/entries.json').read_text())
def fake(source_id='one', fn=None):
return types.SimpleNamespace(KIND=source_id, sources=lambda: [dict(id=source_id, name=source_id,
enabled=True, trust='official', builtin=True)],
search=fn or (lambda s, q, limit=50: [e for e in ENTRIES if e['source'] == source_id]),
details=lambda s, i: ENTRIES[0],
download=Mock(return_value={'apk': '/fake.apk', 'obb': []}))
class SettingsTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
p = patch.object(search, 'settings_path', return_value=Path(self.tmp.name) / 'enabled.json')
p.start()
self.addCleanup(p.stop)
search._running.clear()
search._status.clear()
class SearchTests(SettingsTest):
def test_group_does_not_merge_distinct_or_unknown_packages(self):
result = search.group(ENTRIES)
self.assertEqual(len(result), 3)
self.assertEqual(sorted(len(a['offers']) for a in result), [1, 2, 2])
same_name = dict(ENTRIES[0], package=None)
self.assertEqual(len(search.group(ENTRIES[:1] + [same_name])), 2)
def test_rank_exact_and_installable_and_verified(self):
result = search.group(ENTRIES, 'Open Brush')
self.assertEqual(result[0]['package'], 'org.brush')
self.assertEqual(result[0]['offers'][0]['source'], 'one')
self.assertEqual(result[1]['package'], 'org.other')
self.assertEqual(len(search.group(ENTRIES, vr=False)), 1)
self.assertEqual(len(search.group(ENTRIES, installable=True)), 1)
def test_fit_unknown_and_native_free_and_vr_hints(self):
self.assertIsNone(search.fit({})['installable'])
self.assertTrue(search.fit({'min_sdk': 23, 'abis': []})['installable'])
self.assertFalse(search.fit({'min_sdk': 23, 'abis': ['x86']})['installable'])
self.assertIn('Legacy VrApi', search.fit({'engine': 'VrApi'})['reasons'][0])
def test_timeout_and_failure_leave_other_results(self):
release = threading.Event()
calls = []
def slow(s, q, limit=50):
calls.append(q)
release.wait(2)
return []
mods = [fake(), fake('slow', slow), fake('broken', Mock(side_effect=SourceError('offline')))]
try:
with patch.object(search, 'modules', return_value=(mods, [])):
started = time.monotonic()
result = search.search(timeout=.03)
self.assertLess(time.monotonic() - started, .3)
self.assertTrue(result['apps'])
self.assertEqual([s['status'] for s in result['sources']], ['ok', 'timed out', 'error'])
search.search('other', timeout=.03)
self.assertEqual(calls, [''])
finally:
release.set()
def test_disable_persists_and_prevents_queries_and_installs(self):
mod = fake()
with patch.object(search, 'modules', return_value=([mod], [])):
search.set_enabled('one', False)
self.assertFalse(search.sources()[0]['enabled'])
self.assertEqual(search.search()['apps'], [])
with self.assertRaisesRegex(SourceError, 'disabled'):
search.install('one', 'brush')
def test_install_passes_metadata_artwork_and_obb(self):
mod = fake()
mod.download.return_value.update(obb=['main.obb'], artwork={'hero': '/hero.png'}, icon_png=b'png')
def install(apk, name=None, icon_png=None, source=None, artwork=None):
self.assertEqual((apk, name, icon_png, source, artwork),
('/fake.apk', 'Open Brush', b'png', 'one', {'hero': '/hero.png'}))
return {'package': 'org.brush'}
with patch.object(search, 'modules', return_value=([mod], [])), \
patch.object(server.frame_android, 'install_obb', create=True) as obb:
# An actual function exposes the future signature for inspection.
with patch.object(server.frame_android, 'install', install):
search.install('one', 'brush', 1)
obb.assert_called_once_with('org.brush', ['main.obb'])
mod.download.assert_called_once_with(mod.sources()[0] | {'status': 'not searched'}, 'brush', version_code=1)
def test_discovery_and_demo_are_opt_in(self):
module = fake()
with patch.object(search.pkgutil, 'iter_modules', return_value=[types.SimpleNamespace(name='example')]), \
patch.object(search.importlib, 'import_module', return_value=module), \
patch.dict(search.os.environ, {'FRAME_APK_SEARCH_DEMO': '0'}):
self.assertEqual(search.modules(), ([module], []))
with patch.object(search.pkgutil, 'iter_modules', return_value=[]), \
patch.dict(search.os.environ, {'FRAME_APK_SEARCH_DEMO': '0'}):
self.assertEqual(search.modules(), ([], []))
def test_newest_compatible_then_official_offer(self):
first = dict(ENTRIES[0], verified=False, trust='community')
newer = dict(first, source='new', version_code=2, updated='2026-01-01')
official = dict(newer, source='official', trust='official')
incompatible = dict(newer, source='blocked', verified=True, min_sdk=40)
offers = search.group([first, incompatible, newer, official])[0]['offers']
self.assertEqual([e['source'] for e in offers], ['official', 'new', 'one', 'blocked'])
def test_missing_obb_support_stops_before_install(self):
mod = fake()
mod.download.return_value['obb'] = ['main.obb']
with patch.object(search, 'modules', return_value=([mod], [])), \
patch.object(server.frame_android, 'install') as install, \
patch.dict(server.frame_android.__dict__):
server.frame_android.__dict__.pop('install_obb', None)
with self.assertRaisesRegex(SourceError, 'OBB'):
search.install('one', 'brush')
install.assert_not_called()
def test_listing_cannot_download(self):
mod = fake()
mod.details = lambda s, i: dict(ENTRIES[0], downloadable=False)
with patch.object(search, 'modules', return_value=([mod], [])):
with self.assertRaisesRegex(SourceError, 'developer page'):
search.install('one', 'brush')
mod.download.assert_not_called()
class EndpointTests(SettingsTest):
def setUp(self):
super().setUp()
self.mod = fake()
p = patch.object(search, 'modules', return_value=([self.mod], []))
p.start()
self.addCleanup(p.stop)
self.httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), server.Handler)
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
self.addCleanup(self.httpd.server_close)
self.addCleanup(self.httpd.shutdown)
def request(self, method, path, body=None):
c = http.client.HTTPConnection('127.0.0.1', self.httpd.server_port)
c.request(method, path, json.dumps(body) if body is not None else None,
{'X-Frame-UI': '1', 'Content-Type': 'application/json'})
r = c.getresponse()
result = r.status, json.loads(r.read())
c.close()
return result
def test_http_search_and_validation(self):
self.assertEqual(self.request('GET', '/api/sources')[1]['sources'][0]['id'], 'one')
self.assertTrue(self.request('GET', '/api/search?q=Brush&vr=true')[1]['apps'])
self.assertEqual(self.request('GET', '/api/search?vr=invalid')[0], 400)
self.assertEqual(self.request('GET', '/api/search?source=missing')[0], 400)
for body in ({'source': 'one'}, {'source': 'one', 'id': 'brush', 'version_code': True}):
self.assertEqual(self.request('POST', '/api/sources/install', body)[0], 400)
def test_http_install_background_job(self):
with patch.object(server.frame_android, 'install', return_value={'package': 'org.brush'}) as install:
status, reply = self.request('POST', '/api/sources/install', {'source': 'one', 'id': 'brush'})
self.assertEqual(status, 200)
for _ in range(100):
job = self.request('GET', '/api/job?id=' + reply['job'])[1]
if job['done']:
break
time.sleep(.01)
self.assertTrue(job['done'])
self.assertIsNone(job['error'])
install.assert_called_once_with('/fake.apk', name='Open Brush', icon_png=None, source='one')
def test_details_endpoint_and_real_install_stages(self):
code, entry = self.request('GET', '/api/sources/details?source=one&id=brush')
self.assertEqual(code, 200)
self.assertEqual(entry['name'], 'Open Brush')
self.assertEqual(entry['verdict']['label'], 'Ready to try on the Frame')
self.assertIn('artwork', entry)
self.assertEqual(self.request('GET', '/api/sources/details?source=one')[0], 400)
stages = []
with patch.object(server.frame_android, 'install', return_value={'package':'org.brush'}):
search.install('one', 'brush', progress=lambda stage, percent: stages.append((stage,percent)))
self.assertEqual(stages, [('Downloading',None),('Installing',None)])
def test_http_repository_management(self):
self.assertEqual(self.request('POST', '/api/sources', {'action': 'enable', 'source': 'one', 'enabled': False})[0], 200)
code, reply = self.request('POST', '/api/sources', {'action': 'add', 'url': 'https://repo.example/repo'})
self.assertEqual(code, 400)
self.assertIn('not available', reply['error'])
mod = fake('fdroid')
mod.add_repo, mod.remove_repo, mod.set_enabled = Mock(), Mock(), Mock()
with patch.object(search, 'modules', return_value=([mod], [])):
self.assertEqual(self.request('POST', '/api/sources', {'action': 'add', 'url': 'https://repo.example/repo'})[0], 200)
mod.add_repo.assert_called_once_with(url='https://repo.example/repo', fingerprint=None, name=None)
self.assertEqual(self.request('POST', '/api/sources', {'action': 'remove', 'source': 'fdroid'})[0], 200)
mod.remove_repo.assert_called_once_with(source_id='fdroid')