Merge branch 'apk-search' into apk-store
No files matched your search
@@ -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.
|
||||
|
After Width: | Height: | Size: 489 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 275 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 559 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 586 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 821 KiB |
@@ -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"
|
||||
}
|
||||
@@ -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}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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')
|
||||