diff --git a/.github/scripts/generate_update_manifest.py b/.github/scripts/generate_update_manifest.py
new file mode 100644
index 000000000..79332242d
--- /dev/null
+++ b/.github/scripts/generate_update_manifest.py
@@ -0,0 +1,113 @@
+#!/usr/bin/env python3
+"""Create the direct-update manifest from already-verified release inputs. No publication."""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import html
+import json
+import re
+from pathlib import Path
+
+from prepare_release import read_app_version
+import update_changelog
+
+
+def listener_markdown(fragment: str) -> str:
+ """Preserve authored emphasis and links from the generated listener HTML."""
+ fragment = re.sub(r']*href=[\'"]([^\'"]+)[\'"][^>]*>(.*?)',
+ lambda m: '[' + re.sub(r'<[^>]+>', '', m[2]) + '](' + html.unescape(m[1]) + ')',
+ fragment, flags=re.DOTALL | re.IGNORECASE)
+ for tag, marker in (("b", "**"), ("strong", "**"), ("i", "*"), ("em", "*"), ("code", "`")):
+ fragment = re.sub(r'?' + tag + r'\b[^>]*>', lambda _: marker, fragment, flags=re.IGNORECASE)
+ fragment = re.sub(r'
', '\n', fragment, flags=re.IGNORECASE)
+ return html.unescape(re.sub(r'<[^>]+>', '', fragment)).strip()
+
+
+def listener_notes(readme: str, tag: str | None = None) -> str:
+ region = update_changelog._extract_marked_region(
+ readme, update_changelog.RELEASE_WHATS_NEW_START,
+ update_changelog.RELEASE_WHATS_NEW_END,
+ )
+ if not region:
+ return ""
+ metadata = update_changelog._parse_release_meta(region)
+ if tag and metadata and metadata[0] != tag:
+ return "" # An artifacts-only release must not claim the previous release's notes.
+ # Preserve every authored listener bullet, without shortening or rewriting.
+ return "\n".join("- " + listener_markdown(bullet) for bullet in re.findall(
+ r"
, then serve that APK and reverse adb port 8765.
+"""
+import argparse
+import hashlib
+import json
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+
+DEFAULT_NOTES = 'Isolated update test. No release was published and no alert was sent.'
+
+
+def manifest(apk, code, state='available', notes=DEFAULT_NOTES, upcoming=""):
+ value = dict(schemaVersion=1, packageName='cx.aswin.boxlore', versionCode=code,
+ versionName=f'Test {code}', minSdk=99 if state == 'incompatible' else 31,
+ apkUrl='http://127.0.0.1:8765/candidate.apk',
+ apkSha256='0' * 64 if state == 'corrupt' else hashlib.sha256(apk.read_bytes()).hexdigest(),
+ apkBytes=apk.stat().st_size, notesUrl='http://127.0.0.1:8765/notes',
+ notes=notes, upcoming=upcoming, testOnly=True)
+ if len(notes.encode('utf-16-le')) // 2 > 24_000:
+ raise ValueError('Release notes exceed the app limit of 24,000 characters')
+ if len(json.dumps(value, ensure_ascii=False).encode('utf-8')) > 64 * 1024:
+ raise ValueError('The update manifest exceeds the app limit of 64 KiB')
+ return value
+
+
+def handler(apk, code, state, notes=DEFAULT_NOTES, upcoming=""):
+ class Handler(BaseHTTPRequestHandler):
+ def do_GET(self):
+ if self.path == '/update.json':
+ if state == 'unavailable':
+ self.send_error(404); return
+ data = json.dumps(manifest(apk, code, state, notes, upcoming), ensure_ascii=False).encode('utf-8')
+ self.send_response(200); self.send_header('Content-Type', 'application/json')
+ self.send_header('Content-Length', str(len(data))); self.end_headers(); self.wfile.write(data)
+ elif self.path == '/candidate.apk':
+ size = apk.stat().st_size; start = 0
+ if self.headers.get('Range'):
+ try: start = int(self.headers['Range'].removeprefix('bytes=').removesuffix('-'))
+ except ValueError: self.send_error(400); return
+ if start >= size or start < 0: self.send_error(416); return
+ self.send_response(206 if start else 200)
+ if start: self.send_header('Content-Range', f'bytes {start}-{size-1}/{size}')
+ self.send_header('Content-Type', 'application/vnd.android.package-archive')
+ self.send_header('Content-Length', str(size-start)); self.end_headers()
+ try:
+ with apk.open('rb') as stream:
+ stream.seek(start)
+ while chunk := stream.read(32768): self.wfile.write(chunk)
+ except (BrokenPipeError, ConnectionResetError): pass
+ elif self.path == '/notes':
+ data = notes.encode('utf-8')
+ self.send_response(200)
+ self.send_header('Content-Type', 'text/plain; charset=utf-8')
+ self.send_header('Content-Length', str(len(data)))
+ self.end_headers(); self.wfile.write(data)
+ else: self.send_error(404)
+ return Handler
+
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--apk', type=Path, required=True)
+ parser.add_argument('--version-code', type=int, required=True)
+ parser.add_argument('--state', choices=('available','unavailable','incompatible','corrupt'), default='available')
+ parser.add_argument('--notes-file', type=Path, help='UTF-8 sample release notes for local scrolling tests')
+ parser.add_argument('--upcoming-file', type=Path, help='UTF-8 local unreleased preview')
+ args = parser.parse_args()
+ if not args.apk.is_file() or args.version_code <= 0: parser.error('Use an existing signed test APK and a positive version code')
+ try:
+ notes = args.notes_file.read_text(encoding='utf-8') if args.notes_file else DEFAULT_NOTES
+ upcoming = args.upcoming_file.read_text(encoding='utf-8') if args.upcoming_file else ''
+ if len(upcoming.encode('utf-16-le')) // 2 > 6_000: raise ValueError('Upcoming preview exceeds 6,000 characters')
+ manifest(args.apk, args.version_code, args.state, notes, upcoming)
+ except (OSError, UnicodeError, ValueError) as error:
+ parser.error(str(error))
+ print('Local test feed: http://127.0.0.1:8765/update.json; use adb reverse tcp:8765 tcp:8765', flush=True)
+ ThreadingHTTPServer(('127.0.0.1', 8765), handler(args.apk, args.version_code, args.state, notes, upcoming)).serve_forever()
diff --git a/.github/scripts/sync_announcement_preview.py b/.github/scripts/sync_announcement_preview.py
new file mode 100644
index 000000000..e5bdb50c0
--- /dev/null
+++ b/.github/scripts/sync_announcement_preview.py
@@ -0,0 +1,23 @@
+"""Sync the app font for the browser preview; native Compose and browser markup remain independent."""
+import argparse
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+
+def sync(check=False):
+ sources = {'font.ttf': ROOT / 'core/designsystem/src/main/res/font/google_sans_flex_variable.ttf'}
+ target = ROOT / 'admin-panel/public/notifyappusers/announcements'
+ if not check:
+ target.mkdir(parents=True, exist_ok=True)
+ for name, source in sources.items():
+ destination = target / name
+ if check:
+ if not destination.exists() or destination.read_bytes() != source.read_bytes():
+ raise ValueError(f'Announcement preview differs: {name}; run sync_announcement_preview.py')
+ else:
+ destination.write_bytes(source.read_bytes())
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--check', action='store_true')
+ sync(parser.parse_args().check)
diff --git a/.github/scripts/test_announcements.py b/.github/scripts/test_announcements.py
new file mode 100644
index 000000000..9e99293d8
--- /dev/null
+++ b/.github/scripts/test_announcements.py
@@ -0,0 +1,140 @@
+import json
+import hashlib
+import sys
+import subprocess
+import os
+import types
+import shlex
+import io
+import unittest
+from pathlib import Path
+from unittest.mock import Mock, patch
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+import manual_dispatch as sender
+import sync_announcement_preview
+import serve_update_test
+import preview_announcement_on_device
+
+class AnnouncementContractsTest(unittest.TestCase):
+ def arguments(self):
+ return sender.argument_parser().parse_args(['--title','Test','--body','Exact **copy**','--type','in-app','--target','test_users','--test-mode','true'])
+ def test_browser_preview_uses_the_app_font(self):
+ sync_announcement_preview.sync(check=True)
+ def test_real_delivery_requires_approval_for_exact_draft(self):
+ args=self.arguments(); transport=Mock(return_value='test')
+ with self.assertRaisesRegex(ValueError,'approve'):sender.send_notification(args,sender=transport)
+ transport.assert_not_called()
+ args.approval_digest=sender.plan_digest(sender.notification_plan(args))
+ sender.send_notification(args,sender=transport)
+ args.body='Edited copy'
+ with self.assertRaises(ValueError):sender.send_notification(args,sender=transport)
+ self.assertEqual(transport.call_count,1)
+ def test_test_audience_accepted_and_release_filter_requires_explicit_override(self):
+ args=self.arguments(); self.assertEqual(sender.notification_plan(args)['topic'],'test_users')
+ args.test_mode='false';args.target='all_users';args.release_alert='true'
+ with self.assertRaises(ValueError):sender.notification_plan(args)
+ args.include_play='true';self.assertEqual(sender.notification_plan(args)['data']['include_play'],'true')
+ def test_preview_contains_current_digest_and_never_calls_sender(self):
+ args=self.arguments();args.preview_only='true';transport=Mock()
+ result=sender.send_notification(args,sender=transport)
+ transport.assert_not_called();self.assertEqual(result['approval_digest'],sender.plan_digest(sender.notification_plan(args)))
+ def test_browser_and_workflow_approval_digest_are_identical(self):
+ args=self.arguments();args.title='boxlore – 更新';args.body='Exact \n **é** copy'
+ plan=sender.notification_plan(args)
+ node_script="const {canonical}=require('./admin-panel/public/notifyappusers/dashboard.js');const crypto=require('node:crypto');let data='';process.stdin.on('data',x=>data+=x).on('end',()=>process.stdout.write(crypto.createHash('sha256').update(JSON.stringify(canonical(JSON.parse(data)))).digest('hex')));"
+ result=subprocess.run(['node','-e',node_script],input=json.dumps(plan,ensure_ascii=False),text=True,capture_output=True,check=True,cwd=Path(__file__).resolve().parents[2])
+ self.assertEqual(result.stdout,sender.plan_digest(plan))
+ def test_direct_sender_excludes_play_and_test_topics_before_delivery(self):
+ args=self.arguments();args.target='direct_users';args.test_mode='false'
+ plan=sender.notification_plan(args)
+ messaging=Mock();messaging.send.return_value='fixture-id'
+ firebase=types.ModuleType('firebase_admin');firebase.initialize_app=Mock();firebase.credentials=Mock();firebase.messaging=messaging
+ with patch.dict(sys.modules,{'firebase_admin':firebase}),patch.dict(os.environ,{'FIREBASE_CREDENTIALS':'{}'}):
+ sender._firebase_send(plan)
+ delivered=messaging.Message.call_args.kwargs
+ self.assertNotIn('topic',delivered)
+ self.assertEqual("'direct_users' in topics && !('play_users' in topics) && !('test_users' in topics)",delivered['condition'])
+ def test_ordinary_sender_default_does_not_use_legacy_play_suppressed_release_category(self):
+ plan=sender.notification_plan(self.arguments())
+ self.assertEqual('ANNOUNCEMENT',plan['data']['category'])
+ self.assertEqual('false',plan['data']['release_alert'])
+ def test_insecure_image_is_rejected_without_dispatch(self):
+ args=self.arguments();args.image='http://example.com/image.png'
+ with self.assertRaisesRegex(ValueError,'HTTPS'):sender.notification_plan(args)
+ def test_local_device_preview_uses_only_adb_and_requires_receiver_success(self):
+ import tempfile
+ with tempfile.TemporaryDirectory() as directory:
+ data={'title':'Local test','body':"Listener's `copy` with $(symbols) and Unicode 更新"}
+ path=Path(directory)/'preview.json';path.write_text(json.dumps({'data':data}))
+ runner=Mock(return_value=subprocess.CompletedProcess([],0,'Broadcast completed: result=-1',''))
+ preview_announcement_on_device.preview(path,runner=runner)
+ command=runner.call_args.args[0]
+ remote=shlex.split(command[-1])
+ self.assertEqual('adb',command[0]);self.assertEqual('shell',command[-2])
+ self.assertIn('cx.aswin.boxlore/.testing.AnnouncementTestReceiver',remote)
+ self.assertEqual(data,json.loads(remote[-1]))
+ runner.return_value=subprocess.CompletedProcess([],0,'Broadcast completed: result=0','')
+ with self.assertRaisesRegex(ValueError,'isolated'):preview_announcement_on_device.preview(path,runner=runner)
+ def test_loopback_fixture_is_explicitly_marked_test_only(self):
+ import tempfile
+ with tempfile.TemporaryDirectory() as directory:
+ path=Path(directory)/'test.apk';path.write_bytes(b'fixture')
+ value=serve_update_test.manifest(path,29)
+ self.assertTrue(value['testOnly']);self.assertEqual(value['apkUrl'],'http://127.0.0.1:8765/candidate.apk')
+ def test_local_update_fixture_serves_exact_bytes_and_resumes_a_partial_download(self):
+ import tempfile
+ class Connection:
+ def __init__(self,request): self.request=request;self.response=bytearray()
+ def makefile(self,*args): return io.BytesIO(self.request)
+ def sendall(self,data): self.response.extend(data)
+ with tempfile.TemporaryDirectory() as directory:
+ path=Path(directory)/'test.apk';path.write_bytes(b'approved-candidate-bytes')
+ Handler=serve_update_test.handler(path,29,'available')
+ Handler.log_message=lambda *args:None
+ for headers,status,expected in [(b'',b'200',path.read_bytes()),(b'Range: bytes=9-\r\n',b'206',path.read_bytes()[9:])]:
+ connection=Connection(b'GET /candidate.apk HTTP/1.0\r\n'+headers+b'\r\n')
+ Handler(connection,('127.0.0.1',0),Mock())
+ header,body=bytes(connection.response).split(b'\r\n\r\n',1)
+ self.assertIn(status,header.split(b'\r\n')[0]);self.assertEqual(expected,body)
+ def test_long_local_notes_are_preserved_in_manifest_and_browser_view(self):
+ import tempfile
+ class Connection:
+ def __init__(self, route): self.route=route;self.response=bytearray()
+ def makefile(self,*args): return io.BytesIO(f'GET {self.route} HTTP/1.0\r\n\r\n'.encode())
+ def sendall(self,data): self.response.extend(data)
+ with tempfile.TemporaryDirectory() as directory:
+ path=Path(directory)/'test.apk';path.write_bytes(b'candidate')
+ notes='Sample notes — scrolling test.\n'*500+'End of test release notes.'
+ Handler=serve_update_test.handler(path,29,'available',notes);Handler.log_message=lambda *args:None
+ for route in ('/update.json','/notes'):
+ connection=Connection(route);Handler(connection,('127.0.0.1',0),Mock())
+ header,body=bytes(connection.response).split(b'\r\n\r\n',1)
+ self.assertIn(b'200',header.split(b'\r\n')[0])
+ self.assertEqual(notes,json.loads(body)['notes'] if route=='/update.json' else body.decode('utf-8'))
+ def test_local_notes_respect_android_character_and_utf8_response_limits(self):
+ import tempfile
+ with tempfile.TemporaryDirectory() as directory:
+ path=Path(directory)/'test.apk';path.write_bytes(b'candidate')
+ for notes in ('a'*24_001,'🎧'*12_001):
+ with self.assertRaisesRegex(ValueError,'24,000'):serve_update_test.manifest(path,29,notes=notes)
+ with self.assertRaisesRegex(ValueError,'64 KiB'):serve_update_test.manifest(path,29,notes='界'*23_000)
+ def test_local_update_fixture_exposes_error_incompatible_and_integrity_test_states(self):
+ import tempfile
+ class Connection:
+ def __init__(self): self.response=bytearray()
+ def makefile(self,*args): return io.BytesIO(b'GET /update.json HTTP/1.0\r\n\r\n')
+ def sendall(self,data): self.response.extend(data)
+ with tempfile.TemporaryDirectory() as directory:
+ path=Path(directory)/'test.apk';path.write_bytes(b'candidate')
+ for state in ('unavailable','incompatible','corrupt'):
+ Handler=serve_update_test.handler(path,29,state);Handler.log_message=lambda *args:None
+ connection=Connection();Handler(connection,('127.0.0.1',0),Mock())
+ header,body=bytes(connection.response).split(b'\r\n\r\n',1)
+ if state=='unavailable':self.assertIn(b'404',header.split(b'\r\n')[0])
+ elif state=='incompatible':self.assertEqual(99,json.loads(body)['minSdk'])
+ else:self.assertNotEqual(hashlib.sha256(path.read_bytes()).hexdigest(),json.loads(body)['apkSha256'])
+ def test_release_publication_only_previews_and_play_bundle_remains_separate(self):
+ workflow=(Path(__file__).resolve().parents[2]/'.github/workflows/changelog-on-merge.yml').read_text()
+ announce=workflow.split(' announce-release:')[1]
+ self.assertIn("preview_only: 'true'",announce);self.assertIn('target: direct_users',announce)
+ self.assertIn('bundlePlayRelease',workflow)
diff --git a/.github/scripts/test_manual_dispatch.py b/.github/scripts/test_manual_dispatch.py
new file mode 100644
index 000000000..69e2281f8
--- /dev/null
+++ b/.github/scripts/test_manual_dispatch.py
@@ -0,0 +1,106 @@
+"""Hermetic sender tests: no Firebase account and no real messages."""
+
+import json
+import os
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import Mock, patch
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+import manual_dispatch as sender
+
+
+def args(*extra):
+ parsed = sender.argument_parser().parse_args([
+ "--title", "boxlore test", "--body", "First line\\nSecond line", "--type", "both", *extra,
+ ])
+ if parsed.dry_run == "false" and parsed.preview_only == "false":
+ parsed.approval_digest = sender.plan_digest(sender.notification_plan(parsed))
+ return parsed
+
+
+class ManualDispatchTest(unittest.TestCase):
+ def test_offline_preview_never_calls_transport(self):
+ transport = Mock(side_effect=AssertionError("Preview must not send"))
+ with tempfile.TemporaryDirectory() as temp:
+ output = Path(temp) / "preview.json"
+ result = sender.send_notification(args("--preview-only", "true", "--preview-output", str(output)), sender=transport)
+ self.assertEqual(json.loads(output.read_text())["mode"], "offline-preview")
+ self.assertFalse(result["sent"])
+ transport.assert_not_called()
+
+ def test_test_mode_rejects_both_production_audiences_before_transport(self):
+ transport = Mock()
+ for topic in ("all_users", "prod_users"):
+ with self.subTest(topic=topic), self.assertRaisesRegex(ValueError, "only permits debug_users"):
+ sender.send_notification(args("--test-mode", "true", "--target", topic), sender=transport)
+ transport.assert_not_called()
+
+ def test_debug_test_message_and_dry_run_use_exact_transport_settings(self):
+ for dry_run in ("true", "false"):
+ with self.subTest(dry_run=dry_run):
+ transport = Mock(return_value="fake-message-id")
+ result = sender.send_notification(args("--test-mode", "true", "--target", "debug_users", "--dry-run", dry_run), sender=transport)
+ self.assertEqual(transport.call_args.args[0]["topic"], "debug_users")
+ self.assertEqual(transport.call_args.args[0]["dry_run"], dry_run == "true")
+ self.assertEqual(result["data"]["body"], "First line\nSecond line")
+
+ def test_existing_manual_contract_retains_all_optional_keys(self):
+ plan = sender.notification_plan(args("--route", "boxlore://podcast/123", "--image", "https://example.invalid/cover.png", "--sound", "silent", "--action-label", "Open show", "--show-action-in-push", "false", "--category", "TIP", "--collapse-key", "manual_tip"))
+ self.assertEqual(plan["topic"], "all_users")
+ self.assertEqual(plan["collapse_key"], "manual_tip")
+ self.assertEqual(plan["data"]["action_label"], "Open show")
+ self.assertEqual(plan["data"]["show_action_in_push"], "false")
+ self.assertEqual(plan["data"]["category"], "TIP")
+ self.assertEqual(plan["data"]["route"], "boxlore://podcast/123")
+ self.assertIn("image", plan["data"])
+
+ def test_unicode_size_limit_counts_utf8_keys_and_values(self):
+ arguments = args()
+ arguments.body = "é" * 1100
+ with self.assertRaisesRegex(ValueError, "Shorten the message"):
+ sender.notification_plan(arguments)
+ arguments.body = "x"
+ base = sender.notification_plan(arguments)["payload_bytes"] - 1
+ arguments.body = "x" * (2048 - base)
+ self.assertEqual(sender.notification_plan(arguments)["payload_bytes"], 2048)
+ arguments.body += "x"
+ with self.assertRaises(ValueError):
+ sender.notification_plan(arguments)
+
+ def test_release_url_requires_release_alert_before_transport(self):
+ transport = Mock()
+ with self.assertRaisesRegex(ValueError, "Enable Release alert"):
+ sender.send_notification(args("--release-url", "https://github.com/boxcreate/boxlore/releases/latest"), sender=transport)
+ transport.assert_not_called()
+ plan = sender.notification_plan(args("--release-url", "https://github.com/boxcreate/boxlore/releases/latest", "--release-alert", "true", "--target", "direct_users"))
+ self.assertEqual("https://github.com/boxcreate/boxlore/releases/latest", plan["data"]["release_url"])
+
+ def test_invalid_controls_fail_closed(self):
+ for field, value in (("dry_run", "TRUE"), ("test_mode", "yes"), ("show_action_in_push", "maybe"), ("type", "popup"), ("target", "unknown"), ("sound", "loud"), ("title", " ")):
+ with self.subTest(field=field), self.assertRaises(ValueError):
+ arguments = args()
+ setattr(arguments, field, value)
+ sender.notification_plan(arguments)
+
+ def test_firebase_adapter_preserves_dry_run_and_legacy_payload(self):
+ # Fake the SDK boundary as well as the higher-level transport: no provider I/O.
+ firebase = Mock()
+ credentials, messaging = Mock(), Mock()
+ firebase.credentials, firebase.messaging = credentials, messaging
+ messaging.send.return_value = "fake-provider-id"
+ modules = {"firebase_admin": firebase, "firebase_admin.credentials": credentials, "firebase_admin.messaging": messaging}
+ arguments = args("--dry-run", "true", "--collapse-key", "test_release")
+ with patch.dict(sys.modules, modules), patch.dict(os.environ, {"FIREBASE_CREDENTIALS": '{"project_id":"offline-test"}'}):
+ result = sender.send_notification(arguments)
+ self.assertEqual(result["response"], "fake-provider-id")
+ self.assertTrue(messaging.send.call_args.kwargs["dry_run"])
+ self.assertEqual(messaging.Message.call_args.kwargs["data"]["type"], "both")
+ self.assertEqual(messaging.Message.call_args.kwargs["topic"], "all_users")
+ messaging.AndroidConfig.assert_called_once_with(collapse_key="test_release")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/.github/scripts/test_release_contracts.py b/.github/scripts/test_release_contracts.py
index b4c696139..6c6adc89d 100644
--- a/.github/scripts/test_release_contracts.py
+++ b/.github/scripts/test_release_contracts.py
@@ -336,13 +336,14 @@ def test_both_publication_paths_and_manual_preflight_are_guarded(self):
"""Full R8 verification gates publication; preflight is opt-in only."""
root = Path(__file__).resolve().parents[2]
workflow = (root / ".github/workflows/changelog-on-merge.yml").read_text()
- self.assertEqual(2, workflow.count("python3 .github/scripts/verify_release_contracts.py"))
+ self.assertEqual(4, workflow.count("python3 .github/scripts/verify_release_contracts.py"))
for block in workflow.split("- name: Verify optimized release contracts")[1:]:
self.assertIn("app-release.apk", block[:450])
- self.assertIn("app-release.aab", block[:450])
+ self.assertIn("app-playRelease.aab", block[:600])
preflight = (root / ".github/workflows/release-contracts.yml").read_text()
self.assertIn("app-release-unsigned.apk", preflight)
self.assertIn("uploadCrashlyticsMappingFileRelease", preflight)
+ self.assertIn("--app-variant playRelease", preflight)
self.assertIn("workflow_dispatch:", preflight)
self.assertNotIn("pull_request:", preflight)
self.assertNotIn("push:", preflight)
diff --git a/.github/scripts/test_release_rehearsal.py b/.github/scripts/test_release_rehearsal.py
new file mode 100644
index 000000000..e5d86471a
--- /dev/null
+++ b/.github/scripts/test_release_rehearsal.py
@@ -0,0 +1,93 @@
+"""Offline rehearsal must leave source files, releases and audiences untouched."""
+
+import hashlib
+import json
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+import rehearse_release as rehearsal
+import update_changelog as copy
+from test_update_changelog_copy import MINIMAL_CHANGELOG
+
+
+GRADLE = 'android { defaultConfig {\nversionCode = 12\nversionName = "0.0.12"\n} }\n'
+README = "# boxlore\n" + copy._render_release_notes_shell(upcoming_inner="old", whats_new_inner=None)
+CHANGELOG = copy._upsert_readme_copy_block(
+ MINIMAL_CHANGELOG, 900,
+ [{"heading": "Improvements", "bullets": ["Your library opens faster.", "Exact second note."]}],
+)
+
+
+class ReleaseRehearsalTest(unittest.TestCase):
+ def test_prepare_uses_real_copy_and_version_functions(self):
+ preview = rehearsal.candidate(GRADLE, CHANGELOG, README, operation="prepare-release", bump="minor", release_date="2026-10-10")
+ self.assertIn('versionName = "0.1.0"', preview["gradle"])
+ self.assertIn("versionCode = 13", preview["gradle"])
+ self.assertIn("## [v0.1.0] - 2026-10-10", preview["changelog"])
+ self.assertIn("Your library opens faster.", preview["readme"])
+ plan = preview["report"]["announcement"]
+ self.assertEqual(plan["topic"], "debug_users")
+ self.assertEqual(plan["mode"], "offline-preview")
+ self.assertFalse(plan["sent"])
+
+ def test_quiet_refresh_keeps_version_and_does_not_prepare_notification(self):
+ preview = rehearsal.candidate(GRADLE, CHANGELOG, README, operation="refresh-latest-artifacts", bump="major", release_date="2026-10-10")
+ self.assertEqual(preview["gradle"], GRADLE)
+ self.assertEqual(preview["changelog"], CHANGELOG)
+ self.assertEqual(preview["readme"], README)
+ self.assertFalse(preview["report"]["version_bumped"])
+ self.assertIsNone(preview["report"]["announcement"])
+
+ def sources(self, root, changelog=CHANGELOG):
+ (root / "app").mkdir()
+ values = {"app/build.gradle.kts": GRADLE, "CHANGELOG.md": changelog, "README.md": README}
+ for name, value in values.items():
+ (root / name).write_text(value, encoding="utf-8")
+ return {name: hashlib.sha256((root / name).read_bytes()).hexdigest() for name in values}
+
+ def test_report_writing_never_changes_source_files(self):
+ with tempfile.TemporaryDirectory() as temp:
+ root = Path(temp)
+ original = self.sources(root)
+ output = root / "preview"
+ rehearsal.write_preview(root, output, release_date="2026-10-10")
+ self.assertEqual(original, {name: hashlib.sha256((root / name).read_bytes()).hexdigest() for name in original})
+ report = json.loads((output / "report.json").read_text())
+ self.assertEqual(report["candidate_code"], 13)
+ self.assertEqual(len(list(output.iterdir())), 4)
+
+ def test_missing_copy_fails_before_any_artifact_is_written(self):
+ with tempfile.TemporaryDirectory() as temp:
+ root = Path(temp)
+ original = self.sources(root, MINIMAL_CHANGELOG)
+ with self.assertRaisesRegex(ValueError, "#900"):
+ rehearsal.write_preview(root, root / "preview")
+ self.assertFalse((root / "preview").exists())
+ self.assertEqual(original, {name: hashlib.sha256((root / name).read_bytes()).hexdigest() for name in original})
+
+ def test_output_symlink_cannot_overwrite_source(self):
+ with tempfile.TemporaryDirectory() as temp:
+ root = Path(temp)
+ self.sources(root)
+ output = root / "preview"
+ output.mkdir()
+ (output / "candidate.gradle.kts").symlink_to(root / "app/build.gradle.kts")
+ with self.assertRaisesRegex(ValueError, "source files"):
+ rehearsal.write_preview(root, output)
+ self.assertEqual((root / "app/build.gradle.kts").read_text(), GRADLE)
+ self.assertFalse((output / "report.json").exists())
+
+ def test_rehearsal_workflow_has_no_publish_or_send_capability(self):
+ workflows = Path(__file__).resolve().parent.parent / "workflows"
+ source = (workflows / "release-rehearsal.yml").read_text()
+ self.assertIn("workflow_dispatch:", source)
+ self.assertIn("contents: read", source)
+ for forbidden in ("secrets.", "contents: write", "gh release", "git push", "send-notification.yml", "FIREBASE_CREDENTIALS"):
+ self.assertNotIn(forbidden, source)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/.github/scripts/test_review_threads.py b/.github/scripts/test_review_threads.py
new file mode 100644
index 000000000..8b5a4752e
--- /dev/null
+++ b/.github/scripts/test_review_threads.py
@@ -0,0 +1,75 @@
+"""Exercise the actual required-check shell without GitHub or credentials."""
+
+import json
+import os
+from pathlib import Path
+import shutil
+import subprocess
+import sys
+import tempfile
+import textwrap
+import unittest
+
+
+def page(nodes, next_page=False, cursor=None):
+ return {"data": {"repository": {"pullRequest": {"reviewThreads": {
+ "nodes": nodes, "pageInfo": {"hasNextPage": next_page, "endCursor": cursor},
+ }}}}}
+
+
+def thread(resolved=False, outdated=False, author="coderabbitai[bot]"):
+ return {"isResolved": resolved, "isOutdated": outdated, "comments": {"nodes": [{
+ "author": {"login": author}, "url": "https://example.invalid/review", "body": "Fix this finding",
+ }]}}
+
+
+class ReviewThreadsTest(unittest.TestCase):
+ def run_gate(self, pages):
+ self.assertIsNotNone(shutil.which("jq"), "jq is required, as on the Actions runner")
+ root = Path(__file__).resolve().parents[2]
+ workflow = (root / ".github/workflows/coderabbit-threads-resolved.yml").read_text()
+ shell = textwrap.dedent(workflow.split(" run: |\n", 1)[1])
+ with tempfile.TemporaryDirectory() as temp:
+ directory = Path(temp)
+ (directory / "pages.json").write_text(json.dumps(pages))
+ gh = directory / "gh"
+ gh.write_text("#!" + sys.executable + "\n" + textwrap.dedent('''\
+ import json
+ import os
+ from pathlib import Path
+ root = Path(os.environ["FIXTURE_DIR"])
+ count = root / "count"
+ index = int(count.read_text()) if count.exists() else 0
+ count.write_text(str(index + 1))
+ print(json.dumps(json.loads((root / "pages.json").read_text())[index]))
+ '''))
+ gh.chmod(0o755)
+ env = dict(os.environ, PATH=temp + os.pathsep + os.environ["PATH"], FIXTURE_DIR=temp,
+ EVENT_NAME="pull_request", PR_NUMBER_EVENT="1107", PR_NUMBER_INPUT="", HEAD_SHA_PR="fixture", REPO="boxcreate/boxlore")
+ return subprocess.run(["bash", "-c", shell], env=env, capture_output=True, text=True, timeout=10)
+
+ def test_unresolved_bot_finding_fails_without_stdin(self):
+ result = self.run_gate([page([thread()])])
+ self.assertNotEqual(0, result.returncode, result.stdout + result.stderr)
+ self.assertIn("1 unresolved CodeRabbit", result.stdout)
+
+ def test_resolved_outdated_and_other_authors_do_not_block(self):
+ result = self.run_gate([page([thread(resolved=True), thread(outdated=True), thread(author="maintainer")])])
+ self.assertEqual(0, result.returncode, result.stdout + result.stderr)
+ self.assertIn("open_coderabbit_threads=0", result.stdout)
+
+ def test_all_pages_are_counted(self):
+ result = self.run_gate([page([], next_page=True, cursor="next"), page([thread()])])
+ self.assertNotEqual(0, result.returncode, result.stdout + result.stderr)
+ self.assertIn("1 unresolved CodeRabbit", result.stdout)
+
+ def test_invalid_or_incomplete_api_responses_fail_closed(self):
+ for response in ({"errors": [{"message": "denied"}]}, {}, page([], next_page=True), page([{"isResolved": False}])):
+ with self.subTest(response=response):
+ result = self.run_gate([response])
+ self.assertNotEqual(0, result.returncode, result.stdout + result.stderr)
+ self.assertIn("::error::", result.stdout)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/.github/scripts/test_update_manifest.py b/.github/scripts/test_update_manifest.py
new file mode 100644
index 000000000..b491c08f1
--- /dev/null
+++ b/.github/scripts/test_update_manifest.py
@@ -0,0 +1,94 @@
+import hashlib
+from pathlib import Path
+import tempfile
+import unittest
+
+from refresh_upcoming import refresh
+from generate_update_manifest import build_manifest, listener_notes, upcoming_notes
+
+
+class UpdateManifestTests(unittest.TestCase):
+ def test_manifest_uses_exact_candidate_version_immutable_url_and_checksum(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ apk = Path(tmp) / "candidate.apk"
+ apk.write_bytes(b"verified signed fixture")
+ manifest = build_manifest('versionCode = 29\nversionName = "0.0.29"\nminSdk = 31', apk, "boxlore-v0.0.29.apk", "Improved playback")
+ self.assertEqual(29, manifest["versionCode"])
+ self.assertEqual("0.0.29", manifest["versionName"])
+ self.assertEqual(hashlib.sha256(apk.read_bytes()).hexdigest(), manifest["apkSha256"])
+ self.assertEqual(apk.stat().st_size, manifest["apkBytes"])
+ self.assertEqual("https://github.com/boxcreate/boxlore/releases/download/v0.0.29/boxlore-v0.0.29.apk", manifest["apkUrl"])
+ self.assertEqual("Improved playback", manifest["notes"])
+
+ def test_listener_bullets_preserved_without_notification_truncation(self):
+ first = "Long authored copy " + "a" * 200
+ readme = '- ' + first + '
- Fixed: Playback & queues.
'
+ self.assertEqual("- " + first + "\n- Fixed: Playback & queues.", listener_notes(readme))
+
+ def test_listener_markdown_preserves_authored_emphasis_and_links(self):
+ readme = '- Better playback with details.
'
+ self.assertEqual('- **Better playback** with [details](https://example.com).', listener_notes(readme))
+
+ def test_upcoming_preserves_authored_copy(self):
+ readme = 'Improvements:- Library: clearer controls.
'
+ self.assertEqual('## Improvements\n- **Library:** clearer controls.', upcoming_notes(readme))
+ self.assertEqual('', upcoming_notes('No upcoming region'))
+
+ def test_preview_refresh_never_changes_binary_metadata(self):
+ original = dict(schemaVersion=1, packageName='cx.aswin.boxlore', versionCode=29, apkSha256='verified')
+ changed = refresh(original, '- Clearer controls
')
+ self.assertEqual(original, {k: v for k, v in changed.items() if k != 'upcoming'})
+ self.assertEqual('- Clearer controls', changed['upcoming'])
+ with self.assertRaises(ValueError): refresh(dict(original, testOnly=True), '')
+
+ def test_invalid_asset_and_empty_apk_rejected(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ apk = Path(tmp) / "candidate.apk"
+ apk.write_bytes(b"a")
+ gradle = 'versionCode = 29\nversionName = "0.0.29"\nminSdk = 31'
+ for asset in ("../other.apk", "a.aab", "a.apk?x=1"):
+ with self.assertRaises(ValueError): build_manifest(gradle, apk, asset)
+ apk.write_bytes(b"")
+ with self.assertRaises(ValueError): build_manifest(gradle, apk, "a.apk")
+
+ def test_multibyte_notes_cannot_exceed_client_response_limit(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ apk = Path(tmp) / "candidate.apk"
+ apk.write_bytes(b"fixture")
+ with self.assertRaisesRegex(ValueError, "64 KiB"):
+ build_manifest('versionCode = 29\nversionName = "0.0.29"\nminSdk = 31', apk, "a.apk", "ࠀ" * 23_000)
+
+ def test_notes_limit_matches_android_utf16_length(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ apk = Path(tmp) / "candidate.apk"
+ apk.write_bytes(b"fixture")
+ gradle = 'versionCode = 29\nversionName = "0.0.29"\nminSdk = 31'
+ self.assertEqual("🎧" * 12_000, build_manifest(gradle, apk, "a.apk", "🎧" * 12_000)["notes"])
+ with self.assertRaisesRegex(ValueError, "24,000"):
+ build_manifest(gradle, apk, "a.apk", "🎧" * 12_001)
+
+ def test_workflow_queues_metadata_writers_and_uses_available_refresh_token(self):
+ root = Path(__file__).resolve().parents[2]
+ workflow = (root / ".github/workflows/changelog-on-merge.yml").read_text()
+ self.assertEqual(4, workflow.count("queue: max"))
+ self.assertEqual(3, workflow.count("group: boxlore-release-metadata"))
+ self.assertIn("group: boxlore-release-preparation", workflow)
+ refresh_job = workflow.split(" refresh-latest-artifacts:", 1)[1]
+ self.assertNotIn("steps.app-token.outputs.token", refresh_job)
+ manifest_step = refresh_job.split("- name: Publish verified update manifest", 1)[1]
+ self.assertIn("GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}", manifest_step)
+
+ def test_workflow_publishes_manifest_only_after_download_verification(self):
+ root = Path(__file__).resolve().parents[2]
+ workflow = (root / ".github/workflows/changelog-on-merge.yml").read_text()
+ jobs = workflow.split(" - name: Verify README latest-download APK")[1:]
+ self.assertEqual(2, len(jobs))
+ for job in jobs:
+ self.assertLess(job.index("exit 1"), job.index("- name: Publish verified update manifest"))
+ self.assertIn("generate_update_manifest.py", job)
+ self.assertIn('gh release upload "$TAG" "$RUNNER_TEMP/update.json"', job)
+ self.assertIn(":app:bundlePlayRelease", workflow)
+ self.assertNotIn("bundleRelease", workflow)
+
+
+if __name__ == "__main__": unittest.main()
diff --git a/.github/scripts/verify_release_contracts.py b/.github/scripts/verify_release_contracts.py
index 21c7b8cd4..c1d472741 100644
--- a/.github/scripts/verify_release_contracts.py
+++ b/.github/scripts/verify_release_contracts.py
@@ -33,6 +33,7 @@
DEBUG_ONLY = {"com/google/firebase/appcheck/debug/DebugAppCheckProviderFactory",
"com/google/firebase/appcheck/debug/FirebaseAppCheckDebugRegistrar",
APP_PREFIX + "debug/TestNotificationReceiver"}
+DEBUG_ONLY.add(APP_PREFIX + "testing/AnnouncementTestReceiver")
WIDGET_MODELS = {APP_PREFIX + "feature/widgets/" + name for name in
("NowPlayingWidgetSnapshot", "LibraryWidgetSnapshot", "WidgetShowRow", "WidgetEpisodeRow")}
@@ -258,17 +259,33 @@ def main() -> int:
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[2])
parser.add_argument("--sdk", type=Path)
parser.add_argument("--report", type=Path)
+ parser.add_argument("--app-variant", choices=("release", "playRelease"), default="release")
args = parser.parse_args()
root = args.root.resolve()
sdk = args.sdk or Path(os.environ.get("ANDROID_SDK_ROOT") or os.environ.get("ANDROID_HOME") or "")
try:
- classes = load_project_classes(root)
- mapping = read_mapping(root / "app/build/outputs/mapping/release/mapping.txt")
- manifest = manifest_constructors(root / "app/build/intermediates/merged_manifests/release/processReleaseManifest/AndroidManifest.xml")
+ variant = args.app_variant
+ task_variant = variant[0].upper() + variant[1:]
+ classes = load_project_classes(root, variant)
+ mapping = read_mapping(root / f"app/build/outputs/mapping/{variant}/mapping.txt")
+ manifest_path = root / f"app/build/intermediates/merged_manifests/{variant}/process{task_variant}Manifest/AndroidManifest.xml"
+ manifest = manifest_constructors(manifest_path)
+ permission = "android.permission.REQUEST_INSTALL_PACKAGES"
+ permissions = {item.get("{http://schemas.android.com/apk/res/android}name") for item in ET.parse(manifest_path).getroot().iter("uses-permission")}
+ if any(item.get("{http://schemas.android.com/apk/res/android}name") == "cx.aswin.boxlore.ISOLATED_UPDATE_TEST" for item in ET.parse(manifest_path).getroot().iter("meta-data")):
+ raise ValueError("Isolated test build cannot be published")
+ if variant == "playRelease" and permission in permissions:
+ raise ValueError("Play bundle must not request APK installation permission")
+ if variant == "release" and permission not in permissions:
+ raise ValueError("Direct APK installer permission is missing")
report = {}
for artifact in args.artifacts:
dex, size = inspect_archive(artifact, sdk_tool("dexdump", sdk))
counts = validate_contracts(classes, dex, mapping, manifest)
+ if variant == "playRelease":
+ for name in (APP_PREFIX + "updates/DirectApkInstaller", APP_PREFIX + "updates/ApkFileDownloader"):
+ if mapping.get(name, name) in dex:
+ raise ValueError("APK installer code packaged in Play bundle: " + name)
counts["raw_uri_payloads"] = validate_raw_uri_payloads(artifact, root, literal_uri_resources(root))
if artifact.suffix == ".apk":
counts["dynamic_resources"] = validate_resources(artifact, root, sdk_tool("aapt2", sdk))
diff --git a/.github/workflows/changelog-on-merge.yml b/.github/workflows/changelog-on-merge.yml
index bedb3a10f..071d96a96 100644
--- a/.github/workflows/changelog-on-merge.yml
+++ b/.github/workflows/changelog-on-merge.yml
@@ -30,10 +30,10 @@ on:
release_notes_source:
description: Release-note source for prepare-release
required: true
- default: reconcile-with-ai
+ default: reconcile-reviewed-copy
type: choice
options:
- - reconcile-with-ai
+ - reconcile-reviewed-copy
- readme-upcoming
skip_notify:
description: >-
@@ -65,7 +65,9 @@ jobs:
contents: write
pull-requests: read
concurrency:
- group: changelog-on-merge
+ group: boxlore-release-metadata
+ # Queue every metadata writer; the default single pending slot drops jobs.
+ queue: max
cancel-in-progress: false
steps:
- name: Generate boxlore pusher token
@@ -84,7 +86,6 @@ jobs:
- name: Append merged PR event to CHANGELOG
if: github.event_name == 'pull_request'
env:
- GROQ_API_KEY: ${{ secrets.REVIEW_KEY }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
@@ -95,7 +96,6 @@ jobs:
if: github.event_name == 'workflow_dispatch'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
- GROQ_API_KEY: ${{ secrets.REVIEW_KEY }}
PR_NUMBER: ${{ github.event.inputs.pr_number }}
run: |
if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]; then
@@ -109,14 +109,10 @@ jobs:
python3 .github/scripts/update_changelog.py append \
--pr-json "$RUNNER_TEMP/pr.json"
- - name: Curate CHANGELOG grouping and priority
- env:
- GROQ_API_KEY: ${{ secrets.REVIEW_KEY }}
+ - name: Order authored CHANGELOG bullets
run: python3 .github/scripts/update_changelog.py sync-changelog
- - name: Curate README from all unreleased changes
- env:
- GROQ_API_KEY: ${{ secrets.REVIEW_KEY }}
+ - name: Place authored README copy by category and impact
run: python3 .github/scripts/update_changelog.py sync-readme
- name: Commit and push changelog update
@@ -145,6 +141,21 @@ jobs:
echo "::error::Failed to push CHANGELOG update after 5 attempts"
exit 1
+ - name: Refresh Coming next in the verified update feed
+ env:
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
+ run: |
+ TAG=$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName --jq '.tagName')
+ mkdir -p "$RUNNER_TEMP/upcoming-feed"
+ if ! gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern update.json --dir "$RUNNER_TEMP/upcoming-feed"; then
+ echo "::warning::Upcoming preview not published: the latest release has no accessible update feed. Run publication after fixing access or creating the verified feed."
+ exit 0
+ fi
+ git pull --ff-only origin master
+ python3 .github/scripts/refresh_upcoming.py --manifest "$RUNNER_TEMP/upcoming-feed/update.json"
+ gh release upload "$TAG" "$RUNNER_TEMP/upcoming-feed/update.json" --repo "$GITHUB_REPOSITORY" --clobber
+ echo "Updated Coming next only. No APK changed and no notification was sent." >> "$GITHUB_STEP_SUMMARY"
+
prepare-release:
if: >-
github.event_name == 'workflow_dispatch' &&
@@ -154,7 +165,9 @@ jobs:
contents: write
pull-requests: write
concurrency:
- group: prepare-release
+ group: boxlore-release-preparation
+ # Queue every metadata writer; the default single pending slot drops jobs.
+ queue: max
cancel-in-progress: false
steps:
- name: Generate boxlore pusher token
@@ -190,7 +203,6 @@ jobs:
id: release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- GROQ_API_KEY: ${{ secrets.REVIEW_KEY }}
BUMP: ${{ github.event.inputs.bump }}
LATEST_TAG: ${{ steps.latest.outputs.tag }}
RELEASE_NOTES_SOURCE: ${{ github.event.inputs.release_notes_source }}
@@ -310,9 +322,9 @@ jobs:
)"
else
if [[ "$NOTES_SOURCE" == "readme-upcoming" ]]; then
- notes_message="README Upcoming is promoted verbatim into What's New and the in-app notification; AI reconciliation is skipped."
+ notes_message="Reviewed README Upcoming is promoted verbatim into What's New and the in-app notification."
else
- notes_message="Changelog and README use author-written release-copy when present; remaining notes are reconciled before promotion."
+ notes_message="Changelog and README use the required author-written release copy, ordered without rewriting before promotion."
fi
title="release: ${TAG} [skip changelog]"
body="$(cat <-
needs.publish-release.result == 'success' &&
@@ -1250,8 +1304,13 @@ jobs:
type: in-app
route: ${{ needs.publish-release.outputs.notification_route }}
image: ''
- target: all_users
+ target: direct_users
dry_run: 'false'
+ preview_only: 'true'
+ release_url: ${{ format('https://github.com/boxcreate/boxlore/releases/tag/{0}', needs.publish-release.outputs.tag) }}
+ release_alert: 'true'
+ release_version_code: ${{ needs.publish-release.outputs.version_code }}
+ presentation: fullscreen
collapse_key: ${{ format('boxlore-release-{0}', needs.publish-release.outputs.tag) }}
sound: silent
action_label: Download
diff --git a/.github/workflows/coderabbit-threads-resolved.yml b/.github/workflows/coderabbit-threads-resolved.yml
index c71bbd4f2..a74695d3b 100644
--- a/.github/workflows/coderabbit-threads-resolved.yml
+++ b/.github/workflows/coderabbit-threads-resolved.yml
@@ -113,11 +113,28 @@ jobs:
ARGS+=(-F cursor=null)
fi
RESPONSE="$(gh api graphql "${ARGS[@]}")"
+ if ! echo "$RESPONSE" | jq -e '
+ ((.errors // []) | length == 0) and
+ (.data.repository.pullRequest.reviewThreads as $threads |
+ ($threads.nodes | type) == "array" and
+ ($threads.pageInfo.hasNextPage | type) == "boolean" and
+ all($threads.nodes[];
+ (.isResolved | type) == "boolean" and
+ (.isOutdated | type) == "boolean" and
+ (.comments.nodes | type) == "array" and
+ (.comments.nodes | length) > 0))' >/dev/null; then
+ echo "::error::Could not read complete review threads. Retry this check; do not treat it as approval."
+ exit 1
+ fi
PAGE_NODES="$(echo "$RESPONSE" | jq -c '.data.repository.pullRequest.reviewThreads.nodes // []')"
- ALL_NODES="$(jq -c --argjson a "$ALL_NODES" --argjson b "$PAGE_NODES" '$a + $b')"
+ ALL_NODES="$(jq -cn --argjson a "$ALL_NODES" --argjson b "$PAGE_NODES" '$a + $b')"
HAS_NEXT="$(echo "$RESPONSE" | jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage')"
CURSOR="$(echo "$RESPONSE" | jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.endCursor // empty')"
- [[ "$HAS_NEXT" == "true" && -n "$CURSOR" ]] || break
+ if [[ "$HAS_NEXT" == "true" && -z "$CURSOR" ]]; then
+ echo "::error::Review thread pagination is incomplete. Retry this check."
+ exit 1
+ fi
+ [[ "$HAS_NEXT" == "true" ]] || break
done
OPEN_JSON="$(
@@ -145,12 +162,16 @@ jobs:
)"
COUNT="$(echo "$OPEN_JSON" | jq 'length')"
+ if ! [[ "$COUNT" =~ ^[0-9]+$ ]]; then
+ echo "::error::Could not count review threads. Retry this check."
+ exit 1
+ fi
echo "open_coderabbit_threads=${COUNT}"
if [[ "$COUNT" -gt 0 ]]; then
echo "### Unresolved CodeRabbit review threads (${COUNT})"
echo "$OPEN_JSON" | jq -r '.[] | "- **@\(.author)**: \(.preview)\n \(.url)"'
echo "::error::${COUNT} unresolved CodeRabbit review thread(s) on PR #${PR_NUMBER}"
- echo "Fix or dismiss each finding, mark the thread Resolved, then re-run this workflow or push."
+ echo "Address each finding, mark the thread Resolved, then re-run this workflow or push."
exit 1
fi
diff --git a/.github/workflows/release-contracts.yml b/.github/workflows/release-contracts.yml
index 7e16d6f3b..6c2b8c221 100644
--- a/.github/workflows/release-contracts.yml
+++ b/.github/workflows/release-contracts.yml
@@ -34,14 +34,19 @@ jobs:
- name: Build optimized unsigned APK and bundle
run: >-
./gradlew --no-daemon -I .github/scripts/release_verification.init.gradle
- :app:assembleRelease :app:bundleRelease :app:lintRelease
- -x :app:uploadCrashlyticsMappingFileRelease
+ :app:assembleRelease :app:bundlePlayRelease :app:lintPlayRelease :app:lintRelease
+ -x :app:uploadCrashlyticsMappingFileRelease -x :app:uploadCrashlyticsMappingFilePlayRelease
- name: Verify optimized release contracts
run: >-
python3 .github/scripts/verify_release_contracts.py
--report app/build/reports/release-contracts.json
app/build/outputs/apk/release/app-release-unsigned.apk
- app/build/outputs/bundle/release/app-release.aab
+ - name: Verify optimized Play contracts and installer exclusion
+ run: >-
+ python3 .github/scripts/verify_release_contracts.py
+ --app-variant playRelease
+ --report app/build/reports/play-release-contracts.json
+ app/build/outputs/bundle/playRelease/app-playRelease.aab
- name: Preserve release diagnostics
if: always()
uses: actions/upload-artifact@v4
@@ -50,12 +55,14 @@ jobs:
retention-days: 14
path: |
app/build/reports/release-contracts.json
+ app/build/reports/play-release-contracts.json
app/build/reports/lint-results-release.*
app/build/outputs/mapping/release/configuration.txt
app/build/outputs/mapping/release/mapping.txt
app/build/outputs/mapping/release/seeds.txt
app/build/outputs/mapping/release/usage.txt
app/build/outputs/mapping/release/missing_rules.txt
+ app/build/outputs/mapping/playRelease/
- name: Remove build-only Firebase stub
if: always()
run: rm -f app/google-services.json
diff --git a/.github/workflows/release-rehearsal.yml b/.github/workflows/release-rehearsal.yml
new file mode 100644
index 000000000..a9c1c0e42
--- /dev/null
+++ b/.github/workflows/release-rehearsal.yml
@@ -0,0 +1,61 @@
+name: Offline release rehearsal
+
+on:
+ workflow_dispatch:
+ inputs:
+ operation:
+ description: Preview a new release or quiet same-version refresh
+ type: choice
+ default: prepare-release
+ options: [prepare-release, refresh-latest-artifacts]
+ bump:
+ description: Preview version bump for a new release
+ type: choice
+ default: patch
+ options: [patch, minor, major]
+
+permissions:
+ contents: read
+
+jobs:
+ rehearse:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ with:
+ persist-credentials: false
+ - name: Set up Node for announcement preview checks
+ uses: actions/setup-node@v5
+ with:
+ node-version: '22'
+ - name: Announcement composer contract checks
+ run: node --test .github/scripts/test_announcement_admin.cjs
+ - name: Test release tooling without credentials
+ run: python3 -m unittest discover -s .github/scripts -p 'test_*.py' -v
+ - name: Prepare preview files
+ env:
+ OPERATION: ${{ inputs.operation }}
+ BUMP: ${{ inputs.bump }}
+ run: |
+ python3 .github/scripts/rehearse_release.py \
+ --output "$RUNNER_TEMP/release-preview" \
+ --operation "$OPERATION" --bump "$BUMP"
+ - name: Save preview files
+ uses: actions/upload-artifact@v4
+ with:
+ name: release-preview
+ path: ${{ runner.temp }}/release-preview/
+ if-no-files-found: error
+ - name: Explain result and recovery
+ if: always()
+ run: |
+ cat >> "$GITHUB_STEP_SUMMARY" <<'SUMMARY'
+ ## Release rehearsal
+ Nothing was published or sent. Source version files and download links were unchanged.
+
+ **Passed:** Download the `release-preview` artifact. Review candidate notes and `report.json`.
+
+ **Failed:** Open the first failed step. If listener copy is missing, fill that PR's release-copy region, run Backfill changelog, and retry. If a test failed, fix the reported regression and rerun this rehearsal. Do not run Publish release to repair a rehearsal failure.
+
+ This rehearsal covers local copy, version calculation and announcement payloads. APK builds, update installation, provider delivery and remote merged-PR reconciliation need separate checks.
+ SUMMARY
diff --git a/.github/workflows/send-notification.yml b/.github/workflows/send-notification.yml
index b2c71bf3f..b2efdd0b2 100644
--- a/.github/workflows/send-notification.yml
+++ b/.github/workflows/send-notification.yml
@@ -35,6 +35,8 @@ on:
- all_users
- debug_users
- prod_users
+ - direct_users
+ - test_users
dry_run:
description: 'Dry Run Mode (Validate only)'
required: true
@@ -43,6 +45,18 @@ on:
options:
- 'false'
- 'true'
+ test_mode:
+ description: 'Test mode — only isolated test or debug builds may receive this message'
+ required: false
+ default: 'false'
+ type: choice
+ options: ['false', 'true']
+ preview_only:
+ description: 'Offline preview — no message sent and no Firebase access'
+ required: false
+ default: 'false'
+ type: choice
+ options: ['false', 'true']
collapse_key:
description: 'Collapse Key (Optional)'
required: false
@@ -80,7 +94,42 @@ on:
category:
description: 'In-App category badge label'
required: true
- default: "WHAT'S NEW"
+ default: "ANNOUNCEMENT"
+ presentation:
+ description: In-app appearance
+ type: choice
+ default: compact
+ options: [compact, fullscreen]
+ tone:
+ description: Accent role
+ type: choice
+ default: primary
+ options: [primary, secondary, tertiary, error]
+ image_style:
+ description: Image layout
+ type: choice
+ default: banner
+ options: [banner, cover]
+ release_url:
+ description: GitHub release page for the View on GitHub action
+ type: string
+ default: ''
+ release_alert:
+ description: GitHub release alert (normally excludes Google Play)
+ type: boolean
+ default: false
+ include_play:
+ description: Explicitly include Google Play for this release alert
+ type: boolean
+ default: false
+ release_version_code:
+ description: Release code (0 for ordinary alerts)
+ type: string
+ default: '0'
+ approval_digest:
+ description: Digest from the reviewed offline preview (required for delivery)
+ type: string
+ default: ''
workflow_call:
inputs:
title:
@@ -114,6 +163,16 @@ on:
required: false
default: 'false'
type: string
+ test_mode:
+ description: 'Test mode — only isolated test or debug builds may receive this message'
+ required: false
+ default: 'false'
+ type: string
+ preview_only:
+ description: 'Offline preview — no message sent and no Firebase access'
+ required: false
+ default: 'false'
+ type: string
collapse_key:
description: 'Optional Android collapse key'
required: false
@@ -142,8 +201,32 @@ on:
category:
description: 'In-app category badge'
required: false
- default: "WHAT'S NEW"
+ default: "ANNOUNCEMENT"
+ type: string
+ presentation:
+ type: string
+ default: compact
+ tone:
+ type: string
+ default: primary
+ image_style:
+ type: string
+ default: banner
+ release_url:
+ type: string
+ default: ''
+ release_alert:
type: string
+ default: 'false'
+ include_play:
+ type: string
+ default: 'false'
+ release_version_code:
+ type: string
+ default: '0'
+ approval_digest:
+ type: string
+ default: ''
secrets:
FCM_SERVICE_ACCOUNT_KEY:
description: 'Firebase service-account JSON'
@@ -164,16 +247,25 @@ jobs:
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
- python-version: '3.9'
+ python-version: '3.12'
- name: Install dependencies
+ if: inputs.preview_only != 'true'
run: |
python -m pip install --upgrade pip
pip install firebase-admin
- - name: Send Notification
+ - name: Preview, validate or send announcement
env:
- FIREBASE_CREDENTIALS: ${{ secrets.FCM_SERVICE_ACCOUNT_KEY }}
+ FIREBASE_CREDENTIALS: ${{ inputs.preview_only != 'true' && secrets.FCM_SERVICE_ACCOUNT_KEY || '' }}
+ PRESENTATION: ${{ inputs.presentation || 'compact' }}
+ TONE: ${{ inputs.tone || 'primary' }}
+ IMAGE_STYLE: ${{ inputs.image_style || 'banner' }}
+ RELEASE_URL: ${{ inputs.release_url || '' }}
+ RELEASE_ALERT: ${{ inputs.release_alert || 'false' }}
+ INCLUDE_PLAY: ${{ inputs.include_play || 'false' }}
+ RELEASE_VERSION_CODE: ${{ inputs.release_version_code || '0' }}
+ APPROVAL_DIGEST: ${{ inputs.approval_digest }}
NOTIFICATION_TITLE: ${{ inputs.title }}
NOTIFICATION_BODY: ${{ inputs.body }}
NOTIFICATION_TYPE: ${{ inputs.type }}
@@ -181,6 +273,8 @@ jobs:
NOTIFICATION_IMAGE: ${{ inputs.image }}
NOTIFICATION_TARGET: ${{ inputs.target }}
NOTIFICATION_DRY_RUN: ${{ inputs.dry_run }}
+ NOTIFICATION_TEST_MODE: ${{ inputs.test_mode || 'false' }}
+ NOTIFICATION_PREVIEW_ONLY: ${{ inputs.preview_only || 'false' }}
NOTIFICATION_COLLAPSE_KEY: ${{ inputs.collapse_key }}
NOTIFICATION_SOUND: ${{ inputs.sound }}
NOTIFICATION_ACTION_LABEL: ${{ inputs.action_label }}
@@ -189,6 +283,9 @@ jobs:
NOTIFICATION_CATEGORY: ${{ inputs.category }}
run: |
python .github/scripts/manual_dispatch.py \
+ --release-url "$RELEASE_URL" --presentation "$PRESENTATION" --tone "$TONE" --image-style "$IMAGE_STYLE" \
+ --release-alert "$RELEASE_ALERT" --include-play "$INCLUDE_PLAY" \
+ --release-version-code "$RELEASE_VERSION_CODE" --approval-digest "$APPROVAL_DIGEST" \
--title "$NOTIFICATION_TITLE" \
--body "$NOTIFICATION_BODY" \
--type "$NOTIFICATION_TYPE" \
@@ -196,9 +293,20 @@ jobs:
--image "$NOTIFICATION_IMAGE" \
--target "$NOTIFICATION_TARGET" \
--dry-run "$NOTIFICATION_DRY_RUN" \
+ --test-mode "$NOTIFICATION_TEST_MODE" \
+ --preview-only "$NOTIFICATION_PREVIEW_ONLY" \
+ --preview-output "$RUNNER_TEMP/announcement-preview.json" \
--collapse-key "$NOTIFICATION_COLLAPSE_KEY" \
--sound "$NOTIFICATION_SOUND" \
--action-label "$NOTIFICATION_ACTION_LABEL" \
--show-action-in-push "$NOTIFICATION_SHOW_ACTION_IN_PUSH" \
--show-action-in-app "$NOTIFICATION_SHOW_ACTION_IN_APP" \
--category "$NOTIFICATION_CATEGORY"
+
+ - name: Save offline preview
+ if: inputs.preview_only == 'true'
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ with:
+ name: announcement-preview
+ path: ${{ runner.temp }}/announcement-preview.json
+ if-no-files-found: error
diff --git a/app/README.md b/app/README.md
index d71e6d3c0..9763e8409 100644
--- a/app/README.md
+++ b/app/README.md
@@ -6,6 +6,15 @@ The application module owns the Android app shell: `BoxLoreApplication`, `MainAc
## Public API
+- The app-scoped updater checks on foreground at most every six hours (failed requests back off for 15 minutes). Settings → Check for updates bypasses the interval. The Home/Library utility capsule gets an icon-only action only for a confirmed newer compatible version. Background checks never open a prompt or start a download. Direct installs read a bounded versioned `update.json` from GitHub Latest, cache the last valid offer and distinguish connection errors from an up-to-date result. Same-version artifact refreshes stay quiet.
+- Direct APK updates use an app-private, checksum-keyed resumable transfer. Download, verification and installation are separate explicit actions; the Android installer owns confirmation. Before each handoff, the APK's checksum, package, exact version, Android minimum and signing identity are checked again. Interrupted transfers resume; failed integrity checks discard corrupt bytes. Returning from install permission settings or cancelling Android installation retains the Install action. A changed offer discards stale prepared state. Restoration separates session eligibility from verified-file recovery and retains cancellation and checksum guards.
+- `assembleRelease` builds the direct APK. `bundlePlayRelease` builds the Google Play AAB with release optimization/signing, the same application identity and normal notification receiver, but no APK downloader/installer implementation or `REQUEST_INSTALL_PACKAGES` permission. Play availability uses Play Core and caches the confirmed version across process recreation; the explicit action obtains fresh eligibility before starting a flow or finishing a downloaded flexible update. In-progress immediate Play updates resume on foreground. Release artifact validation checks both variants separately, including installer exclusion in Play.
+- Announcements use native Material 3 Compose surfaces, typography and buttons. Compact alerts and full-screen messages have a fixed category/close header, scrollable Markdown and optional image, and fixed actions. Controlled accent roles, banner/uncropped cover, headings, lists, callouts, links and emphasis are supported; payload HTML remains text. Release alerts have Download update (native metadata check and transfer), View on GitHub (keeps the alert on return), and Dismiss. Normal alerts retain their optional single action. The browser admin preview follows the same visual specification without executing in the app.
+- Announcement payload additions are optional and persist with legacy defaults. Only release alerts are install-channel/version filtered; ordinary and new-episode notifications remain available to both channels. New Play/direct builds retain ordinary broadcast topics and register their install channel. Release sends use direct_users while excluding Play/test topics, with an explicit include-Play override via All installs. Older clients cannot be classified through their shared prod_users topic, so they do not receive these new strict direct-only release sends until updated; the legacy payload/window remains readable for normal sends.
+- The updater persists its visible/prepared session through permission settings, Activity recreation and process restart. Opening it refreshes metadata automatically, retaining cached notes while checking and without starting a download. An announcement Download action performs one refresh before transfer. Available-update screens have no redundant check button; failed/current checks retain recovery controls. Its unboxed release-note paragraphs scroll between a fixed header and full-width download/install controls, keeping progress and actions accessible for long notes. Restoration verifies an existing completed file without starting a new transfer or installation. Missing files return to Download; an explicit Install tap rechecks the package/signature and retries Android's permission or installation flow. Completing an update clears the obsolete session on the next app start. Update offers/session preferences are excluded from cloud/device backup.
+- `assembleUpdateTest` is signed and optimized like release, uses a loopback-only test manifest and test-only metadata, and subscribes only to test_users for announcements. Its ADB-only AnnouncementTestReceiver previews an exported message locally without FCM; this receiver and test marker are rejected by shipping artifact checks. Test update/session preferences are separate. Its mapping upload and Crashlytics collection are disabled, and analytics mark it internal. Test builds must never be published; they intentionally keep the package/signature so installation can preserve listening data.
+
+
- While native predictive Back is held over a regular destination such as Home, the shell previews the saved theme for navigation and its canvas. Cancelling restores the detail palette; committed navigation retains its existing loader handoff. Scaffold foreground ownership prevents inherited black text in dark previews. JVM tests drive real Back start/progress/cancel events without committing.
- Custom theme saves are emitted through the analytics facade after preference persistence succeeds, separately from selecting an existing theme.
@@ -132,6 +141,11 @@ Routes include onboarding, home, learn, briefing, settings, debug, explore, libr
## Testing notes
+- Native announcements have no WebView, JavaScript bridge, render handshake or alternate fallback layout. Closing, scrolling and actions work immediately; image failure removes only the image. Compact surfaces use surfaceContainer and a subtle backdrop. Announcements and the updater set system-bar icon contrast on their own Dialog window without changing the underlying Activity; `NativeDialogSystemBarsTest` covers light/dark and dimmed surfaces.
+- `AnnouncementPolicyTest` and `AnnouncementPreferencesTest` cover optional fields, legacy category fallback, channel/version filtering, isolated audiences and persisted styles/actions. `UpdateSessionTest` and `AppUpdatesPanelTest` cover saved files, process recreation and the explicit permission-return retry. Sender tests compare browser/workflow approval digests and reject changed drafts before delivery. `AnnouncementRenderTest` covers release/custom actions, scoped accent roles and Markdown compatibility. Shipping artifact checks still reject isolated receivers/markers.
+- Push actions share cold/warm launch routing: registered share/episode/show links use Navigation, unmatched web links open externally, and update targets open the native updater. Known utility app-scheme links resolve to allowlisted routes, including Feedback and Lore. Legacy release APK actions map to the updater; general APK links remain ordinary links. Empty or non-allowlisted targets do not render action buttons. Unmatched verified App Links exclude boxlore from the external chooser to avoid a launch loop. `PushActionRouterTest` exercises a real JVM NavHost, and `PushAnnouncementActionTest` checks the notification's actual PendingIntent destination without sending FCM.
+- `updates/` JVM regressions cover newer/current/incompatible manifests, six-hour checks, retry backoff, manual override, coalesced requests, offline cached offers, version refreshes, malformed/untrusted/oversized metadata, checksum failure, exact range resume and servers ignoring Range. Fake HTTP responses keep these checks away from production. `UpdateEntryPointsTest` covers the action appearing/disappearing while Settings/Feedback remain usable. `AppUpdatesTest` covers quiet checks, explicit transfer, dismissal, retries, changing offers and Play not using the APK installer. `ReleaseNotesMarkdownTest` verifies adjacent emphasis and delimiters inside links or code without swallowing later formatting. Additional fixtures cover cancelling a blocked socket, package/version/signing identity and the update screen's notes, error and installation states.
+
- Unit tests live under `app/src/test`, including app container smoke coverage, sync coordinator lifecycle stability (`AppContainerSmokeTest`), `CloudSyncWorker` WorkManager execution, resolution, and periodic scheduling (`CloudSyncWorkerTest`), `CloudSyncTriggerCoordinator` auth transition handling, process start/stop debouncing, playback milestone detection, and database mutation coalescing (`CloudSyncTriggerCoordinatorTest`), worker factory mapping, FCM payload parsing (type + snake/camel ids, feedUrl/guid/enclosure), new-episode route/id, bounded slot bounds, negative-modulo non-negative floor, normalized intent `filterEquals` equivalence, and auto-download WorkManager enqueueing helpers (`NewEpisodeFcmLogicTest`), opted-in feed hydration before notify, library backup analytics error codes, import-dialog system-bar edge-to-edge (`ImportDialogSystemBarsTest`), push-target route allowlisting, bottom navigation visibility, onboarding origin derivation, and settings back navigation handling (`BottomNavPresentationTest`), cold-start destination precedence (`StartDestinationResolverTest`), launch-landing Back decisions (`LaunchSubscriptionsBackDecisionTest`), and episode navigation route building and argument decoding (`EpisodeNavRouteRegressionTest`).
- Navigation and feature UI behavior are covered mainly in feature module tests and Maestro smoke flows.
- `AdaptivePlayerChromeControllerTest` additionally covers Lore-only forcing, prior-state restoration, history exclusion, episode/availability resets and player-interaction priority.
@@ -173,3 +187,17 @@ Release uses R8 full-mode code optimization and resource shrinking. App rules pr
`.github/scripts/verify_release_contracts.py` compares pre-R8 release classes with the actual APK and AAB: worker/input-merger constructors, manifest and metadata providers, legacy services, generated Room implementations, nested Gson fields/defaults and generic field types, TypeToken signatures, Retrofit annotations/generic types and dynamic serializers (including widget snapshots). The APK resource table also verifies dynamically looked-up onboarding covers, the Android Auto label font and named resource URIs found in main/release Kotlin sources. Named raw-resource bytes must survive in both APK and AAB. The two notification chimes are explicitly retained because channel URIs resolve them by name; channel IDs and user settings remain unchanged. Both publication paths fail before publishing if a contract breaks. An optional unsigned release preflight uses the same optimization rules without production keys or SDK mapping uploads; full artifact builds do not run automatically on PRs. Fast contract-regression tests still run in PR CI.
Pending automatic-download ledger entries remain replayable by existing foreground reconciliation. This packaging change does not reset WorkManager or replay old notifications. Artifact checks cover optimization contracts; release-device smoke testing still covers SDK behavior, sign-in, playback, download execution and restored data. See [Testing](../docs/TESTING.md#optimized-release-validation).
+
+Update release notes support a listener Markdown subset: headings, bullet and numbered lists, emphasis, inline code and HTTP(S) links. Plain-text notes remain compatible; HTML is not executed.
+
+The update action footer paints behind the system navigation area; navigation insets apply inside the footer so actions stay above the gesture handle without a separate bottom color strip.
+
+When no update is offered, the update page shows the boxlore wordmark, installed version and centered status. The no-update composition is vertically centered, and the status avoids repeating the brand name already shown in the logo. Its tonal check action shows progress and prevents duplicate checks while loading; errors keep a recovery action.
+
+The up-to-date page offers a collapsed Coming next preview when the direct update feed contains optional `upcoming` Markdown. It is labelled Not released yet, cached independently of update availability and retained through failed checks. Older feeds omit it; Google Play update checks do not use this direct-release preview.
+
+In-app release and upcoming notes hide GitHub pull-request links and parenthesized PR numbers at rendering time, including cached notes. Ordinary help and product links remain visible; source release notes are unchanged.
+
+Ordinary browsing of the update page is not persisted as a startup destination. Only a matching prepared pending update can restore the install page; stale visibility flags and completed-update sessions are cleared. Returning from Android install-permission settings still preserves the pending install flow.
+
+An initial update-check state must perform a lookup even when timestamps survived a process restart. Once a result exists, normal interval and retry limits apply; timestamps alone cannot leave the page indefinitely checking.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index a2a2a57e7..412f4b356 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -70,6 +70,9 @@ android {
buildConfigField("String", "BOXLORE_CAST_RECEIVER_ID", "\"$resolvedCastReceiverId\"")
buildConfigField("String", "POSTHOG_API_KEY", "\"${localProps.getProperty("posthog.apiKey", "")}\"")
buildConfigField("String", "POSTHOG_HOST", "\"${localProps.getProperty("posthog.host", "")}\"")
+ buildConfigField("boolean", "BOXLORE_ISOLATED_TESTS", "false")
+ buildConfigField("boolean", "BOXLORE_DIRECT_UPDATES", "true")
+ buildConfigField("String", "BOXLORE_UPDATE_MANIFEST_URL", "\"https://github.com/boxcreate/boxlore/releases/latest/download/update.json\"")
}
signingConfigs {
@@ -91,6 +94,36 @@ android {
"proguard-rules.pro",
)
}
+ create("updateTest") {
+ initWith(getByName("release"))
+ matchingFallbacks += "release"
+ buildConfigField("boolean", "BOXLORE_ISOLATED_TESTS", "true")
+ buildConfigField("String", "BOXLORE_UPDATE_MANIFEST_URL", "\"http://127.0.0.1:8765/update.json\"")
+ versionNameSuffix = "-test"
+ }
+ create("playRelease") {
+ initWith(getByName("release"))
+ matchingFallbacks += "release"
+ buildConfigField("boolean", "BOXLORE_DIRECT_UPDATES", "false")
+ }
+ }
+
+ sourceSets {
+ getByName("debug") {
+ kotlin.srcDir("src/direct/java")
+ manifest.srcFile("src/direct/AndroidManifest.xml")
+ }
+ getByName("release") {
+ kotlin.srcDir("src/direct/java")
+ manifest.srcFile("src/direct/AndroidManifest.xml")
+ }
+ getByName("updateTest") {
+ kotlin.srcDir("src/direct/java")
+ kotlin.srcDir("src/release/java")
+ }
+ getByName("playRelease") {
+ kotlin.srcDir("src/release/java")
+ }
}
lint {
@@ -224,3 +257,16 @@ dependencies {
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}
+
+androidComponents {
+ onVariants(selector().withBuildType("updateTest")) { variant ->
+ providers.gradleProperty("boxloreUpdateTestVersionCode").orNull?.toInt()?.let { code ->
+ require(code > 0)
+ variant.outputs.forEach { it.versionCode.set(code) }
+ }
+ }
+}
+
+tasks.matching { it.name == "uploadCrashlyticsMappingFileUpdateTest" }.configureEach {
+ enabled = false
+}
diff --git a/app/src/direct/AndroidManifest.xml b/app/src/direct/AndroidManifest.xml
new file mode 100644
index 000000000..aa8c63f97
--- /dev/null
+++ b/app/src/direct/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/direct/java/cx/aswin/boxlore/updates/ApkFileDownloader.kt b/app/src/direct/java/cx/aswin/boxlore/updates/ApkFileDownloader.kt
new file mode 100644
index 000000000..a608e76d5
--- /dev/null
+++ b/app/src/direct/java/cx/aswin/boxlore/updates/ApkFileDownloader.kt
@@ -0,0 +1,100 @@
+package cx.aswin.boxlore.updates
+
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.security.MessageDigest
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+
+/** Range-resumable app-private transfer, keyed by checksum rather than mutable version name. */
+internal class ApkFileDownloader(private val directory: File, private val client: OkHttpClient) {
+ suspend fun download(manifest: UpdateManifest, onProgress: (Float) -> Unit): File = withContext(Dispatchers.IO) {
+ manifest.validate()
+ directory.mkdirs()
+ val stem = manifest.apkSha256.lowercase()
+ val ready = File(directory, "$stem.apk")
+ val partial = File(directory, "$stem.part")
+ if (ready.exists() && matches(ready, manifest)) return@withContext ready
+ ready.delete()
+ if (partial.length() > manifest.apkBytes) partial.delete()
+ if (partial.length() == manifest.apkBytes) {
+ if (matches(partial, manifest)) {
+ require(partial.renameTo(ready))
+ return@withContext ready
+ }
+ require(partial.delete()) { "Could not restart a corrupted transfer" }
+ }
+ val offset = partial.length()
+ val request = Request.Builder().url(manifest.apkUrl).header("Accept-Encoding", "identity")
+ .apply { if (offset > 0) header("Range", "bytes=$offset-") }.build()
+ client.newCall(request).withUpdateResponse { response ->
+ if (response.code !in listOf(200, 206)) throw IOException("APK transfer failed: ${response.code}")
+ require(response.request.url.isHttps || (cx.aswin.boxlore.BuildConfig.BOXLORE_ISOLATED_TESTS && manifest.testOnly && response.request.url.toString() == "http://127.0.0.1:8765/candidate.apk")) { "Insecure download redirect" }
+ val append = response.code == 206
+ if (append) validateContentRange(response.header("Content-Range"), offset, manifest.apkBytes)
+ val body = response.body ?: throw IOException("Empty APK")
+ var received = if (append) offset else 0L
+ onProgress(received.toFloat() / manifest.apkBytes)
+ body.byteStream().use { input ->
+ FileOutputStream(partial, append).use { output ->
+ val buffer = ByteArray(32 * 1024)
+ var lastReported = 0L
+ while (true) {
+ currentCoroutineContext().ensureActive()
+ val read = input.read(buffer)
+ if (read < 0) break
+ received += read
+ if (received > manifest.apkBytes) {
+ partial.delete()
+ throw IOException("APK larger than published size")
+ }
+ output.write(buffer, 0, read)
+ val time = System.nanoTime()
+ if (time - lastReported > 100_000_000L) {
+ onProgress(received.toFloat() / manifest.apkBytes)
+ lastReported = time
+ }
+ }
+ }
+ }
+ }
+ if (partial.length() != manifest.apkBytes) throw IOException("Incomplete APK; retry to resume")
+ if (!matches(partial, manifest)) {
+ partial.delete()
+ throw IOException("APK checksum mismatch")
+ }
+ require(partial.renameTo(ready)) { "Could not prepare APK" }
+ onProgress(1f)
+ ready
+ }
+
+ companion object {
+ fun matches(file: File, manifest: UpdateManifest): Boolean = file.isFile &&
+ file.length() == manifest.apkBytes &&
+ sha256(file).equals(manifest.apkSha256, ignoreCase = true)
+
+ fun sha256(file: File): String {
+ val digest = MessageDigest.getInstance("SHA-256")
+ file.inputStream().use { input ->
+ val buffer = ByteArray(32 * 1024)
+ while (true) {
+ val read = input.read(buffer)
+ if (read < 0) break
+ digest.update(buffer, 0, read)
+ }
+ }
+ return digest.digest().joinToString("") { "%02x".format(it) }
+ }
+ }
+}
+
+internal fun validateContentRange(value: String?, offset: Long, total: Long) {
+ val match = Regex("bytes (\\d+)-(\\d+)/(\\d+)").matchEntire(value.orEmpty()) ?: throw IOException("Invalid resume response")
+ val (start, end, size) = match.destructured
+ require(start.toLong() == offset && size.toLong() == total && end.toLong() in offset until total) { "Mismatched resume response" }
+}
diff --git a/app/src/direct/java/cx/aswin/boxlore/updates/ApkIdentity.kt b/app/src/direct/java/cx/aswin/boxlore/updates/ApkIdentity.kt
new file mode 100644
index 000000000..d6b1a7840
--- /dev/null
+++ b/app/src/direct/java/cx/aswin/boxlore/updates/ApkIdentity.kt
@@ -0,0 +1,19 @@
+package cx.aswin.boxlore.updates
+
+internal data class ApkIdentity(
+ val packageName: String,
+ val versionCode: Long,
+ val minSdk: Int,
+ val signers: Set,
+ val signingHistory: Set = emptySet(),
+)
+
+internal fun validateApkIdentity(current: ApkIdentity, candidate: ApkIdentity, manifest: UpdateManifest) {
+ require(candidate.packageName == current.packageName && candidate.packageName == manifest.packageName)
+ require(candidate.versionCode == manifest.versionCode && candidate.versionCode > current.versionCode)
+ require(candidate.minSdk == manifest.minSdk)
+ require(current.signers.isNotEmpty() && candidate.signers.isNotEmpty())
+ val matchingKeys = current.signers == candidate.signers
+ val verifiedRotation = current.signers.size == 1 && candidate.signers.size == 1 && candidate.signingHistory.containsAll(current.signers)
+ require(matchingKeys || verifiedRotation) { "APK signing identity mismatch" }
+}
diff --git a/app/src/direct/java/cx/aswin/boxlore/updates/DirectApkInstaller.kt b/app/src/direct/java/cx/aswin/boxlore/updates/DirectApkInstaller.kt
new file mode 100644
index 000000000..6b3982c32
--- /dev/null
+++ b/app/src/direct/java/cx/aswin/boxlore/updates/DirectApkInstaller.kt
@@ -0,0 +1,71 @@
+package cx.aswin.boxlore.updates
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.provider.Settings
+import androidx.core.content.FileProvider
+import java.io.File
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+internal fun createApkInstaller(context: Context): ApkInstaller = DirectApkInstaller(context.applicationContext)
+
+private class DirectApkInstaller(private val context: Context) : ApkInstaller {
+ private val downloader = ApkFileDownloader(File(context.cacheDir, "updates"), updateHttpClient())
+
+ override suspend fun restore(manifest: UpdateManifest): File? = withContext(Dispatchers.IO) {
+ val file = File(context.cacheDir, "updates/${manifest.apkSha256.lowercase()}.apk")
+ if (!file.isFile) return@withContext null
+ verify(manifest, file)
+ file
+ }
+
+ override suspend fun prepare(manifest: UpdateManifest, onState: (ApkInstallState) -> Unit): File {
+ onState(ApkInstallState(ApkInstallStage.DOWNLOADING))
+ val file = downloader.download(manifest) { onState(ApkInstallState(ApkInstallStage.DOWNLOADING, it)) }
+ onState(ApkInstallState(ApkInstallStage.VERIFYING, 1f))
+ verify(manifest, file)
+ return file
+ }
+
+ @Suppress("DEPRECATION")
+ private suspend fun verify(manifest: UpdateManifest, file: File) = withContext(Dispatchers.IO) {
+ require(ApkFileDownloader.matches(file, manifest)) { "APK changed after download" }
+ val manager = context.packageManager
+ val current = manager.getPackageInfo(context.packageName, PackageManager.GET_SIGNING_CERTIFICATES)
+ val candidate = manager.getPackageArchiveInfo(file.absolutePath, PackageManager.GET_SIGNING_CERTIFICATES)
+ ?: error("Unreadable APK")
+ val installed = current.signingInfo ?: error("Missing installed signature")
+ val downloaded = candidate.signingInfo ?: error("Missing APK signature")
+ val currentKeys = installed.apkContentsSigners.map { it.toCharsString() }.toSet()
+ val candidateKeys = downloaded.apkContentsSigners.map { it.toCharsString() }.toSet()
+ val lineage = downloaded.signingCertificateHistory?.map { it.toCharsString() }.orEmpty()
+ validateApkIdentity(
+ current = ApkIdentity(current.packageName, current.longVersionCode, current.applicationInfo?.minSdkVersion ?: 0, currentKeys),
+ candidate = ApkIdentity(candidate.packageName, candidate.longVersionCode, candidate.applicationInfo?.minSdkVersion ?: 0, candidateKeys, lineage.toSet()),
+ manifest = manifest,
+ )
+ }
+
+ override suspend fun launch(activity: Activity, manifest: UpdateManifest, file: File): Boolean {
+ verify(manifest, file)
+ return withContext(Dispatchers.Main.immediate) {
+ if (!context.packageManager.canRequestPackageInstalls()) {
+ activity.startActivity(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}")))
+ false
+ } else {
+ val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
+ activity.startActivity(
+ Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(uri, "application/vnd.android.package-archive")
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ )
+ true
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/cx/aswin/boxlore/AppContainer.kt b/app/src/main/java/cx/aswin/boxlore/AppContainer.kt
index 8a6e3c124..7b239dd9d 100644
--- a/app/src/main/java/cx/aswin/boxlore/AppContainer.kt
+++ b/app/src/main/java/cx/aswin/boxlore/AppContainer.kt
@@ -80,6 +80,7 @@ class AppContainer(
private val appContext = context.applicationContext
private val syncScope = applicationScope
+ val appUpdates by lazy { cx.aswin.boxlore.updates.createAppUpdates(appContext, syncScope) }
private val legacyRssRepairLaunchDecision =
runCatching {
@Suppress("DEPRECATION")
diff --git a/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt b/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt
index 0f5b8b806..b9f6acf6b 100644
--- a/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt
+++ b/app/src/main/java/cx/aswin/boxlore/BoxLoreApplication.kt
@@ -218,9 +218,9 @@ class BoxLoreApplication :
}
// Tag internal/test users so they can be filtered in PostHog settings
- if (BuildConfig.DEBUG) {
+ if (BuildConfig.DEBUG || BuildConfig.BOXLORE_ISOLATED_TESTS) {
PostHog.register("is_internal", true)
- PostHog.register("app_environment", "debug")
+ PostHog.register("app_environment", if (BuildConfig.BOXLORE_ISOLATED_TESTS) "update_test" else "debug")
} else {
PostHog.register("is_internal", false)
PostHog.register("app_environment", "production")
diff --git a/app/src/main/java/cx/aswin/boxlore/MainActivity.kt b/app/src/main/java/cx/aswin/boxlore/MainActivity.kt
index 2d024156c..801aaf5f4 100644
--- a/app/src/main/java/cx/aswin/boxlore/MainActivity.kt
+++ b/app/src/main/java/cx/aswin/boxlore/MainActivity.kt
@@ -66,6 +66,7 @@ class MainActivity : ComponentActivity() {
private val playAppUpdateHelper by lazy {
PlayAppUpdateHelper(this, updateLauncher)
}
+ private val appUpdates get() = (application as BoxLoreApplication).container.appUpdates
private var consumedAuthLink: String? = null
private var inFlightAuthLink: String? = null
@@ -168,7 +169,8 @@ class MainActivity : ComponentActivity() {
isCurrentlyPlaying = ::isCurrentlyPlaying,
scope = lifecycleScope,
)
- playAppUpdateHelper.resumeInProgressUpdate()
+ appUpdates.foreground()
+ if (appUpdates.usesPlay) playAppUpdateHelper.resumeInProgressUpdate()
}
override fun onStart() {
@@ -222,8 +224,11 @@ class MainActivity : ComponentActivity() {
LaunchedEffect(Unit) {
withFrameNanos {}
withFrameNanos {}
- playAppUpdateHelper.checkForUpdates()
+ appUpdates.foreground()
}
+ androidx.compose.runtime.CompositionLocalProvider(
+ cx.aswin.boxlore.updates.LocalPlayUpdateAction provides { playAppUpdateHelper.startUpdate() },
+ ) {
BoxLoreAppRoot(
activity = this@MainActivity,
application = application as BoxLoreApplication,
@@ -235,6 +240,7 @@ class MainActivity : ComponentActivity() {
it.setUiForeground(lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED))
},
)
+ }
pendingCrossDeviceAuthLink?.let { linkString ->
cx.aswin.boxlore.core.designsystem.theme.BoxLoreTheme {
diff --git a/app/src/main/java/cx/aswin/boxlore/fcm/AnnouncementPolicy.kt b/app/src/main/java/cx/aswin/boxlore/fcm/AnnouncementPolicy.kt
new file mode 100644
index 000000000..9b8f746e8
--- /dev/null
+++ b/app/src/main/java/cx/aswin/boxlore/fcm/AnnouncementPolicy.kt
@@ -0,0 +1,77 @@
+package cx.aswin.boxlore.fcm
+
+import android.content.Context
+import cx.aswin.boxlore.BuildConfig
+import cx.aswin.boxlore.core.prefs.UserPreferencesRepository.Announcement
+import cx.aswin.boxlore.ui.announcement.AnnouncementLayout
+import cx.aswin.boxlore.ui.announcement.resolveAnnouncementLayout
+import cx.aswin.boxlore.util.isInstalledFromPlayStore
+import java.net.URI
+
+internal fun ParsedFcmNotification.toAnnouncement() = Announcement(
+ title, body, route, imageUrl, actionLabel, showActionInApp, System.currentTimeMillis(), category,
+ presentation, tone, imageStyle, releaseAlert, includePlay, testOnly, releaseVersionCode, releaseUrl,
+)
+
+internal fun isReleaseAnnouncement(announcement: Announcement): Boolean = announcement.releaseAlert ?: (resolveAnnouncementLayout(announcement.category) == AnnouncementLayout.WhatsNew)
+
+/** Only the repository's public release pages can be presented as View on GitHub. */
+internal fun releaseAnnouncementUrl(announcement: Announcement): String {
+ val explicit = announcement.releaseUrl?.takeIf { value ->
+ runCatching {
+ val uri = URI(value)
+ uri.scheme == "https" &&
+ uri.host == "github.com" &&
+ uri.port == -1 &&
+ uri.userInfo == null &&
+ uri.query == null &&
+ uri.fragment == null &&
+ (uri.path == "/boxcreate/boxlore/releases/latest" || uri.path.startsWith("/boxcreate/boxlore/releases/tag/"))
+ }.getOrDefault(false)
+ }
+ if (explicit != null) return explicit
+ val tag = Regex("https://github\\.com/boxcreate/boxlore/releases/download/([^/?#]+)/[^?#]+\\.apk").matchEntire(announcement.route.orEmpty())?.groupValues?.get(1)
+ return if (tag != null) "https://github.com/boxcreate/boxlore/releases/tag/$tag" else "https://github.com/boxcreate/boxlore/releases/latest"
+}
+
+/** Only release announcements are channel-filtered. Episode and ordinary alerts remain independent. */
+internal fun announcementAllowed(announcement: Announcement, play: Boolean, testBuild: Boolean, installedVersion: Long, isolated: Boolean = false): Boolean {
+ if (isolated && !announcement.testOnly) return false
+ if (announcement.testOnly && !testBuild) return false
+ val release = isReleaseAnnouncement(announcement)
+ if (!release) return true
+ if (play && !announcement.includePlay) return false
+ return announcement.releaseVersionCode == 0L || announcement.releaseVersionCode > installedVersion
+}
+
+fun Context.shouldSuppressAnnouncement(announcement: Announcement): Boolean = !announcementAllowed(
+ announcement,
+ play = !BuildConfig.BOXLORE_DIRECT_UPDATES || isInstalledFromPlayStore(),
+ testBuild = BuildConfig.DEBUG || BuildConfig.BOXLORE_ISOLATED_TESTS,
+ installedVersion = BuildConfig.VERSION_CODE.toLong(),
+ isolated = BuildConfig.BOXLORE_ISOLATED_TESTS,
+)
+
+internal fun isUpdaterAnnouncementAction(announcement: Announcement, route: String): Boolean {
+ if (route == "boxlore://updates" || route == "boxlore://updates/download") return true
+ if (!isReleaseAnnouncement(announcement)) return false
+ return isBoxloreReleaseApk(route)
+}
+
+internal fun isBoxloreReleaseApk(route: String): Boolean = runCatching {
+ val uri = URI(route)
+ val trustedHost = uri.scheme == "https" && uri.host == "github.com"
+ val trustedAuthority = uri.port == -1 && uri.userInfo == null
+ trustedHost && trustedAuthority && uri.path.startsWith("/boxcreate/boxlore/releases/download/") && uri.path.endsWith(".apk")
+}.getOrDefault(false)
+
+internal fun pushAnnouncementTarget(notification: ParsedFcmNotification): String? {
+ val route = notification.route ?: return null
+ return if (isReleaseAnnouncement(notification.toAnnouncement()) && isBoxloreReleaseApk(route)) {
+ "boxlore://updates/download"
+ } else {
+ route
+ }
+}
+
+internal fun shouldDismissAnnouncementForAction(announcement: Announcement, route: String): Boolean = !isReleaseAnnouncement(announcement) || isUpdaterAnnouncementAction(announcement, route)
diff --git a/app/src/main/java/cx/aswin/boxlore/fcm/BoxLoreFcmService.kt b/app/src/main/java/cx/aswin/boxlore/fcm/BoxLoreFcmService.kt
index d15f1a2f7..e6e3f7d3f 100644
--- a/app/src/main/java/cx/aswin/boxlore/fcm/BoxLoreFcmService.kt
+++ b/app/src/main/java/cx/aswin/boxlore/fcm/BoxLoreFcmService.kt
@@ -9,14 +9,14 @@ import android.media.AudioAttributes
import android.net.Uri
import android.os.Build
import androidx.core.app.NotificationCompat
-import com.google.firebase.messaging.FirebaseMessaging
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import cx.aswin.boxlore.BoxLoreApplication
import cx.aswin.boxlore.core.catalog.SharedAppDependenciesHolder
import cx.aswin.boxlore.core.designsystem.components.optimizedImageUrl
import cx.aswin.boxlore.core.prefs.UserPreferencesRepository
-import cx.aswin.boxlore.ui.announcement.shouldSuppressWhatsNewOnPlay
+import cx.aswin.boxlore.fcm.shouldSuppressAnnouncement
+import cx.aswin.boxlore.navigation.PushTargetRouteAllowlist
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -34,17 +34,7 @@ class BoxLoreFcmService : FirebaseMessagingService() {
@Suppress("DEPRECATION")
super.onNewToken(token)
RssNotificationSyncWorker.enqueue(applicationContext)
- // Subscribe to the global announcements topic
- FirebaseMessaging.getInstance().subscribeToTopic("all_users")
-
- // Subscribe to environment-specific topic safely by clearing the antagonist topic
- if (cx.aswin.boxlore.BuildConfig.DEBUG) {
- FirebaseMessaging.getInstance().subscribeToTopic("debug_users")
- FirebaseMessaging.getInstance().unsubscribeFromTopic("prod_users")
- } else {
- FirebaseMessaging.getInstance().subscribeToTopic("prod_users")
- FirebaseMessaging.getInstance().unsubscribeFromTopic("debug_users")
- }
+ FcmTopicHelper.subscribeDefaultTopics(applicationContext)
}
override fun onMessageReceived(message: RemoteMessage) {
@@ -65,17 +55,11 @@ class BoxLoreFcmService : FirebaseMessagingService() {
}
val parsed = FcmPayloadParser.parse(data)
+ if (applicationContext.shouldSuppressAnnouncement(parsed.toAnnouncement())) return
if (parsed.type == "in-app" || parsed.type == "both") {
- saveInAppAnnouncement(
- parsed.title,
- parsed.body,
- parsed.route,
- parsed.imageUrl,
- parsed.actionLabel,
- parsed.showActionInApp,
- parsed.category,
- )
+ val prefs = userPreferences()
+ CoroutineScope(Dispatchers.IO).launch { prefs.setAnnouncement(parsed.toAnnouncement()) }
}
if (parsed.type == "push" || parsed.type == "both") {
@@ -84,13 +68,6 @@ class BoxLoreFcmService : FirebaseMessagingService() {
}
private fun handlePushAnnouncement(parsed: ParsedFcmNotification, type: String) {
- if (applicationContext.shouldSuppressWhatsNewOnPlay(parsed.category)) {
- android.util.Log.d(
- "BoxLoreFcmService",
- "Skipping Whats New push on Play Store install (category=${parsed.category})",
- )
- return
- }
try {
showPushNotification(parsed.copy(type = type))
} catch (e: Exception) {
@@ -136,42 +113,7 @@ class BoxLoreFcmService : FirebaseMessagingService() {
}
}
- private fun saveInAppAnnouncement(
- title: String,
- body: String,
- route: String?,
- imageUrl: String?,
- actionLabel: String?,
- showActionInApp: Boolean,
- category: String,
- ) {
- // GitHub APK "What's New" / release download CTA is meaningless on Play installs.
- if (applicationContext.shouldSuppressWhatsNewOnPlay(category)) {
- android.util.Log.d(
- "BoxLoreFcmService",
- "Skipping Whats New in-app announcement on Play Store install (category=$category)",
- )
- return
- }
-
- val prefs = userPreferences()
- CoroutineScope(Dispatchers.IO).launch {
- val announcement =
- UserPreferencesRepository.Announcement(
- title = title,
- body = body,
- route = route,
- imageUrl = imageUrl,
- actionLabel = actionLabel,
- showActionInApp = showActionInApp,
- category = category,
- timestamp = System.currentTimeMillis(),
- )
- prefs.setAnnouncement(announcement)
- }
- }
-
- private fun showPushNotification(notification: ParsedFcmNotification) {
+ internal fun showPushNotification(notification: ParsedFcmNotification) {
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val config = getPushChannelConfig(notification.sound)
@@ -199,30 +141,9 @@ class BoxLoreFcmService : FirebaseMessagingService() {
val notificationId = NewEpisodeFcmLogic.ANNOUNCEMENT_NOTIFICATION_ID_BASE + slot
val contentRequestCode = NewEpisodeFcmLogic.ANNOUNCEMENT_REQUEST_CODE_BASE + slot
val actionRequestCode = NewEpisodeFcmLogic.ANNOUNCEMENT_ACTION_REQUEST_CODE_BASE + slot
+ val target = PushTargetRouteAllowlist.sanitize(pushAnnouncementTarget(notification))
- val intent =
- createPushIntent(
- notification.route,
- notification.type,
- notification.podcastId,
- notification.episodeId,
- )
- val pendingIntent =
- try {
- PendingIntent.getActivity(
- this,
- contentRequestCode,
- intent,
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
- )
- } catch (e: SecurityException) {
- android.util.Log.w(
- "BoxLoreFcmService",
- "Failed to create PendingIntent for push announcement due to UID quota exhaustion",
- e,
- )
- null
- }
+ val pendingIntent = announcementPendingIntent(contentRequestCode, notification, target)
val notificationBuilder =
NotificationCompat
@@ -241,31 +162,9 @@ class BoxLoreFcmService : FirebaseMessagingService() {
notificationBuilder.setSound(config.soundUri)
}
- val route = notification.route
+ val route = target
if (notification.showActionInPush && !route.isNullOrBlank()) {
- val actionIntent =
- createPushIntent(
- route,
- notification.type,
- notification.podcastId,
- notification.episodeId,
- )
- val actionPendingIntent =
- try {
- PendingIntent.getActivity(
- this,
- actionRequestCode,
- actionIntent,
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
- )
- } catch (e: SecurityException) {
- android.util.Log.w(
- "BoxLoreFcmService",
- "Failed to create action PendingIntent for push announcement due to UID quota exhaustion",
- e,
- )
- null
- }
+ val actionPendingIntent = announcementPendingIntent(actionRequestCode, notification, route)
if (actionPendingIntent != null) {
notificationBuilder.addAction(
cx.aswin.boxlore.R.drawable.ic_notification_custom,
@@ -279,6 +178,18 @@ class BoxLoreFcmService : FirebaseMessagingService() {
notificationManager.notify(notificationId, notificationBuilder.build())
}
+ private fun announcementPendingIntent(requestCode: Int, notification: ParsedFcmNotification, target: String?): PendingIntent? = try {
+ PendingIntent.getActivity(
+ this,
+ requestCode,
+ createPushIntent(target, notification.type, notification.podcastId, notification.episodeId),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ } catch (e: SecurityException) {
+ android.util.Log.w("BoxLoreFcmService", "Failed to create announcement PendingIntent due to UID quota exhaustion", e)
+ null
+ }
+
private data class PushChannelConfig(val id: String, val name: String, val soundUri: Uri?, val importance: Int,)
private fun getPushChannelConfig(sound: String?): PushChannelConfig = when (sound) {
diff --git a/app/src/main/java/cx/aswin/boxlore/fcm/FcmPayloadParser.kt b/app/src/main/java/cx/aswin/boxlore/fcm/FcmPayloadParser.kt
index f542b4dec..a216f47e2 100644
--- a/app/src/main/java/cx/aswin/boxlore/fcm/FcmPayloadParser.kt
+++ b/app/src/main/java/cx/aswin/boxlore/fcm/FcmPayloadParser.kt
@@ -19,6 +19,14 @@ data class ParsedFcmNotification(
val feedUrl: String? = null,
val guid: String? = null,
val enclosureUrl: String? = null,
+ val presentation: String = "compact",
+ val tone: String = "primary",
+ val imageStyle: String = "banner",
+ val releaseAlert: Boolean? = null,
+ val includePlay: Boolean = false,
+ val testOnly: Boolean = false,
+ val releaseVersionCode: Long = 0,
+ val releaseUrl: String? = null,
)
/**
@@ -54,6 +62,14 @@ object FcmPayloadParser {
feedUrl = feedUrl(data),
guid = guid(data),
enclosureUrl = enclosureUrl(data),
+ presentation = data["presentation"]?.takeIf { it == "fullscreen" } ?: "compact",
+ tone = data["tone"]?.takeIf { it in setOf("primary", "secondary", "tertiary", "error") } ?: "primary",
+ imageStyle = data["image_style"]?.takeIf { it == "cover" } ?: "banner",
+ releaseAlert = data["release_alert"]?.toBooleanStrictOrNull(),
+ includePlay = data["include_play"] == "true",
+ testOnly = data["test_mode"] == "true",
+ releaseVersionCode = data["release_version_code"]?.toLongOrNull()?.coerceAtLeast(0) ?: 0,
+ releaseUrl = data["release_url"],
)
}
diff --git a/app/src/main/java/cx/aswin/boxlore/fcm/FcmTopicHelper.kt b/app/src/main/java/cx/aswin/boxlore/fcm/FcmTopicHelper.kt
index a4f6e149b..c83044d82 100644
--- a/app/src/main/java/cx/aswin/boxlore/fcm/FcmTopicHelper.kt
+++ b/app/src/main/java/cx/aswin/boxlore/fcm/FcmTopicHelper.kt
@@ -5,6 +5,7 @@ import android.util.Log
import com.google.firebase.messaging.FirebaseMessaging
import cx.aswin.boxlore.BuildConfig
import cx.aswin.boxlore.core.catalog.SubscriptionRepository
+import cx.aswin.boxlore.util.isInstalledFromPlayStore
import java.io.File
/** FCM topic subscribe helpers and post-restore reconciliation. */
@@ -13,16 +14,12 @@ object FcmTopicHelper {
private const val SENTINEL_NAME = "fcm_topics_synced"
/** Subscribe to broadcast topics (all_users + debug/prod). */
- fun subscribeDefaultTopics() {
+ fun subscribeDefaultTopics(context: Context) {
try {
- FirebaseMessaging.getInstance().subscribeToTopic("all_users")
- if (BuildConfig.DEBUG) {
- FirebaseMessaging.getInstance().subscribeToTopic("debug_users")
- FirebaseMessaging.getInstance().unsubscribeFromTopic("prod_users")
- } else {
- FirebaseMessaging.getInstance().subscribeToTopic("prod_users")
- FirebaseMessaging.getInstance().unsubscribeFromTopic("debug_users")
- }
+ val messaging = FirebaseMessaging.getInstance()
+ val topics = announcementTopics(BuildConfig.DEBUG, BuildConfig.BOXLORE_ISOLATED_TESTS, !BuildConfig.BOXLORE_DIRECT_UPDATES || context.isInstalledFromPlayStore())
+ topics.first.forEach { messaging.subscribeToTopic(it) }
+ topics.second.forEach { messaging.unsubscribeFromTopic(it) }
} catch (e: Exception) {
Log.e(TAG, "Failed FCM init", e)
}
@@ -44,3 +41,14 @@ object FcmTopicHelper {
}
}
}
+
+internal fun announcementTopics(debug: Boolean, isolated: Boolean, play: Boolean): Pair, Set> {
+ val all = setOf("all_users", "prod_users", "debug_users", "test_users", "play_users", "direct_users")
+ val subscribed = when {
+ isolated -> setOf("test_users")
+ debug -> setOf("all_users", "debug_users")
+ play -> setOf("all_users", "prod_users", "play_users")
+ else -> setOf("all_users", "prod_users", "direct_users")
+ }
+ return subscribed to (all - subscribed)
+}
diff --git a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt
index a1d2406ea..6b93ba187 100644
--- a/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt
+++ b/app/src/main/java/cx/aswin/boxlore/navigation/NavGraphSettingsDestinations.kt
@@ -117,6 +117,7 @@ private fun androidx.navigation.NavGraphBuilder.addMainSettingsRoute(w: NavGraph
initialPage = settingsPage,
isOnboarding = isFromOnboarding,
onSendFeedback = { navController.navigate("feedback") },
+ onCheckForUpdates = { container.appUpdates.open(checkNow = true) },
onSupportPageVisibilityChanged = w.actions.onSupportPageVisibilityChanged,
),
regionSettings = RegionSettings(
diff --git a/app/src/main/java/cx/aswin/boxlore/navigation/PushActionRouter.kt b/app/src/main/java/cx/aswin/boxlore/navigation/PushActionRouter.kt
new file mode 100644
index 000000000..95047c17a
--- /dev/null
+++ b/app/src/main/java/cx/aswin/boxlore/navigation/PushActionRouter.kt
@@ -0,0 +1,53 @@
+package cx.aswin.boxlore.navigation
+
+import android.content.ComponentName
+import android.content.Intent
+import android.net.Uri
+import cx.aswin.boxlore.BuildConfig
+import cx.aswin.boxlore.MainActivity
+import cx.aswin.boxlore.fcm.isBoxloreReleaseApk
+
+/** Dispatches notification targets without treating external links as app destinations. */
+internal fun dispatchPushAction(
+ target: String?,
+ handleDeepLink: (Intent) -> Boolean,
+ navigate: (String) -> Unit,
+ openExternal: (Uri) -> Unit,
+ openUpdater: (download: Boolean) -> Unit,
+): Boolean {
+ val route = PushTargetRouteAllowlist.sanitize(target) ?: return false
+ if (isBoxloreReleaseApk(route)) {
+ openUpdater(true)
+ return true
+ }
+ if (route in setOf("boxlore://updates", "boxlore://updates/download", "boxcast://updates", "boxcast://updates/download")) {
+ openUpdater(route.endsWith("/download"))
+ return true
+ }
+ if (!PushTargetRouteAllowlist.isAppOrWebUri(route)) {
+ navigate(route)
+ return true
+ }
+ val uri = Uri.parse(route)
+ val web = uri.scheme == "https" || uri.scheme == "http"
+ if (web && uri.host.isNullOrBlank()) return false
+ if (handleDeepLink(Intent(Intent.ACTION_VIEW, uri))) return true
+ if (web) {
+ openExternal(uri)
+ return true
+ }
+ val appRoute = uri.host.orEmpty() + uri.encodedPath.orEmpty() + (uri.encodedQuery?.let { "?$it" } ?: "")
+ val allowedAppRoute = PushTargetRouteAllowlist.sanitize(appRoute)?.takeUnless(PushTargetRouteAllowlist::isAppOrWebUri) ?: return false
+ navigate(allowedAppRoute)
+ return true
+}
+
+/** Prevents unrecognized verified App Links from dispatching back to MainActivity. */
+internal fun externalPushActionIntent(uri: Uri): Intent {
+ val intent = Intent(Intent.ACTION_VIEW, uri)
+ val claimed = uri.host == "aswin.cx" && listOf("/boxlore/share", "/boxcast/share", "/boxlore/auth").any { uri.path.orEmpty().startsWith(it) }
+ if (!claimed) return intent
+ return Intent.createChooser(intent, null).apply {
+ putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(ComponentName(BuildConfig.APPLICATION_ID, MainActivity::class.java.name)))
+ }
+}
diff --git a/app/src/main/java/cx/aswin/boxlore/navigation/PushTargetRouteAllowlist.kt b/app/src/main/java/cx/aswin/boxlore/navigation/PushTargetRouteAllowlist.kt
index 12e3b324d..d756cb0ad 100644
--- a/app/src/main/java/cx/aswin/boxlore/navigation/PushTargetRouteAllowlist.kt
+++ b/app/src/main/java/cx/aswin/boxlore/navigation/PushTargetRouteAllowlist.kt
@@ -10,6 +10,11 @@ object PushTargetRouteAllowlist {
"explore",
"library",
"settings",
+ "feedback",
+ "learn",
+ "learn/history",
+ "smart_downloads_settings",
+ "auto_download_settings",
"debug",
"onboarding",
"library/liked",
diff --git a/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt b/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt
index cb13fa0a1..902315a4d 100644
--- a/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt
+++ b/app/src/main/java/cx/aswin/boxlore/ui/BoxLoreAppRoot.kt
@@ -70,6 +70,8 @@ import cx.aswin.boxlore.core.playback.setSleepTimer
import cx.aswin.boxlore.core.playback.stopAndClearQueue
import cx.aswin.boxlore.core.prefs.PrefsFileMigrator
import cx.aswin.boxlore.fcm.FcmTopicHelper
+import cx.aswin.boxlore.fcm.isUpdaterAnnouncementAction
+import cx.aswin.boxlore.fcm.shouldSuppressAnnouncement
import cx.aswin.boxlore.feature.home.ModeSwitchState
import cx.aswin.boxlore.feature.onboarding.generateRecommendationsFromOpml
import cx.aswin.boxlore.feature.onboarding.markOnboardingCompletedSilent
@@ -83,7 +85,8 @@ import cx.aswin.boxlore.navigation.NavHostActions
import cx.aswin.boxlore.navigation.NavHostSession
import cx.aswin.boxlore.navigation.NavOpmlCallbacks
import cx.aswin.boxlore.navigation.NavSettingsState
-import cx.aswin.boxlore.navigation.PushTargetRouteAllowlist
+import cx.aswin.boxlore.navigation.dispatchPushAction
+import cx.aswin.boxlore.navigation.externalPushActionIntent
import cx.aswin.boxlore.navigation.isLaunchLandingBackRoute
import cx.aswin.boxlore.navigation.navigateBottomNavTab
import cx.aswin.boxlore.navigation.navigateHomeFromLaunchSubscriptions
@@ -94,7 +97,6 @@ import cx.aswin.boxlore.navigation.shouldShowBottomNav
import cx.aswin.boxlore.navigation.snapshotNavBackStack
import cx.aswin.boxlore.ui.announcement.FeatureAnnouncementOverlay
import cx.aswin.boxlore.ui.announcement.InAppAnnouncementDialog
-import cx.aswin.boxlore.ui.announcement.shouldSuppressWhatsNewOnPlay
import cx.aswin.boxlore.ui.libraryimport.OpmlImportDialog
import cx.aswin.boxlore.ui.libraryimport.OpmlImportDialogActions
import cx.aswin.boxlore.ui.libraryimport.OpmlImportEffects
@@ -131,48 +133,32 @@ fun BoxLoreAppRoot(
val currentWarmIntent = warmStartIntent.value
LaunchedEffect(currentWarmIntent) {
val intent = currentWarmIntent ?: return@LaunchedEffect
- if (intent.data != null) {
- val uri = intent.data
- AnalyticsHelper.trackDeepLinkOpened(
- linkScheme = uri?.scheme ?: "unknown",
- isFirstOpen = false,
- linkHost = uri?.host,
- coldStart = false,
- )
- navController.handleDeepLink(intent)
- warmStartIntent.value = null
- return@LaunchedEffect
- }
- val rawTarget = intent.getStringExtra("target_route")
- val allowed = PushTargetRouteAllowlist.sanitize(rawTarget)
- if (allowed != null) {
- if (PushTargetRouteAllowlist.isAppOrWebUri(allowed)) {
- val deepLinkIntent =
- android.content
- .Intent(
- android.content.Intent.ACTION_VIEW,
- ).apply {
- data = android.net.Uri.parse(allowed)
- }
- val uri = deepLinkIntent.data
- AnalyticsHelper.trackDeepLinkOpened(
- linkScheme = uri?.scheme ?: "unknown",
- isFirstOpen = false,
- linkHost = uri?.host,
- coldStart = false,
- )
- navController.handleDeepLink(deepLinkIntent)
- } else {
- runCatching { navController.navigate(allowed) }
- .onFailure {
- Log.w("BoxLoreAppRoot", "Ignoring invalid target_route=$allowed", it)
+ val rawTarget = intent.data?.toString() ?: intent.getStringExtra("target_route")
+ runCatching {
+ dispatchPushAction(
+ target = rawTarget,
+ handleDeepLink = { deepLinkIntent ->
+ val uri = deepLinkIntent.data
+ AnalyticsHelper.trackDeepLinkOpened(
+ linkScheme = uri?.scheme ?: "unknown",
+ isFirstOpen = false,
+ linkHost = uri?.host,
+ coldStart = false,
+ )
+ navController.handleDeepLink(if (intent.data != null) intent else deepLinkIntent)
+ },
+ navigate = { navController.navigate(it) },
+ openExternal = { activity.startActivity(externalPushActionIntent(it)) },
+ openUpdater = { download ->
+ if (download) {
+ application.container.appUpdates.openAndDownload()
+ } else {
+ application.container.appUpdates.open(checkNow = true)
}
- }
- intent.removeExtra("target_route")
- } else if (!rawTarget.isNullOrBlank()) {
- Log.w("BoxLoreAppRoot", "Rejected non-allowlisted target_route=$rawTarget")
- intent.removeExtra("target_route")
- }
+ },
+ )
+ }.onFailure { Log.w("BoxLoreAppRoot", "Unable to open notification target", it) }
+ intent.removeExtra("target_route")
warmStartIntent.value = null
}
@@ -479,7 +465,7 @@ fun BoxLoreAppRoot(
}
LaunchedEffect(Unit) {
- FcmTopicHelper.subscribeDefaultTopics()
+ FcmTopicHelper.subscribeDefaultTopics(activity)
}
var opmlImportState by remember { mutableStateOf(OpmlImportState.Idle) }
@@ -555,6 +541,7 @@ fun BoxLoreAppRoot(
surfaceStyle = surfaceStyle,
fontRoundness = fontRoundness,
) {
+ cx.aswin.boxlore.updates.AppUpdatesHost(application.container.appUpdates, activity) {
CompositionLocalProvider(LocalNavigationStyle provides navigationStyle, cx.aswin.boxlore.core.designsystem.theme.LocalArtworkNavigationColors provides artworkNavigation) {
loreQueueConflictEpisode?.let { pendingLoreEpisode ->
LoreQueueConflictDialog(
@@ -570,8 +557,8 @@ fun BoxLoreAppRoot(
if (onboardingCompleted && activeAnnouncement != null) {
val announcement = activeAnnouncement!!
val suppressWhatsNewOnPlay =
- remember(announcement.category) {
- activity.shouldSuppressWhatsNewOnPlay(announcement.category)
+ remember(announcement) {
+ activity.shouldSuppressAnnouncement(announcement)
}
if (suppressWhatsNewOnPlay) {
LaunchedEffect(announcement.timestamp, announcement.category) {
@@ -582,8 +569,15 @@ fun BoxLoreAppRoot(
announcement = announcement,
onDismiss = { scope.launch { userPrefs.clearAnnouncement() } },
onAction = { route ->
- scope.launch { userPrefs.clearAnnouncement() }
- try {
+ if (cx.aswin.boxlore.fcm.shouldDismissAnnouncementForAction(announcement, route)) scope.launch { userPrefs.clearAnnouncement() }
+ if (isUpdaterAnnouncementAction(announcement, route)) {
+ if (route == "boxlore://updates/download") {
+ application.container.appUpdates.openAndDownload()
+ } else {
+ application.container.appUpdates.open(checkNow = true)
+ }
+ } else {
+ try {
val intent =
android.content.Intent(
android.content.Intent.ACTION_VIEW,
@@ -593,6 +587,7 @@ fun BoxLoreAppRoot(
} catch (e: Exception) {
Log.e("Announcement", "Failed to open route", e)
}
+ }
},
)
}
@@ -975,5 +970,6 @@ fun BoxLoreAppRoot(
),
)
}
+ }
}
}
diff --git a/app/src/main/java/cx/aswin/boxlore/ui/NativeDialogSystemBars.kt b/app/src/main/java/cx/aswin/boxlore/ui/NativeDialogSystemBars.kt
new file mode 100644
index 000000000..c40eaa7e7
--- /dev/null
+++ b/app/src/main/java/cx/aswin/boxlore/ui/NativeDialogSystemBars.kt
@@ -0,0 +1,25 @@
+package cx.aswin.boxlore.ui
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.luminance
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.window.DialogWindowProvider
+import androidx.core.view.WindowCompat
+
+/** Change only the dialog's chrome, leaving the underlying Activity untouched. */
+@Composable
+internal fun NativeDialogSystemBars(background: Color, dimmed: Boolean = false) {
+ val view = LocalView.current
+ val darkIcons = dialogUsesDarkSystemBarIcons(background, dimmed)
+ SideEffect {
+ val window = (view.parent as? DialogWindowProvider)?.window ?: return@SideEffect
+ WindowCompat.getInsetsController(window, view).apply {
+ isAppearanceLightStatusBars = darkIcons
+ isAppearanceLightNavigationBars = darkIcons
+ }
+ }
+}
+
+internal fun dialogUsesDarkSystemBarIcons(background: Color, dimmed: Boolean): Boolean = !dimmed && background.luminance() > 0.5f
diff --git a/app/src/main/java/cx/aswin/boxlore/ui/announcement/AnnouncementActions.kt b/app/src/main/java/cx/aswin/boxlore/ui/announcement/AnnouncementActions.kt
new file mode 100644
index 000000000..54baa9a9f
--- /dev/null
+++ b/app/src/main/java/cx/aswin/boxlore/ui/announcement/AnnouncementActions.kt
@@ -0,0 +1,26 @@
+package cx.aswin.boxlore.ui.announcement
+
+import cx.aswin.boxlore.core.prefs.UserPreferencesRepository.Announcement
+import cx.aswin.boxlore.fcm.isReleaseAnnouncement
+import cx.aswin.boxlore.fcm.releaseAnnouncementUrl
+
+internal enum class AnnouncementActionKind { UPDATE, GITHUB, CUSTOM }
+internal data class AnnouncementAction(val kind: AnnouncementActionKind, val route: String, val label: String? = null)
+
+/** Release buttons always use the native updater and a validated GitHub page. */
+internal fun announcementActions(value: Announcement): List {
+ if (isReleaseAnnouncement(value)) {
+ return listOf(
+ AnnouncementAction(AnnouncementActionKind.UPDATE, "boxlore://updates/download"),
+ AnnouncementAction(AnnouncementActionKind.GITHUB, releaseAnnouncementUrl(value)),
+ )
+ }
+ val route = value.route?.takeIf { value.showActionInApp && it.isNotBlank() } ?: return emptyList()
+ return listOf(AnnouncementAction(AnnouncementActionKind.CUSTOM, route, value.actionLabel?.takeIf { it.isNotBlank() }))
+}
+
+/** Retain the previous HTTPS-only image policy without accepting file/content URLs. */
+internal fun announcementImageUrl(value: String?): String? {
+ val uri = value?.let { runCatching { java.net.URI(it) }.getOrNull() } ?: return null
+ return value.takeIf { uri.scheme.equals("https", ignoreCase = true) && !uri.host.isNullOrBlank() && uri.userInfo == null }
+}
diff --git a/app/src/main/java/cx/aswin/boxlore/ui/announcement/InAppAnnouncementDialog.kt b/app/src/main/java/cx/aswin/boxlore/ui/announcement/InAppAnnouncementDialog.kt
index 2f897b593..892bd4575 100644
--- a/app/src/main/java/cx/aswin/boxlore/ui/announcement/InAppAnnouncementDialog.kt
+++ b/app/src/main/java/cx/aswin/boxlore/ui/announcement/InAppAnnouncementDialog.kt
@@ -1,386 +1,166 @@
package cx.aswin.boxlore.ui.announcement
-import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.safeDrawingPadding
+import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
-import androidx.compose.material3.AssistChip
-import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Button
-import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
+import cx.aswin.boxlore.BuildConfig
+import cx.aswin.boxlore.R
import cx.aswin.boxlore.core.analytics.AnalyticsHelper
-import cx.aswin.boxlore.core.designsystem.theme.GoogleSansWeight
import cx.aswin.boxlore.core.prefs.UserPreferencesRepository.Announcement
+import cx.aswin.boxlore.ui.NativeDialogSystemBars
+import cx.aswin.boxlore.updates.ReleaseNotesMarkdown
+import cx.aswin.boxlore.util.isInstalledFromPlayStore
-private val ActionShape = RoundedCornerShape(16.dp)
-private val DialogShape = RoundedCornerShape(28.dp)
-private val ImageShape = RoundedCornerShape(16.dp)
-private val ActionHeight = 52.dp
-
+/** Native Material 3 presentation; the admin's browser preview follows the same visual specification. */
@Composable
-fun InAppAnnouncementDialog(announcement: Announcement, onDismiss: () -> Unit, onAction: (route: String) -> Unit,) {
- val hasImage = !announcement.imageUrl.isNullOrBlank()
- val hasAction = announcement.showActionInApp && !announcement.route.isNullOrBlank()
- val style = remember(announcement.category) { announcementLayoutStyle(announcement.category) }
-
- LaunchedEffect(announcement.timestamp, announcement.title) {
- AnalyticsHelper.trackInAppAnnouncementViewed(
- category = announcement.category,
- hasImage = hasImage,
- hasAction = hasAction,
- )
- }
-
- val dismissExplicitly = {
- AnalyticsHelper.trackInAppAnnouncementDismissed(
- category = announcement.category,
- hasImage = hasImage,
- hasAction = hasAction,
- )
+fun InAppAnnouncementDialog(announcement: Announcement, onDismiss: () -> Unit, onAction: (route: String) -> Unit) {
+ val actions = remember(announcement) { announcementActions(announcement) }
+ val fullscreen = announcement.presentation == "fullscreen"
+ val colors = announcementColors(MaterialTheme.colorScheme, announcement.tone)
+ val dismiss = {
+ AnalyticsHelper.trackInAppAnnouncementDismissed(announcement.category, !announcement.imageUrl.isNullOrBlank(), announcement.showActionInApp)
onDismiss()
}
-
- Dialog(
- onDismissRequest = { /* outside / back do not dismiss */ },
- properties =
- DialogProperties(
- dismissOnBackPress = false,
- dismissOnClickOutside = false,
- usePlatformDefaultWidth = false,
- ),
- ) {
- AnnouncementDialogCard(
- announcement = announcement,
- style = style,
- hasImage = hasImage,
- hasAction = hasAction,
- onDismissExplicitly = dismissExplicitly,
- onAction = onAction,
- )
+ LaunchedEffect(announcement.timestamp) {
+ AnalyticsHelper.trackInAppAnnouncementViewed(announcement.category, !announcement.imageUrl.isNullOrBlank(), announcement.showActionInApp && !announcement.route.isNullOrBlank())
}
-}
-
-@Composable
-private fun AnnouncementDialogCard(
- announcement: Announcement,
- style: AnnouncementLayoutStyle,
- hasImage: Boolean,
- hasAction: Boolean,
- onDismissExplicitly: () -> Unit,
- onAction: (route: String) -> Unit,
-) {
- val surfaceBorder =
- if (style.useErrorChip) {
- BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.45f))
- } else {
- null
- }
-
- Surface(
- shape = DialogShape,
- color = MaterialTheme.colorScheme.surfaceContainerHigh,
- tonalElevation = 6.dp,
- border = surfaceBorder,
- modifier =
- Modifier
- .fillMaxWidth(0.94f)
- .heightIn(max = 560.dp)
- .padding(horizontal = 12.dp, vertical = 16.dp),
- ) {
- Box {
- IconButton(
- onClick = onDismissExplicitly,
- modifier =
- Modifier
- .align(Alignment.TopEnd)
- .padding(4.dp),
+ MaterialTheme(colorScheme = colors) {
+ Dialog(onDismissRequest = dismiss, properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false)) {
+ NativeDialogSystemBars(colors.surface, dimmed = !fullscreen)
+ BoxWithConstraints(
+ Modifier.fillMaxSize()
+ .background(if (fullscreen) colors.surface else colors.scrim.copy(alpha = 0.38f))
+ .safeDrawingPadding()
+ .padding(horizontal = if (fullscreen) 0.dp else 20.dp),
+ contentAlignment = Alignment.Center,
) {
- Icon(
- imageVector = Icons.Rounded.Close,
- contentDescription = "Dismiss",
- tint = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
-
- Column(modifier = Modifier.padding(20.dp)) {
- AnnouncementCategoryChip(
- category = announcement.category,
- style = style,
- )
- AnnouncementScrollBody(
- announcement = announcement,
- style = style,
- hasImage = hasImage,
- )
- Spacer(modifier = Modifier.height(20.dp))
- AnnouncementActions(
- announcement = announcement,
- style = style,
- hasImage = hasImage,
- hasAction = hasAction,
- onDismissExplicitly = onDismissExplicitly,
- onAction = onAction,
- )
+ val panel = if (fullscreen) Modifier.fillMaxSize() else Modifier.widthIn(max = 560.dp).fillMaxWidth().heightIn(max = maxHeight * 0.9f)
+ Surface(
+ modifier = panel,
+ shape = RoundedCornerShape(if (fullscreen) 0.dp else 28.dp),
+ color = if (fullscreen) colors.surface else colors.surfaceContainer,
+ shadowElevation = if (fullscreen) 0.dp else 3.dp,
+ ) {
+ Column {
+ AnnouncementHeader(announcement.category, dismiss)
+ Column(
+ Modifier.weight(1f, fill = fullscreen).verticalScroll(rememberScrollState()).padding(horizontal = 24.dp, vertical = 12.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ AnnouncementImage(announcement)
+ Text(announcement.title, style = if (fullscreen) MaterialTheme.typography.headlineMedium else MaterialTheme.typography.headlineSmall)
+ ReleaseNotesMarkdown(announcement.body, stripPrLinks = false)
+ }
+ AnnouncementFooter(actions, dismiss, onAction)
+ }
+ }
}
}
}
}
@Composable
-private fun AnnouncementCategoryChip(category: String, style: AnnouncementLayoutStyle,) {
- val chipContainer = chipContainerColor(style)
- val chipContent = chipContentColor(style)
-
- Row(
- modifier =
- Modifier
- .fillMaxWidth()
- .padding(end = 36.dp, bottom = 12.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- AssistChip(
- onClick = {},
- label = {
- Text(
- text = category,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- style = MaterialTheme.typography.labelLarge,
- fontWeight = GoogleSansWeight.semiBold,
- )
- },
- leadingIcon = {
- Icon(
- imageVector = style.icon,
- contentDescription = null,
- modifier = Modifier.size(18.dp),
- )
- },
- colors =
- AssistChipDefaults.assistChipColors(
- containerColor = chipContainer,
- labelColor = chipContent,
- leadingIconContentColor = chipContent,
- ),
- border = null,
- )
+private fun AnnouncementHeader(category: String, onDismiss: () -> Unit) {
+ Row(Modifier.fillMaxWidth().padding(start = 24.dp, end = 16.dp, top = 12.dp), verticalAlignment = Alignment.CenterVertically) {
+ Box(Modifier.weight(1f)) {
+ Surface(shape = RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.primaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer) {
+ Text(category, Modifier.padding(horizontal = 12.dp, vertical = 8.dp), style = MaterialTheme.typography.labelMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+ }
+ IconButton(onClick = onDismiss) { Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.announcement_close)) }
}
}
@Composable
-private fun chipContainerColor(style: AnnouncementLayoutStyle): Color = when {
- style.useErrorChip -> MaterialTheme.colorScheme.errorContainer
- style.useTertiaryChip -> MaterialTheme.colorScheme.tertiaryContainer
- else -> MaterialTheme.colorScheme.primaryContainer
+private fun AnnouncementImage(announcement: Announcement) {
+ val url = announcementImageUrl(announcement.imageUrl) ?: return
+ var failed by remember(url) { mutableStateOf(false) }
+ if (failed) return
+ val cover = announcement.imageStyle == "cover"
+ AsyncImage(
+ model = url,
+ contentDescription = null,
+ contentScale = if (cover) ContentScale.Fit else ContentScale.Crop,
+ modifier = Modifier.fillMaxWidth().heightIn(max = if (cover) 260.dp else 200.dp).aspectRatio(if (cover) 1f else 16f / 9f).clip(RoundedCornerShape(20.dp)),
+ onError = { failed = true },
+ )
}
@Composable
-private fun chipContentColor(style: AnnouncementLayoutStyle): Color = when {
- style.useErrorChip -> MaterialTheme.colorScheme.onErrorContainer
- style.useTertiaryChip -> MaterialTheme.colorScheme.onTertiaryContainer
- else -> MaterialTheme.colorScheme.onPrimaryContainer
-}
-
-@Composable
-private fun ColumnScope.AnnouncementScrollBody(announcement: Announcement, style: AnnouncementLayoutStyle, hasImage: Boolean,) {
- Column(
- modifier =
- Modifier
- .weight(1f, fill = false)
- .verticalScroll(rememberScrollState()),
- ) {
- if (hasImage) {
- AsyncImage(
- model = announcement.imageUrl,
- contentDescription = null,
- modifier =
- Modifier
- .fillMaxWidth()
- .height(style.imageHeight)
- .clip(ImageShape),
- contentScale = ContentScale.Crop,
- )
- Spacer(modifier = Modifier.height(if (style.emphasizeImage) 12.dp else 16.dp))
+private fun AnnouncementFooter(actions: List, onDismiss: () -> Unit, onAction: (String) -> Unit) {
+ if (actions.firstOrNull()?.kind == AnnouncementActionKind.UPDATE) {
+ Column(Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ actions.forEach { item -> AnnouncementActionButton(item, Modifier.fillMaxWidth(), onAction) }
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.announcement_dismiss)) }
}
-
- Text(
- text = announcement.title,
- style = MaterialTheme.typography.headlineSmall,
- fontWeight =
- if (style.layout == AnnouncementLayout.Tip) {
- GoogleSansWeight.medium
- } else {
- GoogleSansWeight.semiBold
- },
- color = MaterialTheme.colorScheme.onSurface,
- modifier = Modifier.padding(bottom = 12.dp),
- )
-
- AnnouncementBodyBlocks(
- body = announcement.body,
- style = style,
- )
- }
-}
-
-@Composable
-private fun AnnouncementBodyBlocks(body: String, style: AnnouncementLayoutStyle,) {
- val blocks = remember(body) { parseBodyToBlocks(body) }
- val bodySpacing = if (style.emphasizeBullets) 8.dp else 10.dp
- val bulletColor =
- if (style.useErrorChip) {
- MaterialTheme.colorScheme.error
- } else {
- MaterialTheme.colorScheme.primary
- }
- val bulletTextStyle =
- if (style.emphasizeBullets) {
- MaterialTheme.typography.bodyLarge
- } else {
- MaterialTheme.typography.bodyMedium
- }
-
- Column(verticalArrangement = Arrangement.spacedBy(bodySpacing)) {
- blocks.forEach { block ->
- AnnouncementBodyBlockRow(
- block = block,
- bulletColor = bulletColor,
- bulletTextStyle = bulletTextStyle,
- )
+ } else {
+ FlowRow(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)) {
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.announcement_dismiss)) }
+ actions.forEach { item -> AnnouncementActionButton(item, Modifier, onAction) }
}
}
}
@Composable
-private fun AnnouncementBodyBlockRow(block: BodyBlock, bulletColor: Color, bulletTextStyle: androidx.compose.ui.text.TextStyle,) {
- when {
- block.isSpacer -> Spacer(modifier = Modifier.height(6.dp))
- block.isBullet -> {
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.Top,
- ) {
- Text(
- text = "•",
- style = MaterialTheme.typography.bodyMedium,
- fontWeight = GoogleSansWeight.bold,
- color = bulletColor,
- modifier = Modifier.padding(start = 4.dp, end = 10.dp),
- )
- Text(
- text = block.text,
- style = bulletTextStyle,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.weight(1f),
- )
- }
- }
- else -> {
- Text(
- text = block.text,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.fillMaxWidth(),
- )
- }
+private fun AnnouncementActionButton(item: AnnouncementAction, modifier: Modifier, onAction: (String) -> Unit) {
+ val play = !BuildConfig.BOXLORE_DIRECT_UPDATES || LocalContext.current.isInstalledFromPlayStore()
+ val label = when (item.kind) {
+ AnnouncementActionKind.UPDATE -> stringResource(if (play) R.string.announcement_play else R.string.announcement_download)
+ AnnouncementActionKind.GITHUB -> stringResource(R.string.announcement_github)
+ AnnouncementActionKind.CUSTOM -> item.label ?: stringResource(R.string.announcement_open)
+ }
+ if (item.kind == AnnouncementActionKind.GITHUB) {
+ FilledTonalButton(onClick = { onAction(item.route) }, modifier = modifier.heightIn(min = 48.dp)) { Text(label) }
+ } else {
+ Button(onClick = { onAction(item.route) }, modifier = modifier.heightIn(min = 48.dp)) { Text(label) }
}
}
-@Composable
-private fun AnnouncementActions(
- announcement: Announcement,
- style: AnnouncementLayoutStyle,
- hasImage: Boolean,
- hasAction: Boolean,
- onDismissExplicitly: () -> Unit,
- onAction: (route: String) -> Unit,
-) {
- Column(
- modifier = Modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(10.dp),
- ) {
- if (hasAction) {
- val route = announcement.route.orEmpty()
- val buttonText = announcement.actionLabel?.takeIf { it.isNotBlank() } ?: "View"
- val containerColor =
- if (style.useErrorChip) {
- MaterialTheme.colorScheme.error
- } else {
- MaterialTheme.colorScheme.primary
- }
- Button(
- onClick = {
- AnalyticsHelper.trackInAppAnnouncementAction(
- category = announcement.category,
- hasImage = hasImage,
- actionLabel = buttonText,
- )
- onAction(route)
- },
- modifier =
- Modifier
- .fillMaxWidth()
- .height(ActionHeight),
- shape = ActionShape,
- colors =
- ButtonDefaults.buttonColors(
- containerColor = containerColor,
- ),
- ) {
- Text(
- text = buttonText,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- style = MaterialTheme.typography.labelLarge,
- fontWeight = GoogleSansWeight.semiBold,
- )
- }
- }
-
- OutlinedButton(
- onClick = onDismissExplicitly,
- modifier =
- Modifier
- .fillMaxWidth()
- .height(ActionHeight),
- shape = ActionShape,
- ) {
- Text(
- text = "Dismiss",
- style = MaterialTheme.typography.labelLarge,
- )
- }
- }
+internal fun announcementColors(colors: ColorScheme, tone: String): ColorScheme = when (tone) {
+ "secondary" -> colors.copy(primary = colors.secondary, onPrimary = colors.onSecondary, primaryContainer = colors.secondaryContainer, onPrimaryContainer = colors.onSecondaryContainer)
+ "tertiary" -> colors.copy(primary = colors.tertiary, onPrimary = colors.onTertiary, primaryContainer = colors.tertiaryContainer, onPrimaryContainer = colors.onTertiaryContainer)
+ "error" -> colors.copy(primary = colors.error, onPrimary = colors.onError, primaryContainer = colors.errorContainer, onPrimaryContainer = colors.onErrorContainer)
+ else -> colors
}
diff --git a/app/src/main/java/cx/aswin/boxlore/updates/ApkInstaller.kt b/app/src/main/java/cx/aswin/boxlore/updates/ApkInstaller.kt
new file mode 100644
index 000000000..af222295c
--- /dev/null
+++ b/app/src/main/java/cx/aswin/boxlore/updates/ApkInstaller.kt
@@ -0,0 +1,17 @@
+package cx.aswin.boxlore.updates
+
+import android.app.Activity
+import java.io.File
+
+enum class ApkInstallStage { IDLE, DOWNLOADING, VERIFYING, READY, PERMISSION, FAILED }
+
+data class ApkInstallState(val stage: ApkInstallStage = ApkInstallStage.IDLE, val progress: Float = 0f)
+
+/** Implemented in direct sources; the Play bundle has only a no-installer implementation. */
+internal interface ApkInstaller {
+ suspend fun prepare(manifest: UpdateManifest, onState: (ApkInstallState) -> Unit): File
+
+ /** Verify an existing completed file only. Restoration never starts a new network transfer. */
+ suspend fun restore(manifest: UpdateManifest): File? = null
+ suspend fun launch(activity: Activity, manifest: UpdateManifest, file: File): Boolean
+}
diff --git a/app/src/main/java/cx/aswin/boxlore/updates/AppUpdates.kt b/app/src/main/java/cx/aswin/boxlore/updates/AppUpdates.kt
new file mode 100644
index 000000000..dfc9ddb03
--- /dev/null
+++ b/app/src/main/java/cx/aswin/boxlore/updates/AppUpdates.kt
@@ -0,0 +1,254 @@
+package cx.aswin.boxlore.updates
+
+import android.app.Activity
+import android.content.Context
+import android.os.Build
+import cx.aswin.boxlore.BuildConfig
+import cx.aswin.boxlore.util.isInstalledFromPlayStore
+import java.io.File
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+
+/** Application-scoped checks/downloads survive Activity recreation without retaining it. */
+class AppUpdates internal constructor(
+ val checker: UpdateChecker,
+ val usesPlay: Boolean,
+ private val installer: ApkInstaller,
+ private val scope: CoroutineScope,
+ private val session: UpdateSessionStore = MemoryUpdateSessionStore(),
+) {
+ private val mutableInstall = MutableStateFlow(ApkInstallState())
+ val install = mutableInstall.asStateFlow()
+ private val mutableVisible = MutableStateFlow(false)
+ val visible = mutableVisible.asStateFlow()
+ private var restoring: Job? = null
+ private var restoringChecksum: String? = null
+ private var transfer: Job? = null
+ private var transferChecksum: String? = null
+ private var prepared: Pair? = null
+ private var launchingInstall = false
+
+ init {
+ scope.launch {
+ checker.state.map { it.offer?.manifest?.apkSha256 }.distinctUntilChanged().collect { checksum ->
+ val obsoleteTransfer = transferChecksum != null && transferChecksum != checksum
+ val obsoleteRestore = restoringChecksum != null && restoringChecksum != checksum
+ val obsoletePrepared = prepared != null && prepared?.first?.apkSha256 != checksum
+ if (obsoleteTransfer || obsoleteRestore || obsoletePrepared) {
+ transfer?.cancel()
+ restoring?.cancel()
+ prepared = null
+ session.preparedChecksum = null
+ mutableInstall.value = ApkInstallState()
+ }
+ restoreSession()
+ }
+ }
+ }
+
+ fun foreground() {
+ restoreSession()
+ scope.launch { checker.check() }
+ }
+
+ private fun restoreSession() {
+ if (!session.visible || prepared != null || restoring?.isActive == true) return
+ val manifest = checker.state.value.offer?.manifest
+ if (manifest == null || session.preparedChecksum != manifest.apkSha256) {
+ session.visible = false
+ session.preparedChecksum = null
+ return
+ }
+ mutableVisible.value = true
+ restoringChecksum = manifest.apkSha256
+ restoring = scope.launch {
+ mutableInstall.value = ApkInstallState(ApkInstallStage.VERIFYING, 1f)
+ try {
+ restorePreparedUpdate(manifest)
+ } catch (cancelled: CancellationException) {
+ throw cancelled
+ } catch (_: Exception) {
+ mutableInstall.value = ApkInstallState(ApkInstallStage.FAILED)
+ } finally {
+ if (restoringChecksum == manifest.apkSha256) restoringChecksum = null
+ }
+ }
+ }
+
+ private suspend fun restorePreparedUpdate(manifest: UpdateManifest) {
+ val file = installer.restore(manifest)
+ if (checker.state.value.offer?.manifest?.apkSha256 != manifest.apkSha256) return
+ prepared = file?.let { manifest to it }
+ if (file == null) session.preparedChecksum = null
+ mutableInstall.value = if (file != null) ApkInstallState(ApkInstallStage.READY, 1f) else ApkInstallState()
+ }
+
+ fun open(checkNow: Boolean = true) {
+ mutableVisible.value = true
+ // Persist only a prepared update, not ordinary browsing of this screen.
+ session.visible = session.preparedChecksum != null
+ if (checkNow) scope.launch { checker.check(force = true) }
+ }
+
+ /** An explicit announcement Download tap checks metadata before any transfer. */
+ fun openAndDownload() {
+ open(checkNow = false)
+ scope.launch {
+ checker.check(force = true)
+ restoring?.join()
+ if (!usesPlay && checker.state.value.status == UpdateCheckStatus.AVAILABLE && prepared == null) download()
+ }
+ }
+
+ fun dismiss() {
+ mutableVisible.value = false
+ session.visible = false
+ }
+
+ fun download() {
+ val manifest = checker.state.value.offer?.manifest ?: return
+ if (transfer?.isCompleted == false || restoring?.isActive == true) return
+ transferChecksum = manifest.apkSha256
+ transfer = scope.launch {
+ try {
+ val file = installer.prepare(manifest) {
+ if (checker.state.value.offer?.manifest?.apkSha256 == manifest.apkSha256) mutableInstall.value = it
+ }
+ if (checker.state.value.offer?.manifest?.apkSha256 != manifest.apkSha256) {
+ mutableInstall.value = ApkInstallState()
+ return@launch
+ }
+ prepared = manifest to file
+ session.preparedChecksum = manifest.apkSha256
+ session.visible = mutableVisible.value
+ mutableInstall.value = ApkInstallState(ApkInstallStage.READY, 1f)
+ } catch (cancelled: CancellationException) {
+ mutableInstall.value = ApkInstallState()
+ throw cancelled
+ } catch (_: Exception) {
+ mutableInstall.value = ApkInstallState(ApkInstallStage.FAILED)
+ } finally {
+ transferChecksum = null
+ }
+ }
+ }
+
+ fun pauseDownload() {
+ transfer?.cancel()
+ }
+
+ /** Call only after an explicit Install tap; Android owns installation confirmation. */
+ suspend fun install(activity: Activity) {
+ val (manifest, file) = prepared ?: return
+ if (launchingInstall) return
+ session.visible = true
+ session.preparedChecksum = manifest.apkSha256
+ launchingInstall = true
+ try {
+ mutableInstall.value = ApkInstallState(ApkInstallStage.VERIFYING, 1f)
+ val launched = installer.launch(activity, manifest, file)
+ mutableInstall.value = ApkInstallState(if (launched) ApkInstallStage.READY else ApkInstallStage.PERMISSION, 1f)
+ } catch (cancelled: CancellationException) {
+ mutableInstall.value = if (prepared?.first == manifest) ApkInstallState(ApkInstallStage.READY, 1f) else ApkInstallState()
+ throw cancelled
+ } catch (_: Exception) {
+ mutableInstall.value = ApkInstallState(ApkInstallStage.FAILED)
+ } finally {
+ launchingInstall = false
+ }
+ }
+}
+
+internal fun createAppUpdates(context: Context, scope: CoroutineScope): AppUpdates {
+ val usesPlay = !BuildConfig.BOXLORE_DIRECT_UPDATES || context.isInstalledFromPlayStore()
+ val checkStore = AndroidUpdateCheckStore(context, usesPlay)
+ val session = AndroidUpdateSessionStore(context)
+ val cachedVersion = checkStore.cachedManifest?.versionCode ?: checkStore.cachedPlayVersion
+ if (cachedVersion in 1..BuildConfig.VERSION_CODE.toLong()) {
+ session.visible = false
+ session.preparedChecksum = null
+ }
+ val source = if (usesPlay) {
+ PlayUpdateSource(context)
+ } else {
+ DirectUpdateSource(updateHttpClient(), BuildConfig.BOXLORE_UPDATE_MANIFEST_URL, BuildConfig.VERSION_CODE.toLong(), Build.VERSION.SDK_INT)
+ }
+ return AppUpdates(
+ checker = UpdateChecker(source, checkStore, BuildConfig.VERSION_CODE.toLong(), Build.VERSION.SDK_INT),
+ usesPlay = usesPlay,
+ installer = createApkInstaller(context),
+ // Serialize UI actions, transfer ownership and offer invalidation on Main.
+ // Lookup and file work explicitly move to IO while retaining the process lifetime.
+ scope = CoroutineScope(scope.coroutineContext + Dispatchers.Main.immediate),
+ session = session,
+ )
+}
+
+private class AndroidUpdateCheckStore(context: Context, usesPlay: Boolean) : UpdateCheckStore {
+ private val preferences = context.getSharedPreferences(
+ if (BuildConfig.BOXLORE_ISOLATED_TESTS) {
+ "boxlore_test_updates"
+ } else if (usesPlay) {
+ "boxlore_play_updates"
+ } else {
+ "boxlore_direct_updates"
+ },
+ Context.MODE_PRIVATE
+ )
+ override var checkedAt: Long
+ get() = preferences.getLong("checked_at", 0)
+ set(value) {
+ preferences.edit().putLong("checked_at", value).apply()
+ }
+ override var attemptedAt: Long
+ get() = preferences.getLong("attempted_at", 0)
+ set(value) {
+ preferences.edit().putLong("attempted_at", value).apply()
+ }
+ override var cachedManifest: UpdateManifest?
+ get() = runCatching { preferences.getString("manifest", null)?.let(UpdateManifest::parse) }.getOrNull()
+ set(value) {
+ preferences.edit().putString("manifest", value?.encode()).apply()
+ }
+ override var upcoming: String
+ get() = preferences.getString("upcoming", "").orEmpty()
+ set(value) {
+ preferences.edit().putString("upcoming", value).apply()
+ }
+ override var cachedPlayVersion: Long
+ get() = preferences.getLong("play_offer_version", 0)
+ set(value) {
+ preferences.edit().putLong("play_offer_version", value).apply()
+ }
+}
+
+internal interface UpdateSessionStore {
+ var visible: Boolean
+ var preparedChecksum: String?
+}
+
+internal class MemoryUpdateSessionStore : UpdateSessionStore {
+ override var visible = false
+ override var preparedChecksum: String? = null
+}
+
+private class AndroidUpdateSessionStore(context: Context) : UpdateSessionStore {
+ private val prefs = context.getSharedPreferences(if (BuildConfig.BOXLORE_ISOLATED_TESTS) "boxlore_test_update_session" else "boxlore_update_session", Context.MODE_PRIVATE)
+ override var visible: Boolean
+ get() = prefs.getBoolean("visible", false)
+ set(value) {
+ prefs.edit().putBoolean("visible", value).apply()
+ }
+ override var preparedChecksum: String?
+ get() = prefs.getString("prepared", null)
+ set(value) {
+ prefs.edit().putString("prepared", value).apply()
+ }
+}
diff --git a/app/src/main/java/cx/aswin/boxlore/updates/AppUpdatesHost.kt b/app/src/main/java/cx/aswin/boxlore/updates/AppUpdatesHost.kt
new file mode 100644
index 000000000..0f52feb45
--- /dev/null
+++ b/app/src/main/java/cx/aswin/boxlore/updates/AppUpdatesHost.kt
@@ -0,0 +1,28 @@
+package cx.aswin.boxlore.updates
+
+import android.app.Activity
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import cx.aswin.boxlore.core.designsystem.components.LocalUpdateAvailableAction
+import kotlinx.coroutines.launch
+
+val LocalPlayUpdateAction = staticCompositionLocalOf<() -> Unit> { {} }
+
+@Composable
+fun AppUpdatesHost(updates: AppUpdates, activity: Activity, content: @Composable () -> Unit) {
+ val scope = rememberCoroutineScope()
+ val check by updates.checker.state.collectAsStateWithLifecycle()
+ val updateAction: (() -> Unit)? = if (check.updateAvailable) ({ updates.open() }) else null
+ CompositionLocalProvider(LocalUpdateAvailableAction provides updateAction) {
+ AppUpdatesPanel(
+ updates = updates,
+ onInstall = { scope.launch { updates.install(activity) } },
+ onPlayUpdate = LocalPlayUpdateAction.current,
+ )
+ content()
+ }
+}
diff --git a/app/src/main/java/cx/aswin/boxlore/updates/AppUpdatesPanel.kt b/app/src/main/java/cx/aswin/boxlore/updates/AppUpdatesPanel.kt
new file mode 100644
index 000000000..cdf4d52e2
--- /dev/null
+++ b/app/src/main/java/cx/aswin/boxlore/updates/AppUpdatesPanel.kt
@@ -0,0 +1,223 @@
+package cx.aswin.boxlore.updates
+
+import android.content.Intent
+import android.net.Uri
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.WindowInsetsSides
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.only
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.ArrowBack
+import androidx.compose.material.icons.rounded.ExpandLess
+import androidx.compose.material.icons.rounded.ExpandMore
+import androidx.compose.material.icons.rounded.OpenInNew
+import androidx.compose.material.icons.rounded.Refresh
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.liveRegion
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import cx.aswin.boxlore.R
+import cx.aswin.boxlore.ui.NativeDialogSystemBars
+
+@Composable
+fun AppUpdatesPanel(updates: AppUpdates, onInstall: () -> Unit, onPlayUpdate: () -> Unit) {
+ val visible by updates.visible.collectAsStateWithLifecycle()
+ if (!visible) return
+ val check by updates.checker.state.collectAsStateWithLifecycle()
+ val install by updates.install.collectAsStateWithLifecycle()
+ Dialog(
+ onDismissRequest = updates::dismiss,
+ properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
+ ) {
+ NativeDialogSystemBars(MaterialTheme.colorScheme.surface)
+ Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxSize()) {
+ Column(Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal))) {
+ Row(Modifier.heightIn(min = 56.dp).padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically) {
+ IconButton(onClick = updates::dismiss) {
+ Icon(Icons.AutoMirrored.Rounded.ArrowBack, stringResource(R.string.updates_close))
+ }
+ Text(stringResource(R.string.updates_title), style = MaterialTheme.typography.titleLarge)
+ }
+ if (check.status == UpdateCheckStatus.CHECKING) LinearProgressIndicator(Modifier.fillMaxWidth())
+ Column(
+ modifier = Modifier.fillMaxWidth().weight(1f).then(if (check.offer == null) Modifier.navigationBarsPadding() else Modifier).verticalScroll(rememberScrollState()).padding(24.dp),
+ verticalArrangement = if (check.offer == null) Arrangement.Center else Arrangement.spacedBy(20.dp),
+ ) {
+ if (cx.aswin.boxlore.BuildConfig.BOXLORE_ISOLATED_TESTS) Text(stringResource(R.string.updates_test_build), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ val offer = check.offer
+ if (offer != null) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(stringResource(if (updates.usesPlay) R.string.updates_play_available else R.string.updates_available), style = MaterialTheme.typography.headlineLarge)
+ if (!updates.usesPlay) Text(stringResource(R.string.updates_version, offer.versionName), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ offer.manifest?.let { ReleaseNotes(it) }
+ } else {
+ UpdateStatusContent(check.status, onCheck = { updates.open(checkNow = true) })
+ UpcomingChanges(check.upcoming)
+ }
+ CachedUpdateCheckFailure(check, updates)
+ }
+ if (check.offer != null) {
+ Surface(
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Column(Modifier.navigationBarsPadding().padding(horizontal = 24.dp, vertical = 16.dp)) {
+ UpdateInstallContent(updates, install, onInstall, onPlayUpdate, check.status != UpdateCheckStatus.CHECKING)
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CachedUpdateCheckFailure(check: UpdateCheckState, updates: AppUpdates) {
+ if (check.offer == null || check.status != UpdateCheckStatus.FAILED) return
+ Text(stringResource(R.string.updates_check_failed), color = MaterialTheme.colorScheme.error)
+ TextButton(onClick = { updates.open(checkNow = true) }) { Text(stringResource(R.string.updates_check)) }
+}
+
+@Composable
+private fun UpcomingChanges(notes: String) {
+ if (notes.isBlank()) return
+ var expanded by rememberSaveable { mutableStateOf(false) }
+ Surface(color = MaterialTheme.colorScheme.surfaceContainerLow, shape = MaterialTheme.shapes.large) {
+ Column(Modifier.fillMaxWidth().padding(8.dp)) {
+ TextButton(onClick = { expanded = !expanded }, modifier = Modifier.fillMaxWidth()) {
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(stringResource(R.string.updates_upcoming), style = MaterialTheme.typography.titleMedium)
+ Text(stringResource(R.string.updates_unreleased), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ Icon(if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore, contentDescription = null)
+ }
+ if (expanded) Column(Modifier.padding(16.dp)) { ReleaseNotesMarkdown(notes) }
+ }
+ }
+}
+
+@Composable
+private fun UpdateStatusContent(status: UpdateCheckStatus, onCheck: () -> Unit) {
+ val checking = status == UpdateCheckStatus.CHECKING || status == UpdateCheckStatus.IDLE
+ Column(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ ) {
+ Icon(painterResource(R.drawable.ic_boxlore_wordmark_mic), contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.height(32.dp).aspectRatio(6213f / 820f))
+ Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Text(
+ stringResource(checkStatusText(status)),
+ style = MaterialTheme.typography.headlineMedium,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
+ )
+ Text(
+ stringResource(R.string.updates_version, cx.aswin.boxlore.BuildConfig.VERSION_NAME),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ FilledTonalButton(onClick = onCheck, enabled = !checking, modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp)) {
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically) {
+ if (checking) {
+ CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
+ } else {
+ Icon(Icons.Rounded.Refresh, contentDescription = null, modifier = Modifier.size(20.dp))
+ }
+ Text(stringResource(if (checking) R.string.updates_checking else R.string.updates_check))
+ }
+ }
+ }
+}
+
+private fun checkStatusText(status: UpdateCheckStatus): Int = when (status) {
+ UpdateCheckStatus.CURRENT -> R.string.updates_current
+ UpdateCheckStatus.FAILED -> R.string.updates_check_failed
+ UpdateCheckStatus.INCOMPATIBLE -> R.string.updates_incompatible
+ else -> R.string.updates_checking
+}
+
+@Composable
+private fun ReleaseNotes(manifest: UpdateManifest) {
+ val context = LocalContext.current
+ Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ Text(stringResource(R.string.updates_notes), style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f))
+ IconButton(onClick = { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(manifest.notesUrl))) }) {
+ Icon(Icons.Rounded.OpenInNew, stringResource(R.string.updates_read_notes), Modifier.size(20.dp))
+ }
+ }
+ ReleaseNotesMarkdown(manifest.notes)
+ }
+}
+
+@Composable
+private fun UpdateInstallContent(updates: AppUpdates, state: ApkInstallState, onInstall: () -> Unit, onPlayUpdate: () -> Unit, enabled: Boolean) {
+ if (updates.usesPlay) {
+ Button(onClick = onPlayUpdate, enabled = enabled, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.updates_play)) }
+ return
+ }
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }) {
+ when (state.stage) {
+ ApkInstallStage.DOWNLOADING -> {
+ LinearProgressIndicator(progress = { state.progress }, modifier = Modifier.fillMaxWidth())
+ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ Text(stringResource(R.string.updates_downloading, (state.progress * 100).toInt()), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
+ TextButton(onClick = updates::pauseDownload) { Text(stringResource(R.string.updates_pause)) }
+ }
+ }
+ ApkInstallStage.VERIFYING -> {
+ LinearProgressIndicator(Modifier.fillMaxWidth())
+ Text(stringResource(R.string.updates_verifying))
+ }
+ ApkInstallStage.READY, ApkInstallStage.PERMISSION -> {
+ Text(stringResource(if (state.stage == ApkInstallStage.PERMISSION) R.string.updates_permission else R.string.updates_ready))
+ Button(onClick = onInstall, enabled = enabled, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.updates_install)) }
+ }
+ else -> {
+ if (state.stage == ApkInstallStage.FAILED) Text(stringResource(R.string.updates_transfer_failed), color = MaterialTheme.colorScheme.error)
+ Button(onClick = updates::download, enabled = enabled, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.updates_download)) }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/cx/aswin/boxlore/updates/DirectUpdateSource.kt b/app/src/main/java/cx/aswin/boxlore/updates/DirectUpdateSource.kt
new file mode 100644
index 000000000..5d28d8dd9
--- /dev/null
+++ b/app/src/main/java/cx/aswin/boxlore/updates/DirectUpdateSource.kt
@@ -0,0 +1,43 @@
+package cx.aswin.boxlore.updates
+
+import java.io.IOException
+import java.util.concurrent.TimeUnit
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+
+internal fun updateHttpClient(): OkHttpClient = OkHttpClient.Builder()
+ .connectTimeout(15, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .followSslRedirects(false)
+ .build()
+
+/** Dedicated unauthenticated HTTP client: never send backend tokens to release URLs. */
+internal class DirectUpdateSource(
+ private val client: OkHttpClient,
+ private val manifestUrl: String,
+ private val installedVersion: Long,
+ private val sdkInt: Int,
+) : UpdateSource {
+ override suspend fun lookup(): UpdateLookup = withContext(Dispatchers.IO) {
+ val request = Request.Builder().url(manifestUrl).header("Cache-Control", "no-cache").build()
+ client.newCall(request).withUpdateResponse { response ->
+ if (!response.isSuccessful) throw IOException("Update lookup failed: ${response.code}")
+ val body = response.body ?: throw IOException("Empty update response")
+ val source = body.source()
+ source.request(MAX_MANIFEST_BYTES + 1)
+ val bytes = source.readByteArray(source.buffer.size.coerceAtMost(MAX_MANIFEST_BYTES + 1))
+ require(bytes.size <= MAX_MANIFEST_BYTES) { "Update manifest too large" }
+ val manifest = UpdateManifest.parse(bytes.toString(Charsets.UTF_8))
+ if (cx.aswin.boxlore.BuildConfig.BOXLORE_ISOLATED_TESTS) require(manifest.testOnly)
+ if (manifest.versionCode <= installedVersion) return@withUpdateResponse UpdateLookup(upcoming = manifest.upcoming)
+ if (manifest.minSdk > sdkInt) return@withUpdateResponse UpdateLookup(incompatible = true, upcoming = manifest.upcoming)
+ UpdateLookup(UpdateOffer(manifest.versionCode, manifest.versionName, manifest), upcoming = manifest.upcoming)
+ }
+ }
+
+ companion object {
+ private const val MAX_MANIFEST_BYTES = 64L * 1024
+ }
+}
diff --git a/app/src/main/java/cx/aswin/boxlore/updates/PlayAppUpdateHelper.kt b/app/src/main/java/cx/aswin/boxlore/updates/PlayAppUpdateHelper.kt
index a9f1c5e44..731d3d782 100644
--- a/app/src/main/java/cx/aswin/boxlore/updates/PlayAppUpdateHelper.kt
+++ b/app/src/main/java/cx/aswin/boxlore/updates/PlayAppUpdateHelper.kt
@@ -2,6 +2,7 @@ package cx.aswin.boxlore.updates
import android.app.Activity
import android.util.Log
+import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.IntentSenderRequest
import com.google.android.play.core.appupdate.AppUpdateManager
@@ -10,6 +11,7 @@ import com.google.android.play.core.appupdate.AppUpdateOptions
import com.google.android.play.core.install.model.AppUpdateType
import com.google.android.play.core.install.model.InstallStatus
import com.google.android.play.core.install.model.UpdateAvailability
+import cx.aswin.boxlore.R
/**
* Google Play In-App Updates wiring owned by the Activity shell (not [AppContainer]).
@@ -19,39 +21,43 @@ class PlayAppUpdateHelper(private val activity: Activity, private val updateLaun
AppUpdateManagerFactory.create(activity)
}
- fun checkForUpdates() {
+ /** Fresh Play info for each explicit update tap; no prompt during availability checks. */
+ fun startUpdate() {
try {
appUpdateManager.appUpdateInfo.addOnSuccessListener { appUpdateInfo ->
+ if (appUpdateInfo.installStatus() == InstallStatus.DOWNLOADED) {
+ appUpdateManager.completeUpdate().addOnFailureListener(::showUpdateError)
+ return@addOnSuccessListener
+ }
val isUpdateAvailable =
appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE
val updatePriority = appUpdateInfo.updatePriority()
val daysStale = appUpdateInfo.clientVersionStalenessDays() ?: 0
- val isHighPriority = updatePriority >= 4 || daysStale >= 7
-
- val updateType = when {
- isHighPriority && appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE) ->
- AppUpdateType.IMMEDIATE
- appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE) ->
- AppUpdateType.FLEXIBLE
- else -> null
- }
+ val updateType = choosePlayUpdateType(
+ highPriority = updatePriority >= 4 || daysStale >= 7,
+ immediateAllowed = appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE),
+ flexibleAllowed = appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE),
+ )
if (isUpdateAvailable && updateType != null) {
try {
- appUpdateManager.startUpdateFlowForResult(
+ val started = appUpdateManager.startUpdateFlowForResult(
appUpdateInfo,
updateLauncher,
AppUpdateOptions.newBuilder(updateType).build(),
)
+ if (!started) showUpdateError(IllegalStateException("Play did not start an eligible flow"))
} catch (e: Exception) {
- Log.e(TAG, "Failed to start update flow", e)
+ showUpdateError(e)
}
+ } else {
+ Toast.makeText(activity, R.string.updates_play_unavailable, Toast.LENGTH_LONG).show()
}
}.addOnFailureListener {
- Log.e(TAG, "Failed to check for updates", it)
+ showUpdateError(it)
}
} catch (e: Exception) {
- Log.e(TAG, "App update initialization failed", e)
+ showUpdateError(e)
}
}
@@ -59,23 +65,35 @@ class PlayAppUpdateHelper(private val activity: Activity, private val updateLaun
fun resumeInProgressUpdate() {
try {
appUpdateManager.appUpdateInfo.addOnSuccessListener { appUpdateInfo ->
- if (appUpdateInfo.installStatus() == InstallStatus.DOWNLOADED) {
- // Optional: appUpdateManager.completeUpdate() for background downloads
- }
if (appUpdateInfo.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS) {
- appUpdateManager.startUpdateFlowForResult(
+ runCatching {
+ appUpdateManager.startUpdateFlowForResult(
appUpdateInfo,
updateLauncher,
AppUpdateOptions.newBuilder(AppUpdateType.IMMEDIATE).build(),
)
+ }.onFailure { Log.e(TAG, "Could not resume Play update", it) }
}
- }
+ }.addOnFailureListener { Log.e(TAG, "Could not resume Play update", it) }
} catch (e: Exception) {
Log.e(TAG, "Error checking update status", e)
}
}
+ private fun showUpdateError(error: Exception) {
+ Log.e(TAG, "Could not start Play update", error)
+ Toast.makeText(activity, R.string.updates_play_failed, Toast.LENGTH_LONG).show()
+ }
+
companion object {
private const val TAG = "AppUpdate"
}
}
+
+/** Prefer flexible updates, but never advertise an allowed update with no usable flow. */
+internal fun choosePlayUpdateType(highPriority: Boolean, immediateAllowed: Boolean, flexibleAllowed: Boolean): Int? = when {
+ highPriority && immediateAllowed -> AppUpdateType.IMMEDIATE
+ flexibleAllowed -> AppUpdateType.FLEXIBLE
+ immediateAllowed -> AppUpdateType.IMMEDIATE
+ else -> null
+}
diff --git a/app/src/main/java/cx/aswin/boxlore/updates/PlayUpdateSource.kt b/app/src/main/java/cx/aswin/boxlore/updates/PlayUpdateSource.kt
new file mode 100644
index 000000000..5b64cc3f3
--- /dev/null
+++ b/app/src/main/java/cx/aswin/boxlore/updates/PlayUpdateSource.kt
@@ -0,0 +1,24 @@
+package cx.aswin.boxlore.updates
+
+import android.content.Context
+import com.google.android.play.core.appupdate.AppUpdateManagerFactory
+import com.google.android.play.core.install.model.AppUpdateType
+import com.google.android.play.core.install.model.InstallStatus
+import com.google.android.play.core.install.model.UpdateAvailability
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+import kotlinx.coroutines.suspendCancellableCoroutine
+
+internal class PlayUpdateSource(context: Context) : UpdateSource {
+ private val manager = AppUpdateManagerFactory.create(context)
+
+ override suspend fun lookup(): UpdateLookup = suspendCancellableCoroutine { continuation ->
+ manager.appUpdateInfo.addOnSuccessListener { info ->
+ if (!continuation.isActive) return@addOnSuccessListener
+ val available = info.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE || info.installStatus() == InstallStatus.DOWNLOADED
+ val allowed = info.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE) || info.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE) || info.installStatus() == InstallStatus.DOWNLOADED
+ val offer = if (available && allowed) UpdateOffer(info.availableVersionCode().toLong(), info.availableVersionCode().toString()) else null
+ continuation.resume(UpdateLookup(offer, incompatible = available && !allowed))
+ }.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
+ }
+}
diff --git a/app/src/main/java/cx/aswin/boxlore/updates/ReleaseNotesMarkdown.kt b/app/src/main/java/cx/aswin/boxlore/updates/ReleaseNotesMarkdown.kt
new file mode 100644
index 000000000..d9b30b775
--- /dev/null
+++ b/app/src/main/java/cx/aswin/boxlore/updates/ReleaseNotesMarkdown.kt
@@ -0,0 +1,136 @@
+package cx.aswin.boxlore.updates
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.LinkAnnotation
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.TextLinkStyles
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.withLink
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.dp
+
+internal data class ReleaseNotesBlock(val text: String, val heading: Int = 0, val marker: String? = null, val callout: Boolean = false)
+
+/** Hide development references while preserving ordinary release-note links. */
+internal fun stripReleasePrReferences(notes: String): String = notes.lines().joinToString("\n") { line ->
+ line.replace(Regex("\\[[^\\]]*]\\(https://github\\.com/[^/\\s]+/[^/\\s]+/pull/\\d+(?:[?#][^)]*)?\\)"), "")
+ .replace(Regex("https://github\\.com/[^/\\s]+/[^/\\s]+/pull/\\d+(?:[?#][^\\s)]*)?"), "")
+ .replace(Regex("\\(\\s*\\)|\\(#[0-9]+\\)"), "")
+ .trimEnd()
+}
+
+/** Listener Markdown subset; unsupported syntax remains readable text, never executable HTML. */
+internal fun releaseNotesBlocks(notes: String, stripPrLinks: Boolean = true): List {
+ val blocks = mutableListOf()
+ val paragraph = mutableListOf()
+ fun flush() {
+ if (paragraph.isNotEmpty()) blocks.add(ReleaseNotesBlock(paragraph.joinToString("\n")))
+ paragraph.clear()
+ }
+ val content = if (stripPrLinks) stripReleasePrReferences(notes) else notes
+ content.replace("\r\n", "\n").replace('\r', '\n').lines().forEach { line ->
+ val heading = Regex("^(#{1,6})\\s+(.+)$").matchEntire(line.trim())
+ val bullet = Regex("^\\s*(?:[-*+•]|(\\d+)[.)])\\s+(.+)$").matchEntire(line)
+ when {
+ line.isBlank() -> flush()
+ heading != null -> {
+ flush()
+ blocks.add(ReleaseNotesBlock(heading.groupValues[2], heading.groupValues[1].length))
+ }
+ bullet != null -> {
+ flush()
+ blocks.add(ReleaseNotesBlock(bullet.groupValues[2], marker = bullet.groupValues[1].takeIf { it.isNotEmpty() }?.plus(".") ?: "•"))
+ }
+ line.trimStart().startsWith(">") -> {
+ flush()
+ blocks.add(ReleaseNotesBlock(line.trimStart().removePrefix(">").trimStart(), callout = true))
+ }
+ else -> paragraph.add(line)
+ }
+ }
+ flush()
+ return blocks
+}
+
+private enum class ReleaseInlineFormat(val pattern: Regex) {
+ LINK(Regex("""\[([^\]]+)\]\((https?://[^\s)]+)\)""")),
+ BOLD(Regex("""\*\*(.+?)\*\*|__(.+?)__""")),
+ ITALIC(Regex("""\*(.+?)\*|_(.+?)_""")),
+ CODE(Regex("""`([^`]+)`""")),
+}
+
+/** Retain lookahead, but refresh matches consumed inside a link or code span. */
+private fun nextReleaseInlineMatch(
+ text: String,
+ offset: Int,
+ matches: MutableMap,
+): Pair? {
+ matches.forEach { (format, match) ->
+ if (match != null && match.range.first < offset) matches[format] = format.pattern.find(text, offset)
+ }
+ return matches.entries.filter { it.value != null }
+ .minByOrNull { requireNotNull(it.value).range.first }
+ ?.let { it.key to requireNotNull(it.value) }
+}
+
+internal fun releaseNotesInline(text: String, linkStyle: SpanStyle): AnnotatedString = buildAnnotatedString {
+ // Insertion order gives links and bold precedence when matches start together.
+ val matches = ReleaseInlineFormat.entries.associateWith { it.pattern.find(text) }.toMutableMap()
+ var offset = 0
+ while (true) {
+ val (format, match) = nextReleaseInlineMatch(text, offset, matches) ?: break
+ append(text.substring(offset, match.range.first))
+ when (format) {
+ ReleaseInlineFormat.LINK -> withLink(LinkAnnotation.Url(match.groupValues[2], TextLinkStyles(style = linkStyle))) { append(match.groupValues[1]) }
+ ReleaseInlineFormat.BOLD -> withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(match.groupValues[1].ifEmpty { match.groupValues[2] }) }
+ ReleaseInlineFormat.ITALIC -> withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { append(match.groupValues[1].ifEmpty { match.groupValues[2] }) }
+ ReleaseInlineFormat.CODE -> withStyle(SpanStyle(fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace)) { append(match.groupValues[1]) }
+ }
+ offset = match.range.last + 1
+ }
+ append(text.substring(offset))
+}
+
+@Composable
+internal fun ReleaseNotesMarkdown(notes: String, stripPrLinks: Boolean = true) {
+ val blocks = remember(notes, stripPrLinks) { releaseNotesBlocks(notes, stripPrLinks) }
+ val colors = MaterialTheme.colorScheme
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ blocks.forEach { block ->
+ val style = when (block.heading) {
+ 1 -> MaterialTheme.typography.titleLarge
+ 2 -> MaterialTheme.typography.titleMedium
+ in 3..6 -> MaterialTheme.typography.titleSmall
+ else -> MaterialTheme.typography.bodyLarge
+ }
+ if (block.callout) {
+ Surface(shape = MaterialTheme.shapes.large, color = colors.primaryContainer, contentColor = colors.onPrimaryContainer) {
+ Text(releaseNotesInline(block.text, SpanStyle(color = colors.onPrimaryContainer)), Modifier.fillMaxWidth().padding(16.dp), style = MaterialTheme.typography.bodyLarge)
+ }
+ } else {
+ Row(Modifier.fillMaxWidth().padding(top = if (block.heading > 0) 8.dp else 0.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ block.marker?.let { Text(it, style = style, color = colors.onSurfaceVariant) }
+ Text(
+ releaseNotesInline(block.text, SpanStyle(color = colors.primary)),
+ style = style,
+ color = if (block.heading > 0) colors.onSurface else colors.onSurfaceVariant,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/cx/aswin/boxlore/updates/UpdateChecker.kt b/app/src/main/java/cx/aswin/boxlore/updates/UpdateChecker.kt
new file mode 100644
index 000000000..348768703
--- /dev/null
+++ b/app/src/main/java/cx/aswin/boxlore/updates/UpdateChecker.kt
@@ -0,0 +1,99 @@
+package cx.aswin.boxlore.updates
+
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+fun interface UpdateSource {
+ suspend fun lookup(): UpdateLookup
+}
+
+interface UpdateCheckStore {
+ var checkedAt: Long
+ var attemptedAt: Long
+ var cachedManifest: UpdateManifest?
+ var cachedPlayVersion: Long
+ var upcoming: String
+}
+
+/** No automatic prompts or downloads. Failed checks retain the last verified offer. */
+class UpdateChecker(
+ private val source: UpdateSource,
+ private val store: UpdateCheckStore,
+ private val installedVersion: Long,
+ private val sdkInt: Int,
+ private val now: () -> Long = System::currentTimeMillis,
+) {
+ private val mutex = Mutex()
+ private val mutableState = MutableStateFlow(initialState())
+ val state = mutableState.asStateFlow()
+
+ private fun initialState(): UpdateCheckState {
+ val cached = store.cachedManifest
+ val offer = if (cached != null && cached.versionCode > installedVersion && cached.minSdk <= sdkInt) {
+ UpdateOffer(cached.versionCode, cached.versionName, cached)
+ } else if (cached == null && store.cachedPlayVersion > installedVersion) {
+ UpdateOffer(store.cachedPlayVersion, store.cachedPlayVersion.toString())
+ } else {
+ return UpdateCheckState(upcoming = store.upcoming)
+ }
+ return UpdateCheckState(UpdateCheckStatus.AVAILABLE, offer, store.upcoming)
+ }
+
+ suspend fun check(force: Boolean = false) {
+ // Joining an already-running check never issues a duplicate request.
+ if (!mutex.tryLock()) {
+ mutex.withLock { }
+ return
+ }
+ try {
+ val time = now()
+ if (shouldSkipCheck(force, time)) return
+ store.attemptedAt = time
+ mutableState.value = mutableState.value.copy(status = UpdateCheckStatus.CHECKING)
+ val result = source.lookup()
+ result.offer?.manifest?.validate()
+ val incompatible = result.incompatible || (result.offer?.manifest?.minSdk ?: 0) > sdkInt
+ val offer = result.offer?.takeIf { it.versionCode > installedVersion && !incompatible }
+ require(result.upcoming.length <= 6_000)
+ store.upcoming = result.upcoming
+ cacheOffer(offer)
+ store.checkedAt = now()
+ mutableState.value = UpdateCheckState(
+ status = when {
+ offer != null -> UpdateCheckStatus.AVAILABLE
+ incompatible -> UpdateCheckStatus.INCOMPATIBLE
+ else -> UpdateCheckStatus.CURRENT
+ },
+ offer = offer,
+ upcoming = result.upcoming,
+ )
+ } catch (cancelled: CancellationException) {
+ mutableState.value = mutableState.value.copy(status = UpdateCheckStatus.IDLE)
+ throw cancelled
+ } catch (_: Exception) {
+ mutableState.value = mutableState.value.copy(status = UpdateCheckStatus.FAILED)
+ } finally {
+ mutex.unlock()
+ }
+ }
+
+ private fun shouldSkipCheck(force: Boolean, time: Long): Boolean {
+ if (force || mutableState.value.status == UpdateCheckStatus.IDLE) return false
+ return recent(store.checkedAt, time, CHECK_INTERVAL) || recent(store.attemptedAt, time, RETRY_INTERVAL)
+ }
+
+ private fun cacheOffer(offer: UpdateOffer?) {
+ store.cachedManifest = offer?.manifest
+ store.cachedPlayVersion = if (offer?.manifest == null) offer?.versionCode ?: 0 else 0
+ }
+
+ private fun recent(previous: Long, time: Long, interval: Long): Boolean = previous > 0 && time >= previous && time - previous < interval
+
+ companion object {
+ const val CHECK_INTERVAL = 6 * 60 * 60 * 1000L
+ const val RETRY_INTERVAL = 15 * 60 * 1000L
+ }
+}
diff --git a/app/src/main/java/cx/aswin/boxlore/updates/UpdateHttpCall.kt b/app/src/main/java/cx/aswin/boxlore/updates/UpdateHttpCall.kt
new file mode 100644
index 000000000..fcf9250aa
--- /dev/null
+++ b/app/src/main/java/cx/aswin/boxlore/updates/UpdateHttpCall.kt
@@ -0,0 +1,32 @@
+package cx.aswin.boxlore.updates
+
+import java.io.IOException
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.launch
+import okhttp3.Call
+import okhttp3.Response
+
+/** Cancellation closes the socket even when a response body is blocked waiting for bytes. */
+internal suspend fun Call.withUpdateResponse(body: suspend (Response) -> T): T = coroutineScope {
+ val cancellation = launch(start = CoroutineStart.UNDISPATCHED) {
+ try {
+ awaitCancellation()
+ } finally {
+ this@withUpdateResponse.cancel()
+ }
+ }
+ try {
+ execute().use { body(it) }
+ } catch (error: IOException) {
+ // OkHttp reports a cancelled socket as IO failure. Keep Pause a cancellation,
+ // rather than reporting an error or cancelling the application scope.
+ currentCoroutineContext().ensureActive()
+ throw error
+ } finally {
+ cancellation.cancel()
+ }
+}
diff --git a/app/src/main/java/cx/aswin/boxlore/updates/UpdateManifest.kt b/app/src/main/java/cx/aswin/boxlore/updates/UpdateManifest.kt
new file mode 100644
index 000000000..5653525f5
--- /dev/null
+++ b/app/src/main/java/cx/aswin/boxlore/updates/UpdateManifest.kt
@@ -0,0 +1,74 @@
+package cx.aswin.boxlore.updates
+
+import cx.aswin.boxlore.BuildConfig
+import java.net.URI
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+
+private const val MAX_APK_BYTES = 512L * 1024 * 1024
+private val manifestJson = Json { ignoreUnknownKeys = true }
+
+/** Published only after the signed APK has passed release validation. */
+@Serializable
+data class UpdateManifest(
+ val schemaVersion: Int,
+ val packageName: String,
+ val versionCode: Long,
+ val versionName: String,
+ val minSdk: Int,
+ val apkUrl: String,
+ val apkSha256: String,
+ val apkBytes: Long,
+ val notesUrl: String,
+ val notes: String = "",
+ val testOnly: Boolean = false,
+ val upcoming: String = "",
+) {
+ fun validate(allowTest: Boolean = BuildConfig.BOXLORE_ISOLATED_TESTS): UpdateManifest {
+ require(schemaVersion == 1) { "Unsupported update manifest" }
+ require(packageName == "cx.aswin.boxlore" && versionCode > 0 && minSdk >= 31)
+ require(versionName.isNotBlank() && versionName.length <= 64)
+ require(apkSha256.matches(Regex("[a-fA-F0-9]{64}")))
+ require(apkBytes in 1..MAX_APK_BYTES && notes.length <= 24_000 && upcoming.length <= 6_000)
+ if (testOnly) {
+ require(allowTest) { "Test updates are unavailable in shipping builds" }
+ requireTestUpdateUrl(apkUrl, "/candidate.apk")
+ requireTestUpdateUrl(notesUrl, "/notes")
+ } else {
+ requireReleaseUrl(apkUrl, "/download/")
+ require(URI(apkUrl).path.endsWith(".apk"))
+ requireReleaseUrl(notesUrl, "/tag/")
+ }
+ return this
+ }
+
+ fun encode(): String = manifestJson.encodeToString(serializer(), this)
+
+ companion object {
+ fun parse(value: String, allowTest: Boolean = BuildConfig.BOXLORE_ISOLATED_TESTS): UpdateManifest = manifestJson.decodeFromString(serializer(), value).validate(allowTest)
+ }
+}
+
+private fun requireReleaseUrl(value: String, route: String) {
+ val uri = URI(value)
+ require(uri.scheme == "https" && uri.host == "github.com" && uri.port == -1)
+ require(uri.userInfo == null && uri.query == null && uri.fragment == null)
+ require(uri.path.startsWith("/boxcreate/boxlore/releases$route"))
+ require(uri.rawPath == uri.path && ".." !in uri.path)
+}
+
+data class UpdateOffer(val versionCode: Long, val versionName: String, val manifest: UpdateManifest? = null)
+
+data class UpdateLookup(val offer: UpdateOffer? = null, val incompatible: Boolean = false, val upcoming: String = "")
+
+enum class UpdateCheckStatus { IDLE, CHECKING, CURRENT, AVAILABLE, INCOMPATIBLE, FAILED }
+
+data class UpdateCheckState(val status: UpdateCheckStatus = UpdateCheckStatus.IDLE, val offer: UpdateOffer? = null, val upcoming: String = "") {
+ val updateAvailable: Boolean get() = offer != null
+}
+
+internal fun requireTestUpdateUrl(value: String, path: String) {
+ val uri = URI(value)
+ require(uri.scheme == "http" && uri.host == "127.0.0.1" && uri.port == 8765 && uri.path == path)
+ require(uri.rawPath == uri.path && uri.query == null && uri.fragment == null && uri.userInfo == null)
+}
diff --git a/app/src/main/res/drawable/ic_boxlore_wordmark_mic.xml b/app/src/main/res/drawable/ic_boxlore_wordmark_mic.xml
new file mode 100644
index 000000000..bbc5b0ca3
--- /dev/null
+++ b/app/src/main/res/drawable/ic_boxlore_wordmark_mic.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/announcements.xml b/app/src/main/res/values/announcements.xml
new file mode 100644
index 000000000..da6f5cf5a
--- /dev/null
+++ b/app/src/main/res/values/announcements.xml
@@ -0,0 +1,9 @@
+
+ Open
+ Close announcement
+ Dismiss
+ Download update
+ View on GitHub
+ Update on Google Play
+ Update test build. Updates come from this phone’s local test feed.
+
diff --git a/app/src/main/res/values/updates.xml b/app/src/main/res/values/updates.xml
new file mode 100644
index 000000000..df0c1576b
--- /dev/null
+++ b/app/src/main/res/values/updates.xml
@@ -0,0 +1,27 @@
+
+ App updates
+ Checking for updates…
+ You’re up to date
+ Couldn’t check for updates. Try again later.
+ The latest update needs a newer Android version.
+ Check for updates
+ Close updates
+ Update available
+ Version %1$s
+ An update is available on Google Play
+ Update with Google Play
+ Couldn’t open the update. Try again.
+ This update is no longer available. Check for updates again.
+ Download update
+ Install update
+ Pause download
+ Downloading update · %1$d%%
+ Verifying update…
+ Ready to install. Android will ask you to confirm.
+ Allow boxlore to install updates in Android Settings, then return and tap Install update.
+ Couldn’t prepare the update. Try downloading it again.
+ Release notes
+ Read release notes
+ Coming next
+ Not released yet
+
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
index 40d85db34..6306abbfd 100644
--- a/app/src/main/res/xml/backup_rules.xml
+++ b/app/src/main/res/xml/backup_rules.xml
@@ -10,6 +10,12 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
index 7a2754568..b90b16167 100644
--- a/app/src/main/res/xml/data_extraction_rules.xml
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -5,6 +5,12 @@
+
+
+
+
+
+
@@ -15,6 +21,12 @@
+
+
+
+
+
+
diff --git a/app/src/playRelease/java/cx/aswin/boxlore/updates/PlayApkInstaller.kt b/app/src/playRelease/java/cx/aswin/boxlore/updates/PlayApkInstaller.kt
new file mode 100644
index 000000000..092044229
--- /dev/null
+++ b/app/src/playRelease/java/cx/aswin/boxlore/updates/PlayApkInstaller.kt
@@ -0,0 +1,11 @@
+package cx.aswin.boxlore.updates
+
+import android.app.Activity
+import android.content.Context
+import java.io.File
+
+/** Play builds cannot download or request installation of an APK. */
+internal fun createApkInstaller(@Suppress("UNUSED_PARAMETER") context: Context): ApkInstaller = object : ApkInstaller {
+ override suspend fun prepare(manifest: UpdateManifest, onState: (ApkInstallState) -> Unit): File = error("Use Google Play updates")
+ override suspend fun launch(activity: Activity, manifest: UpdateManifest, file: File): Boolean = false
+}
diff --git a/app/src/test/java/cx/aswin/boxlore/fcm/AnnouncementPolicyTest.kt b/app/src/test/java/cx/aswin/boxlore/fcm/AnnouncementPolicyTest.kt
new file mode 100644
index 000000000..5e9aaebff
--- /dev/null
+++ b/app/src/test/java/cx/aswin/boxlore/fcm/AnnouncementPolicyTest.kt
@@ -0,0 +1,53 @@
+package cx.aswin.boxlore.fcm
+
+import cx.aswin.boxlore.core.prefs.UserPreferencesRepository.Announcement
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+class AnnouncementPolicyTest {
+ private fun alert() = Announcement("News", "Body", null, null, null, false, 1, "NEW RELEASE")
+
+ @Test fun `legacy release alerts remain suppressed on Play but ordinary alerts do not`() {
+ assertFalse(announcementAllowed(alert(), true, false, 28))
+ assertTrue(announcementAllowed(alert().copy(releaseAlert = false), true, false, 28))
+ assertTrue(announcementAllowed(alert().copy(includePlay = true), true, false, 28))
+ }
+
+ @Test fun `release version and test consent are checked independently`() {
+ assertFalse(announcementAllowed(alert().copy(releaseAlert = true, releaseVersionCode = 28), false, false, 28))
+ assertTrue(announcementAllowed(alert().copy(releaseVersionCode = 29), false, false, 28))
+ assertFalse(announcementAllowed(alert().copy(testOnly = true, releaseAlert = false), false, false, 28))
+ assertTrue(announcementAllowed(alert().copy(testOnly = true, releaseAlert = false), false, true, 28))
+ assertFalse(announcementAllowed(alert().copy(releaseAlert = false), false, true, 28, isolated = true))
+ assertTrue(announcementAllowed(alert().copy(testOnly = true, releaseAlert = false), false, true, 28, isolated = true))
+ }
+
+ @Test fun `isolated builds unsubscribe every production audience and Play preserves normal audiences`() {
+ val isolated = announcementTopics(false, true, false)
+ assertEquals(setOf("test_users"), isolated.first)
+ assertTrue(isolated.second.containsAll(setOf("all_users", "prod_users", "play_users", "direct_users")))
+ assertEquals(setOf("all_users", "prod_users", "play_users"), announcementTopics(false, false, true).first)
+ }
+
+ @Test fun `legacy payload defaults to compact and new payload preserves exact presentation`() {
+ val old = FcmPayloadParser.parse(mapOf("title" to "News", "body" to "Exact text"))
+ assertEquals("compact", old.presentation)
+ val current = FcmPayloadParser.parse(mapOf("presentation" to "fullscreen", "tone" to "tertiary", "release_alert" to "false", "include_play" to "true", "test_mode" to "true"))
+ assertEquals("fullscreen", current.presentation)
+ assertFalse(current.releaseAlert!!)
+ assertTrue(current.includePlay)
+ assertTrue(current.testOnly)
+ }
+
+ @Test fun `release actions download internally and Github view preserves the announcement`() {
+ val value = alert().copy(releaseAlert = true, route = "https://github.com/boxcreate/boxlore/releases/download/v29/app.apk")
+ assertEquals("https://github.com/boxcreate/boxlore/releases/tag/v29", releaseAnnouncementUrl(value))
+ assertTrue(isUpdaterAnnouncementAction(value, "boxlore://updates/download"))
+ assertTrue(isUpdaterAnnouncementAction(value.copy(releaseAlert = null), value.route!!))
+ assertFalse(shouldDismissAnnouncementForAction(value, releaseAnnouncementUrl(value)))
+ assertTrue(shouldDismissAnnouncementForAction(value, "boxlore://updates/download"))
+ assertEquals("https://github.com/boxcreate/boxlore/releases/tag/v29", releaseAnnouncementUrl(value.copy(releaseUrl = "https://evil.example/releases/tag/v30")))
+ }
+}
diff --git a/app/src/test/java/cx/aswin/boxlore/fcm/PushAnnouncementActionTest.kt b/app/src/test/java/cx/aswin/boxlore/fcm/PushAnnouncementActionTest.kt
new file mode 100644
index 000000000..d7a36d0cb
--- /dev/null
+++ b/app/src/test/java/cx/aswin/boxlore/fcm/PushAnnouncementActionTest.kt
@@ -0,0 +1,56 @@
+package cx.aswin.boxlore.fcm
+
+import android.app.Application
+import android.app.Notification
+import android.app.NotificationManager
+import android.content.Context
+import android.content.Intent
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+
+@RunWith(org.robolectric.RobolectricTestRunner::class)
+@Config(sdk = [34], application = Application::class)
+class PushAnnouncementActionTest {
+ @Test fun `a real notification action preserves the configured website destination`() {
+ val route = "https://example.org/listen"
+ val notification = show(route)
+ val action = shadowOf(notification.actions.single().actionIntent).savedIntent
+ assertEquals(route, action.getStringExtra("target_route"))
+ assertEquals(Intent.ACTION_VIEW, action.action)
+ assertNull(action.data)
+ assertEquals("cx.aswin.boxlore.MainActivity", action.component?.className)
+ assertNotEquals(notification.contentIntent, notification.actions.single().actionIntent)
+ }
+
+ @Test fun `legacy release download actions use the native updater target`() {
+ val notification = show("https://github.com/boxcreate/boxlore/releases/download/v29/app.apk", release = true)
+ val action = shadowOf(notification.actions.single().actionIntent).savedIntent
+ assertEquals("boxlore://updates/download", action.getStringExtra("target_route"))
+ }
+
+ @Test fun `a general APK link stays a general web action`() {
+ val route = "https://example.org/app.apk"
+ val action = shadowOf(show(route).actions.single().actionIntent).savedIntent
+ assertEquals(route, action.getStringExtra("target_route"))
+ }
+
+ @Test fun `unsafe and empty destinations do not create dead action buttons`() {
+ assertNull(show("javascript:alert(1)").actions)
+ assertNull(show("").actions)
+ }
+
+ private fun show(route: String, release: Boolean = false): Notification {
+ val service = Robolectric.buildService(BoxLoreFcmService::class.java).create().get()
+ val parsed = FcmPayloadParser.parse(mapOf("title" to "Action test", "body" to "Body", "route" to route, "category" to "ANNOUNCEMENT", "release_alert" to release.toString(), "show_action_in_push" to "true", "sound" to "silent"))
+ service.showPushNotification(parsed)
+ val manager = service.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val id = NewEpisodeFcmLogic.ANNOUNCEMENT_NOTIFICATION_ID_BASE + NewEpisodeFcmLogic.announcementSlot(route)
+ return shadowOf(manager).getNotification(id)
+ }
+}
diff --git a/app/src/test/java/cx/aswin/boxlore/navigation/PushActionRouterTest.kt b/app/src/test/java/cx/aswin/boxlore/navigation/PushActionRouterTest.kt
new file mode 100644
index 000000000..fb53d60b8
--- /dev/null
+++ b/app/src/test/java/cx/aswin/boxlore/navigation/PushActionRouterTest.kt
@@ -0,0 +1,145 @@
+package cx.aswin.boxlore.navigation
+
+import android.app.Application
+import android.content.ComponentName
+import android.content.Intent
+import android.net.Uri
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navArgument
+import androidx.navigation.navDeepLink
+import cx.aswin.boxlore.BuildConfig
+import cx.aswin.boxlore.MainActivity
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34], application = Application::class)
+class PushActionRouterTest {
+ @get:Rule val composeRule = createComposeRule()
+ private lateinit var navController: NavHostController
+ private val external = mutableListOf()
+ private val updates = mutableListOf()
+
+ @Test fun `website and GitHub actions open externally when the graph cannot handle them`() {
+ render()
+ val urls = listOf("https://example.org/news", "https://github.com/boxcreate/boxlore/releases/tag/v29")
+ urls.forEach { assertTrue(dispatch(it)) }
+ assertEquals(urls, external.map(Uri::toString))
+ assertEquals("home", navController.currentDestination?.route)
+ }
+
+ @Test fun `podcast and episode links still open their in app destinations`() {
+ render()
+ assertTrue(dispatch("boxlore://podcast/123"))
+ assertEquals("podcast/{id}", navController.currentDestination?.route)
+ assertEquals("123", navController.currentBackStackEntry?.arguments?.getString("id"))
+ assertTrue(dispatch("boxcast://episode/-42"))
+ assertEquals("episode/{id}", navController.currentDestination?.route)
+ assertEquals("-42", navController.currentBackStackEntry?.arguments?.getString("id"))
+ assertTrue(external.isEmpty())
+ }
+
+ @Test fun `matched HTTPS share links stay inside boxlore`() {
+ render()
+ assertTrue(dispatch("https://aswin.cx/boxlore/share?type=podcast&id=123"))
+ assertEquals("podcast/{id}", navController.currentDestination?.route)
+ assertTrue(external.isEmpty())
+ }
+
+ @Test fun `known relative and app scheme utility destinations both navigate`() {
+ render()
+ assertTrue(dispatch("feedback"))
+ assertEquals("feedback", navController.currentDestination?.route)
+ assertTrue(dispatch("boxlore://settings?page=support"))
+ assertEquals("settings?page={page}", navController.currentDestination?.route)
+ assertEquals("support", navController.currentBackStackEntry?.arguments?.getString("page"))
+ assertTrue(dispatch("boxcast://library/downloads"))
+ assertEquals("library/downloads", navController.currentDestination?.route)
+ assertTrue(external.isEmpty())
+ }
+
+ @Test fun `update actions bypass navigation and select explicit download versus check`() {
+ render()
+ assertTrue(dispatch("boxlore://updates/download"))
+ assertTrue(dispatch("boxlore://updates"))
+ assertEquals(listOf(true, false), updates)
+ assertTrue(external.isEmpty())
+ assertEquals("home", navController.currentDestination?.route)
+ }
+
+ @Test fun `an existing release notification APK target also downloads inside the app`() {
+ render()
+ assertTrue(dispatch("https://github.com/boxcreate/boxlore/releases/download/v29/app.apk?download=1"))
+ assertEquals(listOf(true), updates)
+ assertTrue(external.isEmpty())
+ assertEquals("home", navController.currentDestination?.route)
+ }
+
+ @Test fun `unsupported app and unsafe targets never open outside the app`() {
+ render()
+ listOf("boxlore://unknown/path", "javascript:alert(1)", "file:///tmp/app.apk", "https://", "unknown/path").forEach {
+ assertFalse(it, dispatch(it))
+ }
+ assertTrue(external.isEmpty())
+ assertTrue(updates.isEmpty())
+ assertEquals("home", navController.currentDestination?.route)
+ }
+
+ @Test fun `unmatched verified App Links cannot reopen boxlore through the external fallback`() {
+ render()
+ val uri = Uri.parse("https://aswin.cx/boxlore/share?type=unknown")
+ assertTrue(dispatch(uri.toString()))
+ assertEquals(listOf(uri), external)
+ val chooser = externalPushActionIntent(uri)
+ assertEquals(Intent.ACTION_CHOOSER, chooser.action)
+ assertEquals(uri, chooser.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)?.data)
+ assertEquals(listOf(ComponentName(BuildConfig.APPLICATION_ID, MainActivity::class.java.name)), chooser.getParcelableArrayExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, ComponentName::class.java)?.toList())
+ }
+
+ @Test fun `ordinary website actions use the preferred handler without an extra chooser`() {
+ val uri = Uri.parse("https://example.org/news")
+ val intent = externalPushActionIntent(uri)
+ assertEquals(Intent.ACTION_VIEW, intent.action)
+ assertEquals(uri, intent.data)
+ }
+
+ private fun render() {
+ composeRule.setContent {
+ navController = rememberNavController()
+ NavHost(navController, startDestination = "home") {
+ composable("home") {}
+ composable("feedback") {}
+ composable("library/downloads") {}
+ composable("settings?page={page}", arguments = listOf(navArgument("page") { defaultValue = "hub" })) {}
+ composable(
+ "podcast/{id}",
+ deepLinks = listOf(
+ navDeepLink { uriPattern = "boxlore://podcast/{id}" },
+ navDeepLink { uriPattern = "https://aswin.cx/boxlore/share?type=podcast&id={id}" },
+ ),
+ ) {}
+ composable("episode/{id}", deepLinks = listOf(navDeepLink { uriPattern = "boxcast://episode/{id}" })) {}
+ }
+ }
+ composeRule.waitForIdle()
+ }
+
+ private fun dispatch(target: String): Boolean {
+ var result = false
+ composeRule.runOnUiThread {
+ result = dispatchPushAction(target, navController::handleDeepLink, { navController.navigate(it) }, { external += it }, { updates += it })
+ }
+ composeRule.waitForIdle()
+ return result
+ }
+}
diff --git a/app/src/test/java/cx/aswin/boxlore/ui/NativeDialogSystemBarsTest.kt b/app/src/test/java/cx/aswin/boxlore/ui/NativeDialogSystemBarsTest.kt
new file mode 100644
index 000000000..d21a31b83
--- /dev/null
+++ b/app/src/test/java/cx/aswin/boxlore/ui/NativeDialogSystemBarsTest.kt
@@ -0,0 +1,15 @@
+package cx.aswin.boxlore.ui
+
+import androidx.compose.ui.graphics.Color
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class NativeDialogSystemBarsTest {
+ @Test fun `light full screen dialogs use dark icons while dark and dimmed dialogs use light icons`() {
+ assertTrue(dialogUsesDarkSystemBarIcons(Color.White, dimmed = false))
+ assertFalse(dialogUsesDarkSystemBarIcons(Color.Black, dimmed = false))
+ assertFalse(dialogUsesDarkSystemBarIcons(Color.White, dimmed = true))
+ assertFalse(dialogUsesDarkSystemBarIcons(Color.Black, dimmed = true))
+ }
+}
diff --git a/app/src/test/java/cx/aswin/boxlore/ui/announcement/AnnouncementRenderTest.kt b/app/src/test/java/cx/aswin/boxlore/ui/announcement/AnnouncementRenderTest.kt
new file mode 100644
index 000000000..b4e212d9c
--- /dev/null
+++ b/app/src/test/java/cx/aswin/boxlore/ui/announcement/AnnouncementRenderTest.kt
@@ -0,0 +1,55 @@
+package cx.aswin.boxlore.ui.announcement
+
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.ui.graphics.Color
+import cx.aswin.boxlore.core.prefs.UserPreferencesRepository.Announcement
+import cx.aswin.boxlore.updates.releaseNotesBlocks
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class AnnouncementRenderTest {
+ private fun alert() = Announcement("News", "Exact **copy**", null, null, null, false, 1, "ANNOUNCEMENT")
+
+ @Test fun `release download stays in app and GitHub action uses a validated page`() {
+ val value = alert().copy(releaseAlert = true, route = "https://github.com/boxcreate/boxlore/releases/download/v29/app.apk", releaseUrl = "https://evil.example/update")
+ val items = announcementActions(value)
+ assertEquals(listOf(AnnouncementActionKind.UPDATE, AnnouncementActionKind.GITHUB), items.map { it.kind })
+ assertEquals("boxlore://updates/download", items[0].route)
+ assertEquals("https://github.com/boxcreate/boxlore/releases/tag/v29", items[1].route)
+ }
+
+ @Test fun `ordinary announcements honour optional action visibility and exact custom labels`() {
+ val value = alert().copy(route = "https://example.org", actionLabel = "Read the story")
+ assertTrue(announcementActions(value).isEmpty())
+ val action = announcementActions(value.copy(showActionInApp = true)).single()
+ assertEquals(AnnouncementActionKind.CUSTOM, action.kind)
+ assertEquals(value.route, action.route)
+ assertEquals(value.actionLabel, action.label)
+ assertTrue(announcementActions(value.copy(showActionInApp = true, route = " ")).isEmpty())
+ }
+
+ @Test fun `tone changes action and tag colours without replacing surfaces or text roles`() {
+ val base = lightColorScheme(secondary = Color.Blue, secondaryContainer = Color.Cyan)
+ val toned = announcementColors(base, "secondary")
+ assertEquals(base.secondary, toned.primary)
+ assertEquals(base.secondaryContainer, toned.primaryContainer)
+ assertEquals(base.surfaceContainer, toned.surfaceContainer)
+ assertEquals(base.onSurface, toned.onSurface)
+ assertEquals(base, announcementColors(base, "unknown"))
+ }
+
+ @Test fun `announcement images retain the HTTPS only policy`() {
+ assertEquals("https://example.org/cover.png", announcementImageUrl("https://example.org/cover.png"))
+ listOf(null, "file:///storage/cover.png", "content://cover", "http://example.org/cover.png", "https://user:password@example.org/cover.png", "not a url").forEach { assertNull(announcementImageUrl(it)) }
+ }
+
+ @Test fun `native announcement Markdown preserves its links and styled callouts`() {
+ val blocks = releaseNotesBlocks("## News\n\n> Important **information**\n\n[Discussion](https://github.com/boxcreate/boxlore/pull/1)", stripPrLinks = false)
+ assertEquals(2, blocks[0].heading)
+ assertTrue(blocks[1].callout)
+ assertEquals("Important **information**", blocks[1].text)
+ assertTrue(blocks.last().text.contains("/pull/1"))
+ }
+}
diff --git a/app/src/test/java/cx/aswin/boxlore/updates/ApkFileDownloaderTest.kt b/app/src/test/java/cx/aswin/boxlore/updates/ApkFileDownloaderTest.kt
new file mode 100644
index 000000000..8017db79b
--- /dev/null
+++ b/app/src/test/java/cx/aswin/boxlore/updates/ApkFileDownloaderTest.kt
@@ -0,0 +1,106 @@
+package cx.aswin.boxlore.updates
+
+import java.io.File
+import java.io.IOException
+import java.nio.file.Files
+import kotlinx.coroutines.runBlocking
+import okhttp3.OkHttpClient
+import okhttp3.Protocol
+import okhttp3.Response
+import okhttp3.ResponseBody.Companion.toResponseBody
+import org.junit.jupiter.api.Assertions.assertArrayEquals
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertThrows
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+class ApkFileDownloaderTest {
+ @Test fun `interrupted transfer resumes exact offset without corrupting bytes`() = runBlocking {
+ val directory = Files.createTempDirectory("boxlore-update").toFile()
+ try {
+ val bytes = ByteArray(70_000) { (it % 127).toByte() }
+ val original = File(directory, "fixture").apply { writeBytes(bytes) }
+ val manifest = updateManifest().copy(apkBytes = bytes.size.toLong(), apkSha256 = ApkFileDownloader.sha256(original))
+ var range: String? = null
+ val client = OkHttpClient.Builder().addInterceptor { chain ->
+ range = chain.request().header("Range")
+ val offset = range?.removePrefix("bytes=")?.removeSuffix("-")?.toInt() ?: 0
+ Response.Builder().request(chain.request()).protocol(Protocol.HTTP_1_1).code(if (offset > 0) 206 else 200).message("OK")
+ .header("Content-Range", "bytes $offset-${bytes.size - 1}/${bytes.size}")
+ .body(bytes.copyOfRange(offset, bytes.size).toResponseBody()).build()
+ }.build()
+ val downloader = ApkFileDownloader(directory, client)
+ assertTrue(runCatching { downloader.download(manifest) { if (it > 0f) throw IOException("connection lost") } }.isFailure)
+ val partial = File(directory, "${manifest.apkSha256}.part")
+ val offset = partial.length()
+ assertTrue(offset in 1 until bytes.size.toLong())
+ val ready = downloader.download(manifest) { }
+ assertEquals("bytes=$offset-", range)
+ assertArrayEquals(bytes, ready.readBytes())
+ assertFalse(partial.exists())
+ range = "unchanged"
+ assertEquals(ready, downloader.download(manifest) { })
+ assertEquals("unchanged", range)
+ } finally {
+ directory.deleteRecursively()
+ }
+ }
+
+ @Test fun `server ignoring range safely restarts instead of appending`() = runBlocking {
+ val directory = Files.createTempDirectory("boxlore-update").toFile()
+ try {
+ val bytes = "complete apk".toByteArray()
+ val fixture = File(directory, "fixture").apply { writeBytes(bytes) }
+ val manifest = updateManifest().copy(apkBytes = bytes.size.toLong(), apkSha256 = ApkFileDownloader.sha256(fixture))
+ File(directory, "${manifest.apkSha256}.part").writeText("part")
+ val client = responseClient(bytes)
+ assertArrayEquals(bytes, ApkFileDownloader(directory, client).download(manifest) { }.readBytes())
+ } finally {
+ directory.deleteRecursively()
+ }
+ }
+
+ @Test fun `corrupted APK is deleted and cannot become ready`() = runBlocking {
+ val directory = Files.createTempDirectory("boxlore-update").toFile()
+ try {
+ val manifest = updateManifest().copy(apkBytes = 6)
+ val result = runCatching { ApkFileDownloader(directory, responseClient("broken".toByteArray())).download(manifest) { } }
+ assertTrue(result.isFailure)
+ assertTrue(directory.listFiles().orEmpty().isEmpty())
+ } finally {
+ directory.deleteRecursively()
+ }
+ }
+
+ @Test fun `invalid resume range fails rather than mixing downloads`() {
+ for (value in listOf(null, "bytes 0-9/100", "bytes 10-99/200", "bytes 10-100/100")) {
+ assertThrows(Exception::class.java) { validateContentRange(value, 10, 100) }
+ }
+ validateContentRange("bytes 10-99/100", 10, 100)
+ }
+
+ @Test fun `complete corrupted partial restarts instead of requesting a range past the end`() = runBlocking {
+ val directory = Files.createTempDirectory("boxlore-update").toFile()
+ try {
+ val bytes = "complete apk".toByteArray()
+ val fixture = File(directory, "fixture").apply { writeBytes(bytes) }
+ val manifest = updateManifest().copy(apkBytes = bytes.size.toLong(), apkSha256 = ApkFileDownloader.sha256(fixture))
+ File(directory, "${manifest.apkSha256}.part").writeBytes(ByteArray(bytes.size))
+ var range: String? = "not requested"
+ val client = OkHttpClient.Builder().addInterceptor { chain ->
+ range = chain.request().header("Range")
+ Response.Builder().request(chain.request()).protocol(Protocol.HTTP_1_1).code(if (range == null) 200 else 416)
+ .message("Fixture").body(bytes.toResponseBody()).build()
+ }.build()
+ assertArrayEquals(bytes, ApkFileDownloader(directory, client).download(manifest) { }.readBytes())
+ assertEquals(null, range)
+ } finally {
+ directory.deleteRecursively()
+ }
+ }
+
+ private fun responseClient(bytes: ByteArray): OkHttpClient = OkHttpClient.Builder().addInterceptor { chain ->
+ Response.Builder().request(chain.request()).protocol(Protocol.HTTP_1_1).code(200).message("OK").body(bytes.toResponseBody()).build()
+ }.build()
+}
diff --git a/app/src/test/java/cx/aswin/boxlore/updates/ApkIdentityTest.kt b/app/src/test/java/cx/aswin/boxlore/updates/ApkIdentityTest.kt
new file mode 100644
index 000000000..5ca7a100f
--- /dev/null
+++ b/app/src/test/java/cx/aswin/boxlore/updates/ApkIdentityTest.kt
@@ -0,0 +1,29 @@
+package cx.aswin.boxlore.updates
+
+import org.junit.jupiter.api.Assertions.assertThrows
+import org.junit.jupiter.api.Test
+
+class ApkIdentityTest {
+ private val current = ApkIdentity("cx.aswin.boxlore", 28, 31, setOf("trusted"))
+ private val candidate = current.copy(versionCode = 29)
+
+ @Test fun `same signing identity and verified single signer rotation accepted`() {
+ validateApkIdentity(current, candidate, updateManifest())
+ validateApkIdentity(current, candidate.copy(signers = setOf("new"), signingHistory = setOf("trusted", "new")), updateManifest())
+ }
+
+ @Test fun `wrong package downgrade unexpected version or missing signer rejected`() {
+ val invalid = listOf(
+ candidate.copy(packageName = "other.app"),
+ candidate.copy(versionCode = 28),
+ candidate.copy(versionCode = 30),
+ candidate.copy(minSdk = 35),
+ candidate.copy(signers = emptySet()),
+ candidate.copy(signers = setOf("untrusted")),
+ candidate.copy(signers = setOf("untrusted", "trusted"), signingHistory = setOf("trusted")),
+ )
+ invalid.forEach { apk ->
+ assertThrows(IllegalArgumentException::class.java) { validateApkIdentity(current, apk, updateManifest()) }
+ }
+ }
+}
diff --git a/app/src/test/java/cx/aswin/boxlore/updates/AppUpdatesPanelTest.kt b/app/src/test/java/cx/aswin/boxlore/updates/AppUpdatesPanelTest.kt
new file mode 100644
index 000000000..40e277f1e
--- /dev/null
+++ b/app/src/test/java/cx/aswin/boxlore/updates/AppUpdatesPanelTest.kt
@@ -0,0 +1,181 @@
+package cx.aswin.boxlore.updates
+
+import android.app.Activity
+import android.app.Application
+import android.os.Looper
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performScrollTo
+import java.io.File
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [33], application = Application::class)
+class AppUpdatesPanelTest {
+ @get:Rule val composeRule = createComposeRule()
+
+ @Test fun `long notes scroll without hiding download or close actions`() {
+ val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+ try {
+ val notes = (1..60).joinToString("\n\n") { "Sample section $it. Release-note layout content for checking scrolling and action visibility." }
+ val checker = UpdateChecker(UpdateSource { UpdateLookup(UpdateOffer(29, "0.0.29", updateManifest().copy(notes = notes))) }, MemoryUpdateStore(), 28, 34)
+ runBlocking { checker.check() }
+ val installer = object : ApkInstaller {
+ override suspend fun prepare(manifest: UpdateManifest, onState: (ApkInstallState) -> Unit): File = error("This test only checks layout")
+ override suspend fun launch(activity: Activity, manifest: UpdateManifest, file: File): Boolean = error("This test never installs")
+ }
+ val updates = AppUpdates(checker, false, installer, scope)
+ updates.open()
+ composeRule.setContent { MaterialTheme { AppUpdatesPanel(updates, {}, {}) } }
+ composeRule.onNodeWithText("Download update").assertIsDisplayed()
+ composeRule.onNodeWithContentDescription("Close updates").assertIsDisplayed()
+ composeRule.onNodeWithText(notes.substringAfterLast("\n\n")).performScrollTo().assertIsDisplayed()
+ composeRule.onNodeWithText("Check for updates").assertDoesNotExist()
+ composeRule.onNodeWithText("Download update").assertIsDisplayed()
+ composeRule.onNodeWithContentDescription("Close updates").assertIsDisplayed()
+ } finally {
+ scope.cancel()
+ }
+ }
+
+ @Test fun `available screen shows reviewed notes and download before installation`() {
+ val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+ try {
+ var downloads = 0
+ var installClicks = 0
+ val checker = UpdateChecker(UpdateSource { UpdateLookup(UpdateOffer(29, "0.0.29", updateManifest())) }, MemoryUpdateStore(), 28, 34)
+ runBlocking { checker.check() }
+ val installer = object : ApkInstaller {
+ override suspend fun prepare(manifest: UpdateManifest, onState: (ApkInstallState) -> Unit): File {
+ downloads++
+ return File("fixture")
+ }
+ override suspend fun launch(activity: Activity, manifest: UpdateManifest, file: File): Boolean = true
+ }
+ val updates = AppUpdates(checker, false, installer, scope)
+ updates.open()
+ composeRule.setContent { MaterialTheme { AppUpdatesPanel(updates, { installClicks++ }, {}) } }
+ composeRule.onNodeWithText("Version 0.0.29").assertIsDisplayed()
+ composeRule.onNodeWithText("Improved playback").assertIsDisplayed()
+ assertEquals(0, downloads)
+ composeRule.onNodeWithText("Download update").performClick()
+ composeRule.onNodeWithText("Install update").assertIsDisplayed().performClick()
+ assertEquals(1, downloads)
+ assertEquals(1, installClicks)
+ } finally {
+ scope.cancel()
+ }
+ }
+
+ @Test fun `manual check failure has recovery and cannot claim up to date`() {
+ val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+ try {
+ val checker = UpdateChecker(UpdateSource { error("offline fixture") }, MemoryUpdateStore(), 28, 34)
+ runBlocking { checker.check() }
+ val installer = object : ApkInstaller {
+ override suspend fun prepare(manifest: UpdateManifest, onState: (ApkInstallState) -> Unit): File = error("No download during check")
+ override suspend fun launch(activity: Activity, manifest: UpdateManifest, file: File): Boolean = error("No install during check")
+ }
+ val updates = AppUpdates(checker, false, installer, scope)
+ updates.open()
+ composeRule.setContent { MaterialTheme { AppUpdatesPanel(updates, {}, {}) } }
+ composeRule.onNodeWithText("Couldn’t check for updates. Try again later.").assertIsDisplayed()
+ composeRule.onNodeWithText("Check for updates").assertIsDisplayed()
+ composeRule.onNodeWithText("You’re up to date").assertDoesNotExist()
+ } finally {
+ scope.cancel()
+ }
+ }
+
+ @Test fun `interrupted installer verification stays ready and duplicate taps cannot launch twice`() {
+ val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+ try {
+ var launches = 0
+ val checker = UpdateChecker(UpdateSource { UpdateLookup(UpdateOffer(29, "0.0.29", updateManifest())) }, MemoryUpdateStore(), 28, 34)
+ runBlocking { checker.check() }
+ val installer = object : ApkInstaller {
+ override suspend fun prepare(manifest: UpdateManifest, onState: (ApkInstallState) -> Unit): File = File("fixture")
+ override suspend fun launch(activity: Activity, manifest: UpdateManifest, file: File): Boolean {
+ launches++
+ awaitCancellation()
+ }
+ }
+ val updates = AppUpdates(checker, false, installer, scope)
+ updates.download()
+ shadowOf(Looper.getMainLooper()).idle()
+ val activity = Robolectric.buildActivity(Activity::class.java).get()
+ val install = scope.launch { updates.install(activity) }
+ scope.launch { updates.install(activity) }
+ shadowOf(Looper.getMainLooper()).idle()
+ assertEquals(1, launches)
+ assertEquals(ApkInstallStage.VERIFYING, updates.install.value.stage)
+ install.cancel()
+ shadowOf(Looper.getMainLooper()).idle()
+ assertEquals(ApkInstallStage.READY, updates.install.value.stage)
+ } finally {
+ scope.cancel()
+ }
+ }
+
+ @Test fun `unknown sources settings return keeps download and installation action`() {
+ val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+ try {
+ var allowed = false
+ var downloads = 0
+ var launches = 0
+ val checker = UpdateChecker(UpdateSource { UpdateLookup(UpdateOffer(29, "0.0.29", updateManifest())) }, MemoryUpdateStore(), 28, 34)
+ runBlocking { checker.check() }
+ val session = MemoryUpdateSessionStore()
+ val installer = object : ApkInstaller {
+ override suspend fun prepare(manifest: UpdateManifest, onState: (ApkInstallState) -> Unit): File {
+ downloads++
+ return File("verified-fixture")
+ }
+ override suspend fun launch(activity: Activity, manifest: UpdateManifest, file: File): Boolean {
+ launches++
+ return allowed
+ }
+ }
+ val updates = AppUpdates(checker, false, installer, scope, session)
+ val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
+ updates.open()
+ updates.download()
+ shadowOf(Looper.getMainLooper()).idle()
+ scope.launch { updates.install(activity) }
+ shadowOf(Looper.getMainLooper()).idle()
+ assertEquals(ApkInstallStage.PERMISSION, updates.install.value.stage)
+ assertEquals(true, updates.visible.value)
+ assertEquals(true, session.visible)
+ allowed = true
+ updates.foreground()
+ shadowOf(Looper.getMainLooper()).idle()
+ assertEquals(1, launches) // Returning from Settings never auto-starts the installer.
+ scope.launch { updates.install(activity) }
+ shadowOf(Looper.getMainLooper()).idle()
+ assertEquals(ApkInstallStage.READY, updates.install.value.stage)
+ assertEquals(1, downloads)
+ assertEquals(2, launches)
+ assertEquals(true, updates.visible.value)
+ } finally {
+ scope.cancel()
+ }
+ }
+}
diff --git a/app/src/test/java/cx/aswin/boxlore/updates/AppUpdatesTest.kt b/app/src/test/java/cx/aswin/boxlore/updates/AppUpdatesTest.kt
new file mode 100644
index 000000000..4708c9d19
--- /dev/null
+++ b/app/src/test/java/cx/aswin/boxlore/updates/AppUpdatesTest.kt
@@ -0,0 +1,274 @@
+package cx.aswin.boxlore.updates
+
+import android.app.Activity
+import java.io.File
+import java.io.IOException
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+class AppUpdatesTest {
+ @Test fun `ordinary update browsing does not reopen after process restart`() = runTest {
+ val checker = UpdateChecker(UpdateSource { UpdateLookup() }, MemoryUpdateStore(), 29, 34)
+ val session = MemoryUpdateSessionStore()
+ val updates = AppUpdates(checker, false, fakeInstaller { File("fixture") }, backgroundScope, session)
+ updates.open()
+ testScheduler.runCurrent()
+ assertTrue(updates.visible.value)
+ assertFalse(session.visible)
+ val restarted = AppUpdates(checker, false, fakeInstaller { File("fixture") }, backgroundScope, session)
+ testScheduler.runCurrent()
+ assertFalse(restarted.visible.value)
+ }
+
+ @Test fun `legacy visible flag without a pending update is cleared`() = runTest {
+ val checker = UpdateChecker(UpdateSource { UpdateLookup() }, MemoryUpdateStore(), 29, 34)
+ val session = MemoryUpdateSessionStore().apply { visible = true }
+ val updates = AppUpdates(checker, false, fakeInstaller { File("fixture") }, backgroundScope, session)
+ testScheduler.runCurrent()
+ assertFalse(updates.visible.value)
+ assertFalse(session.visible)
+ }
+
+ @Test fun `installed update does not restore its old install page`() = runTest {
+ val manifest = updateManifest(29)
+ val store = MemoryUpdateStore().apply { cachedManifest = manifest }
+ val checker = UpdateChecker(UpdateSource { UpdateLookup() }, store, 29, 34)
+ val session = MemoryUpdateSessionStore().apply {
+ visible = true
+ preparedChecksum = manifest.apkSha256
+ }
+ val updates = AppUpdates(checker, false, fakeInstaller { File("fixture") }, backgroundScope, session)
+ testScheduler.runCurrent()
+ assertFalse(updates.visible.value)
+ assertFalse(session.visible)
+ assertEquals(null, session.preparedChecksum)
+ }
+
+ @Test fun `opening a cached available update refreshes latest metadata without downloading`() = runTest {
+ var checks = 0
+ var downloads = 0
+ var latest = updateManifest().copy(versionCode = 30)
+ val store = MemoryUpdateStore().apply {
+ cachedManifest = updateManifest()
+ checkedAt = System.currentTimeMillis()
+ }
+ val checker = UpdateChecker(
+ UpdateSource {
+ checks++
+ UpdateLookup(UpdateOffer(latest.versionCode, "latest", latest))
+ },
+ store,
+ 28,
+ 34
+ )
+ val updates = AppUpdates(
+ checker,
+ false,
+ fakeInstaller {
+ downloads++
+ File("fixture")
+ },
+ backgroundScope
+ )
+ updates.open()
+ testScheduler.runCurrent()
+ assertEquals(30L, checker.state.value.offer?.versionCode)
+ assertEquals(1, checks)
+ assertEquals(0, downloads)
+ updates.dismiss()
+ latest = latest.copy(versionCode = 31)
+ updates.open()
+ testScheduler.runCurrent()
+ assertEquals(31L, checker.state.value.offer?.versionCode)
+ assertEquals(2, checks)
+ }
+
+ @Test fun `announcement download checks metadata once before starting one transfer`() = runTest {
+ var checks = 0
+ var downloads = 0
+ val checker = UpdateChecker(
+ UpdateSource {
+ checks++
+ UpdateLookup(UpdateOffer(29, "29", updateManifest()))
+ },
+ MemoryUpdateStore(),
+ 28,
+ 34
+ )
+ val updates = AppUpdates(
+ checker,
+ false,
+ fakeInstaller {
+ downloads++
+ File("fixture")
+ },
+ backgroundScope
+ )
+ updates.openAndDownload()
+ testScheduler.runCurrent()
+ assertEquals(1, checks)
+ assertEquals(1, downloads)
+ }
+
+ @Test fun `foreground check does not open a prompt or start a download`() = runTest {
+ var downloads = 0
+ val checker = UpdateChecker(UpdateSource { UpdateLookup(UpdateOffer(29, "0.0.29", updateManifest())) }, MemoryUpdateStore(), 28, 34)
+ val updates = AppUpdates(
+ checker,
+ false,
+ fakeInstaller {
+ downloads++
+ File("fixture")
+ },
+ backgroundScope
+ )
+ updates.foreground()
+ testScheduler.runCurrent()
+ assertTrue(checker.state.value.updateAvailable)
+ assertFalse(updates.visible.value)
+ assertEquals(0, downloads)
+ updates.open()
+ assertTrue(updates.visible.value)
+ assertEquals(0, downloads)
+ }
+
+ @Test fun `only one explicit transfer runs and dismissing the page preserves it`() = runTest {
+ val done = CompletableDeferred()
+ var downloads = 0
+ val checker = UpdateChecker(UpdateSource { UpdateLookup(UpdateOffer(29, "0.0.29", updateManifest())) }, MemoryUpdateStore(), 28, 34)
+ checker.check()
+ val updates = AppUpdates(
+ checker,
+ false,
+ fakeInstaller {
+ downloads++
+ done.await()
+ File("fixture")
+ },
+ backgroundScope
+ )
+ updates.open()
+ updates.download()
+ testScheduler.runCurrent()
+ updates.dismiss()
+ updates.download()
+ testScheduler.runCurrent()
+ assertEquals(1, downloads)
+ assertFalse(updates.visible.value)
+ done.complete(Unit)
+ testScheduler.runCurrent()
+ assertEquals(ApkInstallStage.READY, updates.install.value.stage)
+ }
+
+ @Test fun `failed transfer is retryable without claiming ready to install`() = runTest {
+ var attempts = 0
+ val checker = UpdateChecker(UpdateSource { UpdateLookup(UpdateOffer(29, "0.0.29", updateManifest())) }, MemoryUpdateStore(), 28, 34)
+ checker.check()
+ val updates = AppUpdates(
+ checker,
+ false,
+ fakeInstaller {
+ if (++attempts == 1) throw IOException("bad transfer")
+ File("fixture")
+ },
+ backgroundScope
+ )
+ updates.download()
+ testScheduler.runCurrent()
+ assertEquals(ApkInstallStage.FAILED, updates.install.value.stage)
+ updates.download()
+ testScheduler.runCurrent()
+ assertEquals(ApkInstallStage.READY, updates.install.value.stage)
+ assertEquals(2, attempts)
+ }
+
+ @Test fun `Play update action never starts an APK transfer`() = runTest {
+ var downloads = 0
+ val checker = UpdateChecker(UpdateSource { UpdateLookup(UpdateOffer(29, "29")) }, MemoryUpdateStore(), 28, 34)
+ checker.check()
+ val updates = AppUpdates(
+ checker,
+ true,
+ fakeInstaller {
+ downloads++
+ File("fixture")
+ },
+ backgroundScope
+ )
+ updates.download()
+ testScheduler.runCurrent()
+ assertEquals(0, downloads)
+ }
+
+ @Test fun `changing an offer cancels its transfer and clears a prepared APK`() = runTest {
+ var manifest = updateManifest()
+ var downloads = 0
+ var cancelled = false
+ val checker = UpdateChecker(UpdateSource { UpdateLookup(UpdateOffer(manifest.versionCode, manifest.versionName, manifest)) }, MemoryUpdateStore(), 28, 34)
+ checker.check()
+ val updates = AppUpdates(
+ checker,
+ false,
+ fakeInstaller {
+ if (++downloads == 1) {
+ try {
+ awaitCancellation()
+ } finally {
+ cancelled = true
+ }
+ }
+ File("fixture")
+ },
+ backgroundScope
+ )
+ updates.download()
+ testScheduler.runCurrent()
+ manifest = manifest.copy(versionCode = 30, versionName = "0.0.30", apkSha256 = "b".repeat(64))
+ checker.check(force = true)
+ testScheduler.runCurrent()
+ assertTrue(cancelled)
+ assertEquals(ApkInstallStage.IDLE, updates.install.value.stage)
+ updates.download()
+ testScheduler.runCurrent()
+ assertEquals(ApkInstallStage.READY, updates.install.value.stage)
+ manifest = manifest.copy(versionCode = 31, versionName = "0.0.31", apkSha256 = "c".repeat(64))
+ checker.check(force = true)
+ testScheduler.runCurrent()
+ assertEquals(ApkInstallStage.IDLE, updates.install.value.stage)
+ }
+
+ @Test fun `release Download tap checks then starts transfer while failed checks do not download`() = runTest {
+ var offline = false
+ var downloads = 0
+ val checker = UpdateChecker(UpdateSource { if (offline) error("offline") else UpdateLookup(UpdateOffer(29, "29", updateManifest())) }, MemoryUpdateStore(), 28, 34)
+ val updates = AppUpdates(
+ checker,
+ false,
+ fakeInstaller {
+ downloads++
+ File("fixture")
+ },
+ backgroundScope
+ )
+ updates.openAndDownload()
+ testScheduler.runCurrent()
+ assertTrue(updates.visible.value)
+ assertEquals(1, downloads)
+ assertEquals(ApkInstallStage.READY, updates.install.value.stage)
+ offline = true
+ updates.openAndDownload()
+ testScheduler.runCurrent()
+ assertEquals(1, downloads)
+ assertEquals(UpdateCheckStatus.FAILED, checker.state.value.status)
+ }
+
+ private fun fakeInstaller(prepare: suspend () -> File) = object : ApkInstaller {
+ override suspend fun prepare(manifest: UpdateManifest, onState: (ApkInstallState) -> Unit): File = prepare()
+ override suspend fun launch(activity: Activity, manifest: UpdateManifest, file: File): Boolean = true
+ }
+}
diff --git a/app/src/test/java/cx/aswin/boxlore/updates/DirectUpdateSourceTest.kt b/app/src/test/java/cx/aswin/boxlore/updates/DirectUpdateSourceTest.kt
new file mode 100644
index 000000000..d423d0736
--- /dev/null
+++ b/app/src/test/java/cx/aswin/boxlore/updates/DirectUpdateSourceTest.kt
@@ -0,0 +1,40 @@
+package cx.aswin.boxlore.updates
+
+import java.util.ArrayDeque
+import kotlinx.coroutines.runBlocking
+import okhttp3.OkHttpClient
+import okhttp3.Protocol
+import okhttp3.Response
+import okhttp3.ResponseBody.Companion.toResponseBody
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+class DirectUpdateSourceTest {
+ @Test fun `only a newer compatible valid manifest becomes available`() = runBlocking {
+ val responses = ArrayDeque(listOf(updateManifest().encode(), updateManifest(28).encode(), updateManifest().copy(minSdk = 35).encode()))
+ var cacheHeader: String? = null
+ val client = OkHttpClient.Builder().addInterceptor { chain ->
+ cacheHeader = chain.request().header("Cache-Control")
+ Response.Builder().request(chain.request()).protocol(Protocol.HTTP_1_1).code(200).message("OK")
+ .body(responses.removeFirst().toResponseBody()).build()
+ }.build()
+ val source = DirectUpdateSource(client, "https://example.invalid/update.json", 28, 34)
+ assertEquals(29L, source.lookup().offer?.versionCode)
+ assertEquals("no-cache", cacheHeader)
+ assertNull(source.lookup().offer)
+ assertEquals(UpdateLookup(incompatible = true), source.lookup())
+ }
+
+ @Test fun `HTTP failures malformed or oversized data never report current`() = runBlocking {
+ for ((code, body) in listOf(404 to "missing", 200 to "{broken}", 200 to "a".repeat(65 * 1024))) {
+ val client = OkHttpClient.Builder().addInterceptor { chain ->
+ Response.Builder().request(chain.request()).protocol(Protocol.HTTP_1_1).code(code).message("test")
+ .body(body.toResponseBody()).build()
+ }.build()
+ val source = DirectUpdateSource(client, "https://example.invalid/update.json", 28, 34)
+ assertTrue(runCatching { source.lookup() }.isFailure)
+ }
+ }
+}
diff --git a/app/src/test/java/cx/aswin/boxlore/updates/PlayUpdatePolicyTest.kt b/app/src/test/java/cx/aswin/boxlore/updates/PlayUpdatePolicyTest.kt
new file mode 100644
index 000000000..2af16e1e1
--- /dev/null
+++ b/app/src/test/java/cx/aswin/boxlore/updates/PlayUpdatePolicyTest.kt
@@ -0,0 +1,15 @@
+package cx.aswin.boxlore.updates
+
+import com.google.android.play.core.install.model.AppUpdateType
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Test
+
+class PlayUpdatePolicyTest {
+ @Test fun `immediate-only offers remain actionable even at normal priority`() {
+ assertEquals(AppUpdateType.IMMEDIATE, choosePlayUpdateType(false, true, false))
+ assertEquals(AppUpdateType.FLEXIBLE, choosePlayUpdateType(false, true, true))
+ assertEquals(AppUpdateType.IMMEDIATE, choosePlayUpdateType(true, true, true))
+ assertNull(choosePlayUpdateType(false, false, false))
+ }
+}
diff --git a/app/src/test/java/cx/aswin/boxlore/updates/ReleaseNotesMarkdownTest.kt b/app/src/test/java/cx/aswin/boxlore/updates/ReleaseNotesMarkdownTest.kt
new file mode 100644
index 000000000..21374e280
--- /dev/null
+++ b/app/src/test/java/cx/aswin/boxlore/updates/ReleaseNotesMarkdownTest.kt
@@ -0,0 +1,64 @@
+package cx.aswin.boxlore.updates
+
+import androidx.compose.ui.text.SpanStyle
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class ReleaseNotesMarkdownTest {
+ @Test
+ fun `PR references disappear while useful links remain`() {
+ val source = "- Better playback ([#123](https://github.com/boxcreate/boxlore/pull/123))\n- Clearer settings (#456)\n[Help](https://example.com/help)"
+ assertEquals("- Better playback\n- Clearer settings\n[Help](https://example.com/help)", stripReleasePrReferences(source))
+ }
+
+ @Test
+ fun `headings lists and legacy paragraphs retain their content`() {
+ val blocks = releaseNotesBlocks("## Improvements\r\n\r\n- Faster playback\n2. Clearer settings\n\nLegacy paragraph\ncontinued")
+ assertEquals(2, blocks[0].heading)
+ assertEquals("•", blocks[1].marker)
+ assertEquals("2.", blocks[2].marker)
+ assertEquals("Legacy paragraph\ncontinued", blocks[3].text)
+ }
+
+ @Test
+ fun `inline emphasis and web links hide markdown markers`() {
+ val text = releaseNotesInline("**Bold** and *italic*, `code`, [Notes](https://example.com)", SpanStyle())
+ assertEquals("Bold and italic, code, Notes", text.text)
+ assertEquals(1, text.getLinkAnnotations(0, text.length).size)
+ }
+
+ @Test
+ fun `asterisk and underscore delimiters preserve distinct bold italic and code styles`() {
+ val text = releaseNotesInline("**bold** __strong__ *italic* _emphasis_ `code`", SpanStyle())
+ assertEquals("bold strong italic emphasis code", text.text)
+ assertEquals(listOf(androidx.compose.ui.text.font.FontWeight.Bold, androidx.compose.ui.text.font.FontWeight.Bold), text.spanStyles.mapNotNull { it.item.fontWeight })
+ assertEquals(2, text.spanStyles.count { it.item.fontStyle == androidx.compose.ui.text.font.FontStyle.Italic })
+ assertEquals(1, text.spanStyles.count { it.item.fontFamily == androidx.compose.ui.text.font.FontFamily.Monospace })
+ assertEquals("*mixed_", releaseNotesInline("*mixed_", SpanStyle()).text)
+ }
+
+ @Test
+ fun `adjacent emphasis preserves delimiter precedence`() {
+ val text = releaseNotesInline("**first**__second__*third*_fourth_ tail", SpanStyle())
+ assertEquals("firstsecondthirdfourth tail", text.text)
+ assertEquals(4, text.spanStyles.size)
+ assertEquals(listOf(0, 5, 11, 16), text.spanStyles.map { it.start })
+ }
+
+ @Test
+ fun `delimiters inside code and links do not consume later formatting`() {
+ val text = releaseNotesInline("`**hidden` **visible** [**Label**](https://example.com) _after_", SpanStyle())
+ assertEquals("**hidden visible **Label** after", text.text)
+ assertEquals(1, text.getLinkAnnotations(0, text.length).size)
+ assertEquals(1, text.spanStyles.count { it.item.fontWeight == androidx.compose.ui.text.font.FontWeight.Bold })
+ assertEquals(1, text.spanStyles.count { it.item.fontStyle == androidx.compose.ui.text.font.FontStyle.Italic })
+ assertEquals(1, text.spanStyles.count { it.item.fontFamily == androidx.compose.ui.text.font.FontFamily.Monospace })
+ }
+
+ @Test
+ fun `unsafe link schemes remain inert plain text`() {
+ val text = releaseNotesInline("[Unsafe](javascript:alert) and ", SpanStyle())
+ assertEquals(0, text.getLinkAnnotations(0, text.length).size)
+ assertEquals("[Unsafe](javascript:alert) and ", text.text)
+ }
+}
diff --git a/app/src/test/java/cx/aswin/boxlore/updates/UpdateCheckerTest.kt b/app/src/test/java/cx/aswin/boxlore/updates/UpdateCheckerTest.kt
new file mode 100644
index 000000000..c665dcce3
--- /dev/null
+++ b/app/src/test/java/cx/aswin/boxlore/updates/UpdateCheckerTest.kt
@@ -0,0 +1,198 @@
+package cx.aswin.boxlore.updates
+
+import java.io.IOException
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.async
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+internal fun updateManifest(code: Long = 29): UpdateManifest = UpdateManifest(
+ schemaVersion = 1,
+ packageName = "cx.aswin.boxlore",
+ versionCode = code,
+ versionName = "0.0.$code",
+ minSdk = 31,
+ apkUrl = "https://github.com/boxcreate/boxlore/releases/download/v0.0.$code/boxlore.apk",
+ apkSha256 = "a".repeat(64),
+ apkBytes = 12,
+ notesUrl = "https://github.com/boxcreate/boxlore/releases/tag/v0.0.$code",
+ notes = "Improved playback",
+)
+
+internal class MemoryUpdateStore : UpdateCheckStore {
+ override var checkedAt = 0L
+ override var attemptedAt = 0L
+ override var cachedManifest: UpdateManifest? = null
+ override var cachedPlayVersion = 0L
+ override var upcoming = ""
+}
+
+class UpdateCheckerTest {
+ @Test fun `restored timestamp without a restored result cannot leave checker idle forever`() = runTest {
+ val store = MemoryUpdateStore().apply {
+ checkedAt = 100_000_000L
+ attemptedAt = checkedAt
+ }
+ var lookups = 0
+ val checker = UpdateChecker(
+ UpdateSource {
+ lookups++
+ UpdateLookup()
+ },
+ store,
+ 29,
+ 34
+ ) { 100_000_001L }
+ assertEquals(UpdateCheckStatus.IDLE, checker.state.value.status)
+ checker.check()
+ assertEquals(1, lookups)
+ assertEquals(UpdateCheckStatus.CURRENT, checker.state.value.status)
+ checker.check()
+ assertEquals(1, lookups)
+ }
+
+ @Test fun `unreleased preview is retained without offering a download and survives failed checks`() = runTest {
+ val store = MemoryUpdateStore()
+ var fail = false
+ val checker = UpdateChecker(
+ UpdateSource {
+ if (fail) error("offline")
+ UpdateLookup(upcoming = "- Clearer library controls")
+ },
+ store,
+ 29,
+ 34
+ )
+ checker.check(force = true)
+ assertEquals(UpdateCheckStatus.CURRENT, checker.state.value.status)
+ assertFalse(checker.state.value.updateAvailable)
+ assertEquals("- Clearer library controls", checker.state.value.upcoming)
+ fail = true
+ checker.check(force = true)
+ assertEquals("- Clearer library controls", checker.state.value.upcoming)
+ assertEquals("- Clearer library controls", UpdateChecker(UpdateSource { UpdateLookup() }, store, 29, 34).state.value.upcoming)
+ }
+
+ private var time = 100_000_000L
+ private val store = MemoryUpdateStore()
+ private fun checker(source: UpdateSource) = UpdateChecker(source, store, 28, 34) { time }
+
+ @Test fun `same version refresh and older builds never show an update`() = runTest {
+ for (code in listOf(27L, 28L)) {
+ val checker = checker { UpdateLookup(UpdateOffer(code, "0.0.$code", updateManifest(code))) }
+ checker.check(force = true)
+ assertEquals(UpdateCheckStatus.CURRENT, checker.state.value.status)
+ assertFalse(checker.state.value.updateAvailable)
+ }
+ }
+
+ @Test fun `compatible newer manifest shows an offer and survives process recreation`() = runTest {
+ val manifest = updateManifest()
+ val checker = checker { UpdateLookup(UpdateOffer(29, "0.0.29", manifest)) }
+ assertFalse(checker.state.value.updateAvailable)
+ checker.check()
+ assertTrue(checker.state.value.updateAvailable)
+ assertEquals(manifest, store.cachedManifest)
+ assertTrue(checker { error("No network before foreground") }.state.value.updateAvailable)
+ assertFalse(UpdateChecker(UpdateSource { error("unused") }, store, 29, 34).state.value.updateAvailable)
+ }
+
+ @Test fun `Play availability survives process recreation during the check interval and clears after installation`() = runTest {
+ val checker = checker { UpdateLookup(UpdateOffer(29, "29")) }
+ checker.check()
+ assertTrue(checker.state.value.updateAvailable)
+ val recreated = checker { error("No duplicate Play lookup within the interval") }
+ recreated.check()
+ assertTrue(recreated.state.value.updateAvailable)
+ assertFalse(UpdateChecker(UpdateSource { error("unused") }, store, 29, 34).state.value.updateAvailable)
+ val noLongerAvailable = checker { UpdateLookup() }
+ noLongerAvailable.check(force = true)
+ assertFalse(checker { error("unused") }.state.value.updateAvailable)
+ }
+
+ @Test fun `successful checks throttle six hours and manual checks bypass the interval`() = runTest {
+ var requests = 0
+ val checker = checker {
+ requests++
+ UpdateLookup()
+ }
+ checker.check()
+ repeat(4) {
+ time += 60_000
+ checker.check()
+ }
+ assertEquals(1, requests)
+ checker.check(force = true)
+ assertEquals(2, requests)
+ time += UpdateChecker.CHECK_INTERVAL
+ checker.check()
+ assertEquals(3, requests)
+ }
+
+ @Test fun `failure never claims up to date and backs off automatic retries`() = runTest {
+ var requests = 0
+ val checker = checker {
+ requests++
+ throw IOException("offline")
+ }
+ checker.check()
+ assertEquals(UpdateCheckStatus.FAILED, checker.state.value.status)
+ assertFalse(checker.state.value.updateAvailable)
+ checker.check()
+ assertEquals(1, requests)
+ time += UpdateChecker.RETRY_INTERVAL
+ checker.check()
+ assertEquals(2, requests)
+ checker.check(force = true)
+ assertEquals(3, requests)
+ }
+
+ @Test fun `offline refresh preserves a previously confirmed update`() = runTest {
+ store.cachedManifest = updateManifest()
+ val checker = checker { throw IOException("offline") }
+ checker.check()
+ assertEquals(UpdateCheckStatus.FAILED, checker.state.value.status)
+ assertTrue(checker.state.value.updateAvailable)
+ assertEquals(updateManifest(), store.cachedManifest)
+ }
+
+ @Test fun `incompatible release is distinct from up to date`() = runTest {
+ val checker = checker { UpdateLookup(incompatible = true) }
+ checker.check()
+ assertEquals(UpdateCheckStatus.INCOMPATIBLE, checker.state.value.status)
+ assertFalse(checker.state.value.updateAvailable)
+ }
+
+ @Test fun `foreground and manual request join an in flight lookup`() = runTest {
+ val entered = CompletableDeferred()
+ val complete = CompletableDeferred()
+ var requests = 0
+ val checker = checker {
+ requests++
+ entered.complete(Unit)
+ complete.await()
+ UpdateLookup()
+ }
+ val first = async { checker.check() }
+ entered.await()
+ val second = async { checker.check(force = true) }
+ testScheduler.runCurrent()
+ complete.complete(Unit)
+ first.await()
+ second.await()
+ assertEquals(1, requests)
+ }
+
+ @Test fun `clock reset does not suppress checks for a day`() = runTest {
+ store.checkedAt = time + 86_400_000
+ var requests = 0
+ checker {
+ requests++
+ UpdateLookup()
+ }.check()
+ assertEquals(1, requests)
+ }
+}
diff --git a/app/src/test/java/cx/aswin/boxlore/updates/UpdateEntryPointsTest.kt b/app/src/test/java/cx/aswin/boxlore/updates/UpdateEntryPointsTest.kt
new file mode 100644
index 000000000..84df62682
--- /dev/null
+++ b/app/src/test/java/cx/aswin/boxlore/updates/UpdateEntryPointsTest.kt
@@ -0,0 +1,60 @@
+package cx.aswin.boxlore.updates
+
+import android.app.Application
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.longClick
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTouchInput
+import cx.aswin.boxlore.core.designsystem.components.LocalUpdateAvailableAction
+import cx.aswin.boxlore.core.designsystem.components.TopBarUtilityActions
+import org.junit.Assert.assertEquals
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [33], application = Application::class)
+class UpdateEntryPointsTest {
+ @get:Rule val composeRule = createComposeRule()
+
+ @Test fun `update icon only appears for available action and preserves utility buttons`() {
+ var updateClicks = 0
+ var settingsClicks = 0
+ var feedbackClicks = 0
+ var feedbackLongClicks = 0
+ var settingsLongClicks = 0
+ val action = mutableStateOf<(() -> Unit)?>(null)
+ composeRule.setContent {
+ MaterialTheme {
+ CompositionLocalProvider(LocalUpdateAvailableAction provides action.value) {
+ TopBarUtilityActions(onFeedbackClick = { feedbackClicks++ }, onSettingsClick = { settingsClicks++ }, onFeedbackLongClick = { feedbackLongClicks++ }, onSettingsLongClick = { settingsLongClicks++ })
+ }
+ }
+ }
+ composeRule.onNodeWithContentDescription("Update available").assertDoesNotExist()
+ val utilityCenter = composeRule.onNodeWithContentDescription("Settings").fetchSemanticsNode().boundsInRoot.center.y
+ composeRule.runOnIdle { action.value = { updateClicks++ } }
+ composeRule.onNodeWithContentDescription("Update available").assertIsDisplayed().performClick()
+ assertEquals(utilityCenter, composeRule.onNodeWithContentDescription("Settings").fetchSemanticsNode().boundsInRoot.center.y, 0.01f)
+ composeRule.onNodeWithContentDescription("Settings").performClick()
+ composeRule.onNodeWithContentDescription("Send Feedback").performClick()
+ assertEquals(1, updateClicks)
+ assertEquals(1, settingsClicks)
+ assertEquals(1, feedbackClicks)
+ composeRule.onNodeWithContentDescription("Settings").performTouchInput { longClick() }
+ composeRule.onNodeWithContentDescription("Send Feedback").performTouchInput { longClick() }
+ assertEquals(1, settingsLongClicks)
+ assertEquals(1, feedbackLongClicks)
+ assertEquals(1, settingsClicks)
+ assertEquals(1, feedbackClicks)
+ composeRule.runOnIdle { action.value = null }
+ composeRule.onNodeWithContentDescription("Update available").assertDoesNotExist()
+ }
+}
diff --git a/app/src/test/java/cx/aswin/boxlore/updates/UpdateHttpCallTest.kt b/app/src/test/java/cx/aswin/boxlore/updates/UpdateHttpCallTest.kt
new file mode 100644
index 000000000..9a63ba732
--- /dev/null
+++ b/app/src/test/java/cx/aswin/boxlore/updates/UpdateHttpCallTest.kt
@@ -0,0 +1,51 @@
+package cx.aswin.boxlore.updates
+
+import java.io.IOException
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+import okhttp3.Call
+import okhttp3.Callback
+import okhttp3.Request
+import okhttp3.Response
+import okio.Timeout
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+class UpdateHttpCallTest {
+ @Test fun `pausing a blocked transfer cancels its socket without waiting for timeout`() = runBlocking {
+ val call = BlockingCall()
+ val transfer = launch(Dispatchers.IO) { call.withUpdateResponse { } }
+ try {
+ assertTrue(call.started.await(2, TimeUnit.SECONDS))
+ withTimeout(2_000) { transfer.cancelAndJoin() }
+ assertTrue(call.isCanceled())
+ } finally {
+ call.cancel()
+ transfer.cancelAndJoin()
+ }
+ }
+
+ private class BlockingCall : Call {
+ val started = CountDownLatch(1)
+ private val closed = CountDownLatch(1)
+ override fun request(): Request = Request.Builder().url("https://example.test/update").build()
+ override fun execute(): Response {
+ started.countDown()
+ check(closed.await(5, TimeUnit.SECONDS)) { "Socket was not cancelled" }
+ throw IOException("Socket closed")
+ }
+ override fun cancel() {
+ closed.countDown()
+ }
+ override fun isExecuted(): Boolean = started.count == 0L
+ override fun isCanceled(): Boolean = closed.count == 0L
+ override fun timeout(): Timeout = Timeout.NONE
+ override fun clone(): Call = BlockingCall()
+ override fun enqueue(responseCallback: Callback) = error("Synchronous fixture only")
+ }
+}
diff --git a/app/src/test/java/cx/aswin/boxlore/updates/UpdateManifestTest.kt b/app/src/test/java/cx/aswin/boxlore/updates/UpdateManifestTest.kt
new file mode 100644
index 000000000..a40bd559e
--- /dev/null
+++ b/app/src/test/java/cx/aswin/boxlore/updates/UpdateManifestTest.kt
@@ -0,0 +1,40 @@
+package cx.aswin.boxlore.updates
+
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertThrows
+import org.junit.jupiter.api.Test
+
+class UpdateManifestTest {
+ @Test fun `manifest round trips with listener copy intact and accepts future optional fields`() {
+ val manifest = updateManifest().copy(notes = "• Better playback\n• Faster startup")
+ assertEquals(manifest, UpdateManifest.parse(manifest.encode()))
+ assertEquals(manifest, UpdateManifest.parse(manifest.encode().dropLast(1) + ",\"future\":true}"))
+ }
+
+ @Test fun `untrusted URLs cannot supply an installer or release notes`() {
+ for (url in listOf(
+ "http://github.com/boxcreate/boxlore/releases/download/v1/a.apk",
+ "https://github.com.evil.example/boxcreate/boxlore/releases/download/v1/a.apk",
+ "https://github.com/other/repo/releases/download/v1/a.apk",
+ "https://github.com/boxcreate/boxlore/releases/download/../a.apk",
+ "https://github.com/boxcreate/boxlore/releases/download/%2e%2e/a.apk",
+ "https://user@github.com/boxcreate/boxlore/releases/download/v1/a.apk",
+ )) {
+ assertThrows(IllegalArgumentException::class.java) { updateManifest().copy(apkUrl = url).validate() }
+ }
+ assertThrows(IllegalArgumentException::class.java) { updateManifest().copy(notesUrl = "https://example.com").validate() }
+ }
+
+ @Test fun `missing integrity bounds unsupported schema and wrong package fail closed`() {
+ for (manifest in listOf(
+ updateManifest().copy(schemaVersion = 2),
+ updateManifest().copy(packageName = "other.app"),
+ updateManifest().copy(apkSha256 = "missing"),
+ updateManifest().copy(apkBytes = 0),
+ updateManifest().copy(apkBytes = 513L * 1024 * 1024),
+ updateManifest().copy(versionCode = -1),
+ )) {
+ assertThrows(IllegalArgumentException::class.java) { manifest.validate() }
+ }
+ }
+}
diff --git a/app/src/test/java/cx/aswin/boxlore/updates/UpdateSessionTest.kt b/app/src/test/java/cx/aswin/boxlore/updates/UpdateSessionTest.kt
new file mode 100644
index 000000000..4a3b08dcd
--- /dev/null
+++ b/app/src/test/java/cx/aswin/boxlore/updates/UpdateSessionTest.kt
@@ -0,0 +1,139 @@
+package cx.aswin.boxlore.updates
+
+import android.app.Activity
+import java.io.File
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.test.runTest
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+class UpdateSessionTest {
+ @Test fun `prepared updater screen and verified file restore across a process restart`() = runTest {
+ val manifest = updateManifest()
+ val store = MemoryUpdateStore().apply { cachedManifest = manifest }
+ val session = MemoryUpdateSessionStore()
+ var prepares = 0
+ var restores = 0
+ val installer = object : ApkInstaller {
+ override suspend fun prepare(manifest: UpdateManifest, onState: (ApkInstallState) -> Unit): File {
+ prepares++
+ return File("private-fixture.apk")
+ }
+ override suspend fun restore(manifest: UpdateManifest): File {
+ restores++
+ return File("private-fixture.apk")
+ }
+ override suspend fun launch(activity: Activity, manifest: UpdateManifest, file: File): Boolean = false
+ }
+ val first = AppUpdates(UpdateChecker(UpdateSource { UpdateLookup(UpdateOffer(29, "29", manifest)) }, store, 28, 34), false, installer, backgroundScope, session)
+ first.open()
+ first.download()
+ testScheduler.runCurrent()
+ assertEquals(manifest.apkSha256, session.preparedChecksum)
+ val restored = AppUpdates(UpdateChecker(UpdateSource { UpdateLookup(UpdateOffer(29, "29", manifest)) }, store, 28, 34), false, installer, backgroundScope, session)
+ restored.foreground()
+ testScheduler.runCurrent()
+ assertTrue(restored.visible.value)
+ assertEquals(ApkInstallStage.READY, restored.install.value.stage)
+ assertEquals(1, prepares)
+ assertEquals(1, restores)
+ restored.dismiss()
+ assertFalse(session.visible)
+ }
+
+ @Test fun `a removed cached APK restores the screen without an automatic download`() = runTest {
+ val manifest = updateManifest()
+ val store = MemoryUpdateStore().apply { cachedManifest = manifest }
+ val session = MemoryUpdateSessionStore().apply {
+ visible = true
+ preparedChecksum = manifest.apkSha256
+ }
+ var downloads = 0
+ val installer = object : ApkInstaller {
+ override suspend fun prepare(manifest: UpdateManifest, onState: (ApkInstallState) -> Unit): File {
+ downloads++
+ return File("fixture")
+ }
+ override suspend fun restore(manifest: UpdateManifest): File? = null
+ override suspend fun launch(activity: Activity, manifest: UpdateManifest, file: File): Boolean = false
+ }
+ val updates = AppUpdates(UpdateChecker(UpdateSource { UpdateLookup(UpdateOffer(29, "29", manifest)) }, store, 28, 34), false, installer, backgroundScope, session)
+ updates.foreground()
+ testScheduler.runCurrent()
+ assertTrue(updates.visible.value)
+ assertEquals(ApkInstallStage.IDLE, updates.install.value.stage)
+ assertEquals(null, session.preparedChecksum)
+ assertEquals(0, downloads)
+ }
+
+ @Test fun `explicit release Download waits for cached file verification before replacing a missing file`() = runTest {
+ val manifest = updateManifest()
+ val store = MemoryUpdateStore().apply { cachedManifest = manifest }
+ val session = MemoryUpdateSessionStore().apply {
+ visible = true
+ preparedChecksum = manifest.apkSha256
+ }
+ val restoreDone = CompletableDeferred()
+ var downloads = 0
+ val installer = object : ApkInstaller {
+ override suspend fun prepare(manifest: UpdateManifest, onState: (ApkInstallState) -> Unit): File {
+ downloads++
+ return File("fixture")
+ }
+ override suspend fun restore(manifest: UpdateManifest): File? {
+ restoreDone.await()
+ return null
+ }
+ override suspend fun launch(activity: Activity, manifest: UpdateManifest, file: File): Boolean = false
+ }
+ val updates = AppUpdates(UpdateChecker(UpdateSource { UpdateLookup(UpdateOffer(29, "29", manifest)) }, store, 28, 34), false, installer, backgroundScope, session)
+ updates.openAndDownload()
+ testScheduler.runCurrent()
+ assertEquals(0, downloads)
+ restoreDone.complete(Unit)
+ testScheduler.runCurrent()
+ assertEquals(1, downloads)
+ assertEquals(ApkInstallStage.READY, updates.install.value.stage)
+ }
+
+ @Test fun `a changed release cancels stale file restoration instead of leaving verification stuck`() = runTest {
+ var manifest = updateManifest()
+ val store = MemoryUpdateStore().apply { cachedManifest = manifest }
+ val session = MemoryUpdateSessionStore().apply {
+ visible = true
+ preparedChecksum = manifest.apkSha256
+ }
+ var cancelled = false
+ val installer = object : ApkInstaller {
+ override suspend fun prepare(manifest: UpdateManifest, onState: (ApkInstallState) -> Unit): File = error("No automatic download")
+ override suspend fun restore(manifest: UpdateManifest): File? {
+ try {
+ awaitCancellation()
+ } finally {
+ cancelled = true
+ }
+ }
+ override suspend fun launch(activity: Activity, manifest: UpdateManifest, file: File): Boolean = false
+ }
+ val checker = UpdateChecker(UpdateSource { UpdateLookup(UpdateOffer(manifest.versionCode, manifest.versionName, manifest)) }, store, 28, 34)
+ val updates = AppUpdates(checker, false, installer, backgroundScope, session)
+ testScheduler.runCurrent()
+ assertEquals(ApkInstallStage.VERIFYING, updates.install.value.stage)
+ manifest = manifest.copy(versionCode = 30, apkSha256 = "b".repeat(64))
+ checker.check(force = true)
+ testScheduler.runCurrent()
+ assertTrue(cancelled)
+ assertEquals(ApkInstallStage.IDLE, updates.install.value.stage)
+ assertEquals(null, session.preparedChecksum)
+ }
+
+ @Test fun `loopback update is rejected in shipping policy and cannot point outside loopback`() {
+ val fixture = updateManifest().copy(testOnly = true, apkUrl = "http://127.0.0.1:8765/candidate.apk", notesUrl = "http://127.0.0.1:8765/notes")
+ org.junit.jupiter.api.assertThrows { fixture.validate(false) }
+ fixture.validate(true)
+ org.junit.jupiter.api.assertThrows { fixture.copy(apkUrl = "http://example.com:8765/candidate.apk").validate(true) }
+ }
+}
diff --git a/app/src/updateTest/AndroidManifest.xml b/app/src/updateTest/AndroidManifest.xml
new file mode 100644
index 000000000..a00c4ee41
--- /dev/null
+++ b/app/src/updateTest/AndroidManifest.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/updateTest/java/cx/aswin/boxlore/testing/AnnouncementTestReceiver.kt b/app/src/updateTest/java/cx/aswin/boxlore/testing/AnnouncementTestReceiver.kt
new file mode 100644
index 000000000..a71281354
--- /dev/null
+++ b/app/src/updateTest/java/cx/aswin/boxlore/testing/AnnouncementTestReceiver.kt
@@ -0,0 +1,33 @@
+package cx.aswin.boxlore.testing
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import cx.aswin.boxlore.BoxLoreApplication
+import cx.aswin.boxlore.BuildConfig
+import cx.aswin.boxlore.fcm.FcmPayloadParser
+import cx.aswin.boxlore.fcm.toAnnouncement
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import org.json.JSONObject
+
+/** ADB-only, local in-app preview. This component cannot be packaged in shipping variants. */
+class AnnouncementTestReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (!BuildConfig.BOXLORE_ISOLATED_TESTS || intent.action != "cx.aswin.boxlore.TEST_ANNOUNCEMENT") return
+ val json = runCatching { JSONObject(intent.getStringExtra("payload") ?: return) }.getOrNull() ?: return
+ val data = json.keys().asSequence().associateWith { json.optString(it) } + mapOf("test_mode" to "true", "release_version_code" to "0")
+ val parsed = FcmPayloadParser.parse(data)
+ if (parsed.title.isBlank() || parsed.body.isBlank()) return
+ val pending = goAsync()
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ (context.applicationContext as BoxLoreApplication).userPreferencesRepository.setAnnouncement(parsed.toAnnouncement())
+ pending.resultCode = android.app.Activity.RESULT_OK
+ } finally {
+ pending.finish()
+ }
+ }
+ }
+}
diff --git a/app/src/updateTest/res/xml/update_test_network.xml b/app/src/updateTest/res/xml/update_test_network.xml
new file mode 100644
index 000000000..8cdbd381b
--- /dev/null
+++ b/app/src/updateTest/res/xml/update_test_network.xml
@@ -0,0 +1,4 @@
+
+
+ 127.0.0.1
+
diff --git a/core/designsystem/README.md b/core/designsystem/README.md
index 0d5d0bdee..a5a72b31a 100644
--- a/core/designsystem/README.md
+++ b/core/designsystem/README.md
@@ -6,12 +6,14 @@ Owns shared Compose visual primitives: theme, typography, shapes, motion, loader
## Public API
+- `LocalUpdateAvailableAction` carries an optional app-shell UI action. `TopBarUtilityActions` connects icon-only Update, Feedback and Settings action segments using Material 3 connected spacing and leading/middle/trailing corners. The optional Update segment uses primary-container/on-primary-container roles and a shimmering system-update symbol; it has no nested circle and appears only when the action is supplied. Feedback/Settings click and long-press callbacks, toolbar height and shortcut placement remain unchanged. Its accessible label is **Update available**; the design system owns no update checking or installation.
+
- `ArtworkNavigationColors.appThemePreviewEntryId` tracks a temporary regular-page preview separately from committed entry ownership. Preview changes do not mutate saved artwork palettes; navigation commits and cancellation clear the preview.
- `BoxLoreTheme` and theme helpers such as expressive shapes, motion, typography, and dynamic color utilities. `resolveBoxLoreColorScheme` / `resolveBoxLoreChromeColors` / `darkThemeFromConfig` expose the same Appearance ColorScheme as ARGB for non-Compose chrome (home-screen widgets). Custom `theme_brand` may be a named seed, `#RRGGBB` (Material 3 palette from that seed), or `exact:#RRGGBB` (pins primary to that RGB; not recommended for readability). `Modifier.expressiveClickable` has a long-press overload (`onLongClick`) used by Downloads multi-select. Pass `shape` so press-shrink stays rounded; `pressScaleEnabled = false` while a drag overlay owns scale.
- `CustomThemeSeeds` encodes primary and optional secondary/tertiary seeds as versioned `custom:1:...` values in the existing theme-brand key. `generatePersonalColorScheme` uses Material Color Utilities HCT/tonal palettes and coordinated color roles for both modes; existing authored/hex/preset resolution remains unchanged. `artworkSurfaceStyle` retains Pure surfaces while allowing artwork-tinted detail pages; `selectArtworkSeed` ignores monochrome margins and negligible details. `LocalArtworkColorsEnabled` carries the saved opt-out. `ArtworkNavigationColors` captures the visible source palette synchronously at navigation, retains up to 80 entry snapshots and starts forward and Back handoffs with the departing colors, and invalidates them when the saved theme or artwork opt-out changes; `rememberAnimatedArtworkColors` eases all roles on one 750ms clock and retargets from the visible palette. `LocalArtworkLoaderColors` lets shared loaders inherit the source without recoloring regular-page content; detail pages override it with their synchronized page palette. `ArtworkNavigationTheme` inherits the page’s exact animated accents and restrained neutral surfaces, including Pure overrides; playback chrome remains independent.
- Shared components including `OptimizedImage` (optional `errorContent` for blank/failed art), loaders, `PillFilterChip` (onboarding/Explore genre pills), `BoxLoreLogo` (optional `height` for hero vs chrome sizes), `RemoveDownloadConfirmationDialog` (Material 3 confirmation dialog before removing downloaded media), player-control primitives used by UI modules, floating 3+1 navigation chrome, bottom-content clearance helpers, sleep-timer chrome, and the non-dismissible `RepairProgressPopup` for app-shell foreground repair status.
-- `TopBarUtilityActions` groups Feedback and Settings in one 35dp-tall solid Material 3 capsule, following GlassCast's top-right toolbar style. Settings uses the sliders (`Tune`) icon. Matching 22dp icons have separate 48dp-wide button slots, clipped touch feedback and a restrained press scale read only in their graphics layers. Foundation expands touch bounds vertically beyond the compact visual height. Screen owners retain placement, click/long-press callbacks and shortcut visibility; this component owns no scroll behavior.
+- `TopBarUtilityActions` groups Feedback and Settings as 33dp-tall solid Material 3 connected action segments, adding Update when available. Settings uses the sliders (`Tune`) icon. Feedback/Settings use 20dp icons in 43dp-wide segments with press feedback; the update symbol is 22dp. These remain action buttons rather than mutually exclusive selection controls. Foundation expands touch bounds vertically beyond the compact visual height. Screen owners retain placement, click/long-press callbacks and shortcut visibility; this component owns no scroll behavior.
- `LocalAdaptivePlayerCompactProgress` carries the app-owned player/nav morph clock. `Modifier.adaptivePlayerOverlayOffset` reads it only during placement, moving floating feature controls down by the released player row during the shared `adaptivePlayerDockingProgress` phase. Classic and absent playback retain their placement; list/content clearance remains stable.
- Regular navigation tabs in both presentations use the current theme's `primary` accent and `onPrimary` icon color through `NavigationAccentColors`, including authored presets, wallpaper palettes and exact custom accents. Classic selected labels use the same accent. Floating Lore retains its multicolour aurora and container base, blended into the single moving indicator during draw rather than adding another selection surface. Its selected icon uses `onPrimary` before the aurora arrives, `onPrimaryContainer` at rest, and the more readable of those foregrounds during the solid-base blend; foreground selection follows the same indicator position without a second delayed color animation. Unselected icons retain `onSurfaceVariant`.
- `icon.GenreIcons`: Rich icon palette and resolution helpers for custom podcast genre tags, subscription folders, and filter chips. Maps icon keys to Material Rounded icons (`all`, `findIcon`, `defaultGenreIcon`, `iconOrFallback`, `defaultFolderIcon`, `folderIconOrFallback`).
@@ -110,3 +112,5 @@ Classic navigation applies its system insets outside the minimum 80dp content he
### Upright brand wordmarks
- Extended brand wordmarks and the LORE navigation brain are upright. Share cards use the plain upright wordmark at 280px wide. Stories stack “Listen on” above it in a centred signature below the content, clear of bottom story controls. Square message cards place the label and wordmark in one centred footer row with a 96px bottom inset. Labels retain a 12px size when the full export is displayed at 360px wide; artwork and title remain the main focus.
+
+The toolbar action segments use a compact 43 × 33 dp visual size. The available-update segment has a restrained shimmer across its surface.
diff --git a/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/TopBarUtilityActions.kt b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/TopBarUtilityActions.kt
index 5590176d1..dbbfaa1ac 100644
--- a/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/TopBarUtilityActions.kt
+++ b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/TopBarUtilityActions.kt
@@ -7,15 +7,16 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Feedback
import androidx.compose.material.icons.rounded.Tune
+import androidx.compose.material3.ButtonGroupDefaults
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -25,12 +26,14 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
-import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
-/** Home's utility actions share one solid capsule, wherever the screen places them. */
+/** Connected action segments; screen owners retain navigation and shortcut callbacks. */
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun TopBarUtilityActions(
onFeedbackClick: () -> Unit,
@@ -40,28 +43,48 @@ fun TopBarUtilityActions(
onSettingsLongClick: () -> Unit = {},
settingsButtonModifier: Modifier = Modifier,
) {
- Surface(
+ val onUpdate = LocalUpdateAvailableAction.current
+ val leading = RoundedCornerShape(topStart = 24.dp, topEnd = 7.dp, bottomEnd = 7.dp, bottomStart = 24.dp)
+ val trailing = RoundedCornerShape(topStart = 7.dp, topEnd = 24.dp, bottomEnd = 24.dp, bottomStart = 7.dp)
+ val middle = RoundedCornerShape(7.dp)
+ Row(
modifier = modifier,
- shape = RoundedCornerShape(percent = 50),
- color = MaterialTheme.colorScheme.surfaceContainerHigh,
- contentColor = MaterialTheme.colorScheme.onSurface,
+ horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween),
+ verticalAlignment = Alignment.CenterVertically,
) {
- Row(
- modifier = Modifier.padding(horizontal = 4.dp),
- verticalAlignment = Alignment.CenterVertically,
+ if (onUpdate != null) {
+ UtilityActionSegment(
+ onClick = onUpdate,
+ onLongClick = {},
+ shape = leading,
+ modifier = Modifier.clip(leading).then(updateShimmerModifier()),
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ ) {
+ UpdateAvailableIcon(Modifier.size(22.dp))
+ }
+ }
+ UtilityActionSegment(
+ onClick = onFeedbackClick,
+ onLongClick = onFeedbackLongClick,
+ shape = if (onUpdate == null) leading else middle,
) {
- UtilityActionButton(
- icon = Icons.Rounded.Feedback,
+ Icon(
+ imageVector = Icons.Rounded.Feedback,
contentDescription = "Send Feedback",
- onClick = onFeedbackClick,
- onLongClick = onFeedbackLongClick,
+ modifier = Modifier.size(20.dp),
)
- UtilityActionButton(
- icon = Icons.Rounded.Tune,
+ }
+ UtilityActionSegment(
+ onClick = onSettingsClick,
+ onLongClick = onSettingsLongClick,
+ shape = trailing,
+ modifier = settingsButtonModifier,
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Tune,
contentDescription = "Settings",
- onClick = onSettingsClick,
- onLongClick = onSettingsLongClick,
- modifier = settingsButtonModifier,
+ modifier = Modifier.size(20.dp),
)
}
}
@@ -69,17 +92,19 @@ fun TopBarUtilityActions(
@OptIn(ExperimentalFoundationApi::class)
@Composable
-private fun UtilityActionButton(
- icon: ImageVector,
- contentDescription: String,
+private fun UtilityActionSegment(
onClick: () -> Unit,
onLongClick: () -> Unit,
+ shape: Shape,
modifier: Modifier = Modifier,
+ containerColor: Color = MaterialTheme.colorScheme.surfaceContainerHigh,
+ contentColor: Color = MaterialTheme.colorScheme.onSurface,
+ content: @Composable () -> Unit,
) {
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
- val iconScale = animateFloatAsState(
- targetValue = if (isPressed) 0.94f else 1f,
+ val pressScale = animateFloatAsState(
+ targetValue = if (isPressed) 0.97f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMedium,
@@ -87,10 +112,13 @@ private fun UtilityActionButton(
label = "utilityActionPress",
)
- Box(
+ Surface(
modifier = modifier
- .size(width = 48.dp, height = 35.dp)
- .clip(CircleShape)
+ .size(width = 43.dp, height = 33.dp)
+ .graphicsLayer {
+ scaleX = pressScale.value
+ scaleY = pressScale.value
+ }
.combinedClickable(
interactionSource = interactionSource,
indication = androidx.compose.foundation.LocalIndication.current,
@@ -98,17 +126,10 @@ private fun UtilityActionButton(
onClick = onClick,
onLongClick = onLongClick,
),
- contentAlignment = Alignment.Center,
+ shape = if (isPressed) RoundedCornerShape(12.dp) else shape,
+ color = containerColor,
+ contentColor = contentColor,
) {
- Icon(
- imageVector = icon,
- contentDescription = contentDescription,
- modifier = Modifier
- .size(22.dp)
- .graphicsLayer {
- scaleX = iconScale.value
- scaleY = iconScale.value
- },
- )
+ Box(contentAlignment = Alignment.Center) { content() }
}
}
diff --git a/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/UpdateAvailableIcon.kt b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/UpdateAvailableIcon.kt
new file mode 100644
index 000000000..71f50d4f4
--- /dev/null
+++ b/core/designsystem/src/main/java/cx/aswin/boxlore/core/designsystem/components/UpdateAvailableIcon.kt
@@ -0,0 +1,58 @@
+package cx.aswin.boxlore.core.designsystem.components
+
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.SystemUpdateAlt
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithCache
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import cx.aswin.boxlore.core.designsystem.R
+
+/** App shell supplies this UI action only after confirming a compatible newer update. */
+val LocalUpdateAvailableAction = staticCompositionLocalOf<(() -> Unit)?> { null }
+
+@Composable
+internal fun UpdateAvailableIcon(modifier: Modifier = Modifier) {
+ Icon(
+ imageVector = Icons.Rounded.SystemUpdateAlt,
+ contentDescription = stringResource(R.string.update_available_action),
+ tint = LocalContentColor.current,
+ modifier = modifier,
+ )
+}
+
+@Composable
+internal fun updateShimmerModifier(): Modifier {
+ val transition = rememberInfiniteTransition(label = "updateAvailable")
+ val sweep = transition.animateFloat(
+ initialValue = -1f,
+ targetValue = 3f,
+ animationSpec = infiniteRepeatable(tween(2600, easing = LinearEasing), RepeatMode.Restart),
+ label = "updateShimmer",
+ )
+ return Modifier.drawWithCache {
+ onDrawWithContent {
+ drawContent()
+ val x = size.width * sweep.value
+ drawRect(
+ brush = Brush.linearGradient(
+ colors = listOf(Color.Transparent, Color.White.copy(alpha = 0.22f), Color.Transparent),
+ start = Offset(x - size.width / 2, 0f),
+ end = Offset(x + size.width / 2, size.height),
+ ),
+ )
+ }
+ }
+}
diff --git a/core/designsystem/src/main/res/values/updates.xml b/core/designsystem/src/main/res/values/updates.xml
new file mode 100644
index 000000000..e9e4cb462
--- /dev/null
+++ b/core/designsystem/src/main/res/values/updates.xml
@@ -0,0 +1,3 @@
+
+ Update available
+
diff --git a/core/prefs/README.md b/core/prefs/README.md
index 37e8c45f9..1f38074a7 100644
--- a/core/prefs/README.md
+++ b/core/prefs/README.md
@@ -18,6 +18,7 @@ Owns user preference persistence and migration helpers: DataStore-backed user pr
- `ExploreDefaultTab` / `SubscriptionsDefaultTab` sanitize Appearance **Default tabs** (`for_you`/`top`, `shows`/`new_episodes`) and resolve the pager index when a route does not already pick a tab.
- `SubscriptionsTabStyle` defines and sanitizes Subscriptions tab layout choices: `top` (default) vs `floating` (FAB style).
- `BoxcastPrefs` stores the permanent Home video-showcase dismissal in the canonical `boxlore_prefs` file (`featured_video_showcase_dismissed`). The showcase asks for confirmation before writing it and does not reappear afterward.
+- `Announcement` persists the existing title/body/route/image/action/category plus optional compact/fullscreen presentation, accent/image roles, release/Play/test flags, release code and GitHub release URL. Missing keys keep legacy compact defaults and category-based release detection; clearing removes all fields in one transaction. Rendering and delivery policy belong to app.
- `Context.userPreferencesDataStore` defines the `user_preferences` DataStore delegate.
- `BoxcastPrefs` is the typed facade for `boxlore_prefs` values such as onboarding, genres, recommendation caches, time-of-day rotation slot keys (`cached_byl_slot`), Learn history, learner-log gates, pending magic link authentication email (`pending_auth_email`), sticky feedback drafts (`FeedbackDraft` via `getFeedbackDraft()`, `saveFeedbackDraft()`, and `clearFeedbackDraft()`), the stable sync installation ID (`sync_device_id` via `getOrCreateSyncDeviceId()`), sync cursor state (`sync_last_timestamp`, `sync_last_user_id`, `sync_metadata_version`), and the notification-permission prompt gate (`has_requested_notification_permission`). `clearBylCacheIfPodcastId` invalidates a Because-you-like cache (including its cached slot key) when its seed show adopts a new catalog id.
- `resolveLearnerLogEnabled(isDebugBuild)`: debug defaults on when unset; **release is always off** unless the user explicitly persisted `true` via the debug-screen toggle.
@@ -29,13 +30,19 @@ Owns user preference persistence and migration helpers: DataStore-backed user pr
## Internal structure
+`UserPreferencesRepository` delegates appearance/fast-cache, library ordering and RSS repair, engagement prompts/tips, and announcement storage to same-package helpers. They share the original DataStore; the repository API, nested `Announcement` type, file names, preference keys and defaults remain unchanged.
+
```text
src/main/java/cx/aswin/boxlore/core/prefs/
+ AnnouncementPreferences.kt
+ AppearancePreferences.kt
BoxcastPrefs.kt
DefaultLandingTabs.kt
+ EngagementPreferences.kt
EngagementPromptConstants.kt
FontRoundnessAxis.kt
HomePinnedShows.kt
+ LibraryPreferences.kt
PlaybackSkipBounds.kt
PreferenceIdList.kt
PrefsFileMigrator.kt
@@ -68,6 +75,7 @@ src/main/java/cx/aswin/boxlore/core/prefs/
## Testing notes
+- `AnnouncementPreferencesTest` round-trips rich presentation/release fields and clears them, while retaining legacy defaults and blank-title behavior.
- Unit tests live under `core/prefs/src/test`.
- `ThemeSelectionPreferencesTest` covers artwork-color defaults, saved custom themes, atomic theme selection and fast-cache restoration.
- `BoxcastPrefsTest` covers facade behavior, including targeted Because-you-like cache invalidation, time-of-day rotation slot persistence, permanent featured-video showcase dismissal, and sync state cursors.
@@ -76,7 +84,7 @@ src/main/java/cx/aswin/boxlore/core/prefs/
- `UserPreferencesRepositoryTest` round-trips Manual order and Home pins, including atomic pin toggle, unsubscribe cleanup, and journaled podcast-id replacement.
- Preference tests also verify miniplayer seeks default off, survive repository recreation, and preserve global seek durations when toggled.
- `UserPreferencesRepositoryTest` verifies complete preset persistence/fast-cache restoration, repeated preset selection, later accent customisation and preservation of mode, lettering and navigation.
-- `UserPreferencesRestoreHydrationTest` covers Google Backup restore: appearance streams keep theme fast-cache when DataStore is empty, and `hydrateMissingDataStoreFromFastCache` writes those values into DataStore. Concurrent readers retain fast-cache appearance while application hydration runs in the background, and existing DataStore choices win over missing-key restoration.
+- `UserPreferencesRestoreHydrationTest` covers Google Backup restore: appearance streams keep theme fast-cache when DataStore is empty, and `hydrateMissingDataStoreFromFastCache` writes those values into DataStore. Concurrent readers retain fast-cache appearance while application hydration runs in the background, and existing DataStore choices, including false boolean values, survive repeated missing-key restoration.
- `DefaultLandingTabsTest` covers Explore / Subscriptions default-tab sanitize and pager-index resolution (nav tab and genre win over the preference).
```bash
diff --git a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/AnnouncementPreferences.kt b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/AnnouncementPreferences.kt
new file mode 100644
index 000000000..9d29493a5
--- /dev/null
+++ b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/AnnouncementPreferences.kt
@@ -0,0 +1,128 @@
+package cx.aswin.boxlore.core.prefs
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.emptyPreferences
+import androidx.datastore.preferences.core.longPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import cx.aswin.boxlore.core.prefs.UserPreferencesRepository.Announcement
+import java.io.IOException
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+
+/** Internal storage helper; the repository remains the public entry point. */
+internal class AnnouncementPreferences(
+ private val dataStore: DataStore,
+) {
+ // --- ANNOUNCEMENT PREFERENCES ---
+ private object AnnouncementKeys {
+ val TITLE = stringPreferencesKey("announcement_title")
+ val BODY = stringPreferencesKey("announcement_body")
+ val ROUTE = stringPreferencesKey("announcement_route")
+ val IMAGE_URL = stringPreferencesKey("announcement_image_url")
+ val ACTION_LABEL = stringPreferencesKey("announcement_action_label")
+ val SHOW_ACTION_IN_APP =
+ androidx.datastore.preferences.core
+ .booleanPreferencesKey("announcement_show_action_in_app")
+ val TIMESTAMP =
+ androidx.datastore.preferences.core
+ .longPreferencesKey("announcement_timestamp")
+ val CATEGORY = stringPreferencesKey("announcement_category")
+ val PRESENTATION = stringPreferencesKey("announcement_presentation")
+ val TONE = stringPreferencesKey("announcement_tone")
+ val IMAGE_STYLE = stringPreferencesKey("announcement_image_style")
+ val RELEASE = booleanPreferencesKey("announcement_release")
+ val INCLUDE_PLAY = booleanPreferencesKey("announcement_include_play")
+ val TEST_ONLY = booleanPreferencesKey("announcement_test_only")
+ val RELEASE_CODE = longPreferencesKey("announcement_release_code")
+ val RELEASE_URL = stringPreferencesKey("announcement_release_url")
+ }
+
+ val activeAnnouncementStream: Flow =
+ dataStore.data
+ .catch { if (it is IOException) emit(emptyPreferences()) else throw it }
+ .map { pref ->
+ val title = pref[AnnouncementKeys.TITLE]
+ val body = pref[AnnouncementKeys.BODY]
+ if (!title.isNullOrBlank() && !body.isNullOrBlank()) {
+ Announcement(
+ title = title,
+ body = body,
+ route = pref[AnnouncementKeys.ROUTE],
+ imageUrl = pref[AnnouncementKeys.IMAGE_URL],
+ actionLabel = pref[AnnouncementKeys.ACTION_LABEL],
+ showActionInApp = pref[AnnouncementKeys.SHOW_ACTION_IN_APP] ?: true,
+ timestamp = pref[AnnouncementKeys.TIMESTAMP] ?: 0L,
+ category = pref[AnnouncementKeys.CATEGORY] ?: "WHAT'S NEW",
+ presentation = pref[AnnouncementKeys.PRESENTATION] ?: "compact",
+ tone = pref[AnnouncementKeys.TONE] ?: "primary",
+ imageStyle = pref[AnnouncementKeys.IMAGE_STYLE] ?: "banner",
+ releaseAlert = pref[AnnouncementKeys.RELEASE],
+ includePlay = pref[AnnouncementKeys.INCLUDE_PLAY] ?: false,
+ testOnly = pref[AnnouncementKeys.TEST_ONLY] ?: false,
+ releaseVersionCode = pref[AnnouncementKeys.RELEASE_CODE] ?: 0,
+ releaseUrl = pref[AnnouncementKeys.RELEASE_URL],
+ )
+ } else {
+ null
+ }
+ }.distinctUntilChanged()
+
+ suspend fun setAnnouncement(announcement: Announcement) {
+ dataStore.edit {
+ it[AnnouncementKeys.TITLE] = announcement.title
+ it[AnnouncementKeys.BODY] = announcement.body
+ if (announcement.route != null) it[AnnouncementKeys.ROUTE] = announcement.route else it.remove(AnnouncementKeys.ROUTE)
+ if (announcement.imageUrl !=
+ null
+ ) {
+ it[AnnouncementKeys.IMAGE_URL] = announcement.imageUrl
+ } else {
+ it.remove(AnnouncementKeys.IMAGE_URL)
+ }
+ if (announcement.actionLabel !=
+ null
+ ) {
+ it[AnnouncementKeys.ACTION_LABEL] = announcement.actionLabel
+ } else {
+ it.remove(AnnouncementKeys.ACTION_LABEL)
+ }
+ it[AnnouncementKeys.SHOW_ACTION_IN_APP] = announcement.showActionInApp
+ it[AnnouncementKeys.CATEGORY] = announcement.category
+ it[AnnouncementKeys.TIMESTAMP] = announcement.timestamp
+ it[AnnouncementKeys.PRESENTATION] = announcement.presentation
+ it[AnnouncementKeys.TONE] = announcement.tone
+ it[AnnouncementKeys.IMAGE_STYLE] = announcement.imageStyle
+ announcement.releaseAlert?.let { value -> it[AnnouncementKeys.RELEASE] = value } ?: it.remove(AnnouncementKeys.RELEASE)
+ it[AnnouncementKeys.INCLUDE_PLAY] = announcement.includePlay
+ it[AnnouncementKeys.TEST_ONLY] = announcement.testOnly
+ it[AnnouncementKeys.RELEASE_CODE] = announcement.releaseVersionCode
+ announcement.releaseUrl?.let { url -> it[AnnouncementKeys.RELEASE_URL] = url } ?: it.remove(AnnouncementKeys.RELEASE_URL)
+ }
+ }
+
+ suspend fun clearAnnouncement() {
+ dataStore.edit { pref ->
+ pref.remove(AnnouncementKeys.TITLE)
+ pref.remove(AnnouncementKeys.BODY)
+ pref.remove(AnnouncementKeys.ROUTE)
+ pref.remove(AnnouncementKeys.IMAGE_URL)
+ pref.remove(AnnouncementKeys.ACTION_LABEL)
+ pref.remove(AnnouncementKeys.SHOW_ACTION_IN_APP)
+ pref.remove(AnnouncementKeys.CATEGORY)
+ pref.remove(AnnouncementKeys.TIMESTAMP)
+ pref.remove(AnnouncementKeys.PRESENTATION)
+ pref.remove(AnnouncementKeys.TONE)
+ pref.remove(AnnouncementKeys.IMAGE_STYLE)
+ pref.remove(AnnouncementKeys.RELEASE)
+ pref.remove(AnnouncementKeys.INCLUDE_PLAY)
+ pref.remove(AnnouncementKeys.TEST_ONLY)
+ pref.remove(AnnouncementKeys.RELEASE_CODE)
+ pref.remove(AnnouncementKeys.RELEASE_URL)
+ }
+ }
+}
diff --git a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/AppearancePreferences.kt b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/AppearancePreferences.kt
new file mode 100644
index 000000000..cf8fee1ef
--- /dev/null
+++ b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/AppearancePreferences.kt
@@ -0,0 +1,391 @@
+package cx.aswin.boxlore.core.prefs
+
+import android.content.SharedPreferences
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.emptyPreferences
+import java.io.IOException
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+
+/** Internal storage helper; the repository remains the public entry point. */
+internal class AppearancePreferences(
+ private val dataStore: DataStore,
+ private val syncPrefs: SharedPreferences,
+) {
+ val cachedThemeConfig: String
+ get() = syncPrefs.getString("theme_config", null) ?: "system"
+
+ val cachedSurfaceStyle: String
+ get() = syncPrefs.getString("surface_style", null) ?: "classic_dynamic"
+
+ /** Lettering roundness preset key: `crisp` | `soft` | `round` (default). */
+ val cachedFontRoundness: String
+ get() = FontRoundnessAxis.sanitizeKey(syncPrefs.getString(FontRoundnessAxis.PREF_KEY, null))
+
+ val cachedNavigationStyle: String
+ get() = sanitizeNavigationStyle(syncPrefs.getString("navigation_style", null))
+
+ /** Cold-start destination: `home` | `subscriptions` | `downloads` (default home). */
+ val cachedOpenAppTo: String
+ get() = sanitizeOpenAppTo(syncPrefs.getString("open_app_to", null))
+
+ /** Explore landing tab: `for_you` (default) or `top`. */
+ val cachedExploreDefaultTab: String
+ get() = ExploreDefaultTab.sanitize(syncPrefs.getString(ExploreDefaultTab.PREF_KEY, null))
+
+ /** Subscriptions landing tab: `shows` (default) or `new_episodes`. */
+ val cachedSubscriptionsDefaultTab: String
+ get() = SubscriptionsDefaultTab.sanitize(syncPrefs.getString(SubscriptionsDefaultTab.PREF_KEY, null))
+
+ /** Subscriptions tab layout: `top` (default) or `floating`. */
+ val cachedSubscriptionsTabStyle: String
+ get() = SubscriptionsTabStyle.sanitize(syncPrefs.getString(SubscriptionsTabStyle.PREF_KEY, null))
+
+ val cachedThemeBrand: String
+ get() = syncPrefs.getString("theme_brand", null) ?: "violet"
+
+ val cachedUseDynamicColor: Boolean
+ get() = syncPrefs.getBoolean("use_dynamic_color", false)
+
+ val cachedArtworkColorsEnabled: Boolean
+ get() = syncPrefs.getBoolean("artwork_colors_enabled", true)
+
+ val cachedCustomTheme: ThemeSelection?
+ get() = syncPrefs.getString("custom_theme_brand", null)?.let { brand ->
+ ThemeSelection(brand, syncPrefs.getString("custom_theme_surface", "standard") ?: "standard", syncPrefs.getBoolean("custom_theme_dynamic", false))
+ }
+
+ val artworkColorsEnabledStream: Flow = dataStore.data.catch { exception ->
+ if (exception is IOException) emit(emptyPreferences()) else throw exception
+ }.map { preferences ->
+ preferences[Keys.ARTWORK_COLORS]?.also { syncPrefs.edit().putBoolean("artwork_colors_enabled", it).apply() } ?: cachedArtworkColorsEnabled
+ }.distinctUntilChanged()
+
+ suspend fun setArtworkColorsEnabled(enabled: Boolean) {
+ syncPrefs.edit().putBoolean("artwork_colors_enabled", enabled).apply()
+ dataStore.edit { it[Keys.ARTWORK_COLORS] = enabled }
+ }
+
+ val customThemeStream: Flow = dataStore.data.catch { exception ->
+ if (exception is IOException) emit(emptyPreferences()) else throw exception
+ }.map { preferences ->
+ preferences[Keys.CUSTOM_THEME_BRAND]?.let { brand ->
+ ThemeSelection(brand, preferences[Keys.CUSTOM_THEME_SURFACE] ?: "standard", preferences[Keys.CUSTOM_THEME_DYNAMIC] ?: false).also {
+ syncPrefs.edit().putString("custom_theme_brand", it.brand).putString("custom_theme_surface", it.surfaceStyle).putBoolean("custom_theme_dynamic", it.wallpaperColors).apply()
+ }
+ } ?: cachedCustomTheme
+ }.distinctUntilChanged()
+
+ /** Apply all color inputs together, optionally remembering the current or newly created Custom theme. */
+ suspend fun setThemeSelection(selection: ThemeSelection, customThemeToRemember: ThemeSelection? = null) {
+ val cache = syncPrefs.edit().putString("theme_brand", selection.brand)
+ .putString("surface_style", selection.surfaceStyle).putBoolean("use_dynamic_color", selection.wallpaperColors)
+ customThemeToRemember?.let {
+ cache.putString("custom_theme_brand", it.brand).putString("custom_theme_surface", it.surfaceStyle)
+ .putBoolean("custom_theme_dynamic", it.wallpaperColors)
+ }
+ cache.apply()
+ dataStore.edit { preferences ->
+ preferences[Keys.THEME_BRAND] = selection.brand
+ preferences[Keys.SURFACE_STYLE] = selection.surfaceStyle
+ preferences[Keys.USE_DYNAMIC_COLOR] = selection.wallpaperColors
+ customThemeToRemember?.let {
+ preferences[Keys.CUSTOM_THEME_BRAND] = it.brand
+ preferences[Keys.CUSTOM_THEME_SURFACE] = it.surfaceStyle
+ preferences[Keys.CUSTOM_THEME_DYNAMIC] = it.wallpaperColors
+ }
+ }
+ }
+
+ /** Widget chrome: `app` (default, match Appearance) or `system` (launcher Material You). */
+ val cachedWidgetAppearance: String
+ get() = WidgetAppearance.sanitize(syncPrefs.getString(WidgetAppearance.PREF_KEY, null))
+
+ // THEME PREFERENCES
+ val themeConfigStream: Flow =
+ dataStore.data
+ .catch { exception ->
+ if (exception is IOException) emit(emptyPreferences()) else throw exception
+ }.map { preferences ->
+ val stored = preferences[Keys.THEME_CONFIG]
+ if (stored != null) {
+ syncPrefs.edit().putString("theme_config", stored).apply()
+ stored
+ } else {
+ cachedThemeConfig
+ }
+ }.distinctUntilChanged()
+
+ suspend fun setThemeConfig(themeConfig: String) {
+ syncPrefs.edit().putString("theme_config", themeConfig).apply()
+ dataStore.edit { preferences ->
+ preferences[Keys.THEME_CONFIG] = themeConfig
+ }
+ }
+
+ val useDynamicColorStream: Flow =
+ dataStore.data
+ .catch { exception ->
+ if (exception is IOException) emit(emptyPreferences()) else throw exception
+ }.map { preferences ->
+ val stored = preferences[Keys.USE_DYNAMIC_COLOR]
+ if (stored != null) {
+ syncPrefs.edit().putBoolean("use_dynamic_color", stored).apply()
+ stored
+ } else {
+ cachedUseDynamicColor
+ }
+ }.distinctUntilChanged()
+
+ suspend fun setUseDynamicColor(useDynamicColor: Boolean) {
+ syncPrefs.edit().putBoolean("use_dynamic_color", useDynamicColor).apply()
+ dataStore.edit { preferences ->
+ preferences[Keys.USE_DYNAMIC_COLOR] = useDynamicColor
+ }
+ }
+
+ val themeBrandStream: Flow =
+ dataStore.data
+ .catch { exception ->
+ if (exception is IOException) emit(emptyPreferences()) else throw exception
+ }.map { preferences ->
+ val stored = preferences[Keys.THEME_BRAND]
+ if (stored != null) {
+ syncPrefs.edit().putString("theme_brand", stored).apply()
+ stored
+ } else {
+ cachedThemeBrand
+ }
+ }.distinctUntilChanged()
+
+ suspend fun setThemeBrand(themeBrand: String) {
+ syncPrefs.edit().putString("theme_brand", themeBrand).apply()
+ dataStore.edit { preferences ->
+ preferences[Keys.THEME_BRAND] = themeBrand
+ }
+ }
+
+ val surfaceStyleStream: Flow =
+ dataStore.data
+ .catch { exception ->
+ if (exception is IOException) emit(emptyPreferences()) else throw exception
+ }.map { preferences ->
+ val stored = preferences[Keys.SURFACE_STYLE]
+ if (stored != null) {
+ syncPrefs.edit().putString("surface_style", stored).apply()
+ stored
+ } else {
+ cachedSurfaceStyle
+ }
+ }.distinctUntilChanged()
+
+ suspend fun setSurfaceStyle(surfaceStyle: String) {
+ syncPrefs.edit().putString("surface_style", surfaceStyle).apply()
+ dataStore.edit { preferences ->
+ preferences[Keys.SURFACE_STYLE] = surfaceStyle
+ }
+ }
+
+ val fontRoundnessStream: Flow =
+ dataStore.data
+ .catch { exception ->
+ if (exception is IOException) emit(emptyPreferences()) else throw exception
+ }.map { preferences ->
+ val stored = preferences[Keys.FONT_ROUNDNESS]
+ if (stored != null) {
+ val roundness = FontRoundnessAxis.sanitizeKey(stored)
+ syncPrefs.edit().putString(FontRoundnessAxis.PREF_KEY, roundness).apply()
+ roundness
+ } else {
+ cachedFontRoundness
+ }
+ }.distinctUntilChanged()
+
+ suspend fun setFontRoundness(fontRoundness: String) {
+ val sanitized = FontRoundnessAxis.sanitizeKey(fontRoundness)
+ syncPrefs.edit().putString(FontRoundnessAxis.PREF_KEY, sanitized).apply()
+ dataStore.edit { preferences ->
+ preferences[Keys.FONT_ROUNDNESS] = sanitized
+ }
+ }
+
+ /** Navigation presentation key: `floating` (default) or `classic`. */
+ val navigationStyleStream: Flow =
+ dataStore.data
+ .catch { exception ->
+ if (exception is IOException) emit(emptyPreferences()) else throw exception
+ }.map { preferences ->
+ val stored = preferences[Keys.NAVIGATION_STYLE]
+ if (stored != null) {
+ val navigationStyle = sanitizeNavigationStyle(stored)
+ syncPrefs.edit().putString("navigation_style", navigationStyle).apply()
+ navigationStyle
+ } else {
+ cachedNavigationStyle
+ }
+ }.distinctUntilChanged()
+
+ suspend fun setNavigationStyle(navigationStyle: String) {
+ val sanitized = sanitizeNavigationStyle(navigationStyle)
+ syncPrefs.edit().putString("navigation_style", sanitized).apply()
+ dataStore.edit { preferences ->
+ preferences[Keys.NAVIGATION_STYLE] = sanitized
+ }
+ }
+
+ /** Cold-start landing: `home` (default) or `subscriptions`. Mirrored in theme fast-cache for launch. */
+ val openAppToStream: Flow =
+ dataStore.data
+ .catch { exception ->
+ if (exception is IOException) emit(emptyPreferences()) else throw exception
+ }.map { preferences ->
+ val stored = preferences[Keys.OPEN_APP_TO]
+ if (stored != null) {
+ val openAppTo = sanitizeOpenAppTo(stored)
+ syncPrefs.edit().putString("open_app_to", openAppTo).apply()
+ openAppTo
+ } else {
+ cachedOpenAppTo
+ }
+ }.distinctUntilChanged()
+
+ suspend fun setOpenAppTo(openAppTo: String) {
+ val sanitized = sanitizeOpenAppTo(openAppTo)
+ syncPrefs.edit().putString("open_app_to", sanitized).apply()
+ dataStore.edit { preferences ->
+ preferences[Keys.OPEN_APP_TO] = sanitized
+ }
+ }
+
+ val exploreDefaultTabStream: Flow =
+ dataStore.data
+ .catch { exception ->
+ if (exception is IOException) emit(emptyPreferences()) else throw exception
+ }.map { preferences ->
+ val stored = preferences[Keys.EXPLORE_DEFAULT_TAB]
+ if (stored != null) {
+ val tab = ExploreDefaultTab.sanitize(stored)
+ syncPrefs.edit().putString(ExploreDefaultTab.PREF_KEY, tab).apply()
+ tab
+ } else {
+ cachedExploreDefaultTab
+ }
+ }.distinctUntilChanged()
+
+ suspend fun setExploreDefaultTab(tab: String) {
+ val sanitized = ExploreDefaultTab.sanitize(tab)
+ syncPrefs.edit().putString(ExploreDefaultTab.PREF_KEY, sanitized).apply()
+ dataStore.edit { preferences ->
+ preferences[Keys.EXPLORE_DEFAULT_TAB] = sanitized
+ }
+ }
+
+ val subscriptionsDefaultTabStream: Flow =
+ dataStore.data
+ .catch { exception ->
+ if (exception is IOException) emit(emptyPreferences()) else throw exception
+ }.map { preferences ->
+ val stored = preferences[Keys.SUBSCRIPTIONS_DEFAULT_TAB]
+ if (stored != null) {
+ val tab = SubscriptionsDefaultTab.sanitize(stored)
+ syncPrefs.edit().putString(SubscriptionsDefaultTab.PREF_KEY, tab).apply()
+ tab
+ } else {
+ cachedSubscriptionsDefaultTab
+ }
+ }.distinctUntilChanged()
+
+ suspend fun setSubscriptionsDefaultTab(tab: String) {
+ val sanitized = SubscriptionsDefaultTab.sanitize(tab)
+ syncPrefs.edit().putString(SubscriptionsDefaultTab.PREF_KEY, sanitized).apply()
+ dataStore.edit { preferences ->
+ preferences[Keys.SUBSCRIPTIONS_DEFAULT_TAB] = sanitized
+ }
+ }
+
+ val subscriptionsTabStyleStream: Flow =
+ dataStore.data
+ .catch { exception ->
+ if (exception is IOException) emit(emptyPreferences()) else throw exception
+ }.map { preferences ->
+ val stored = preferences[Keys.SUBSCRIPTIONS_TAB_STYLE]
+ if (stored != null) {
+ val style = SubscriptionsTabStyle.sanitize(stored)
+ syncPrefs.edit().putString(SubscriptionsTabStyle.PREF_KEY, style).apply()
+ style
+ } else {
+ cachedSubscriptionsTabStyle
+ }
+ }.distinctUntilChanged()
+
+ suspend fun setSubscriptionsTabStyle(style: String) {
+ val sanitized = SubscriptionsTabStyle.sanitize(style)
+ syncPrefs.edit().putString(SubscriptionsTabStyle.PREF_KEY, sanitized).apply()
+ dataStore.edit { preferences ->
+ preferences[Keys.SUBSCRIPTIONS_TAB_STYLE] = sanitized
+ }
+ }
+
+ /**
+ * After Google Backup, SharedPreferences fast-cache can restore while DataStore does not.
+ * Copy cache values into missing DataStore keys so UI streams and workers stay aligned.
+ */
+ suspend fun hydrateMissingDataStoreFromFastCache() {
+ dataStore.edit { preferences ->
+ hydrateArtworkThemePreferences(preferences)
+ preferences[Keys.THEME_CONFIG] = preferences[Keys.THEME_CONFIG] ?: cachedThemeConfig
+ preferences[Keys.USE_DYNAMIC_COLOR] = preferences[Keys.USE_DYNAMIC_COLOR] ?: cachedUseDynamicColor
+ preferences[Keys.THEME_BRAND] = preferences[Keys.THEME_BRAND] ?: cachedThemeBrand
+ preferences[Keys.SURFACE_STYLE] = preferences[Keys.SURFACE_STYLE] ?: cachedSurfaceStyle
+ preferences[Keys.FONT_ROUNDNESS] = preferences[Keys.FONT_ROUNDNESS] ?: cachedFontRoundness
+ preferences[Keys.NAVIGATION_STYLE] = preferences[Keys.NAVIGATION_STYLE] ?: cachedNavigationStyle
+ preferences[Keys.OPEN_APP_TO] = preferences[Keys.OPEN_APP_TO] ?: cachedOpenAppTo
+ preferences[Keys.EXPLORE_DEFAULT_TAB] = preferences[Keys.EXPLORE_DEFAULT_TAB] ?: cachedExploreDefaultTab
+ preferences[Keys.SUBSCRIPTIONS_DEFAULT_TAB] = preferences[Keys.SUBSCRIPTIONS_DEFAULT_TAB] ?: cachedSubscriptionsDefaultTab
+ preferences[Keys.SUBSCRIPTIONS_TAB_STYLE] = preferences[Keys.SUBSCRIPTIONS_TAB_STYLE] ?: cachedSubscriptionsTabStyle
+ preferences[Keys.WIDGET_APPEARANCE] = preferences[Keys.WIDGET_APPEARANCE] ?: cachedWidgetAppearance
+ }
+ }
+
+ /**
+ * Widget chrome source: [WidgetAppearance.APP] (default) or [WidgetAppearance.SYSTEM].
+ * Mirrored in theme fast-cache so RemoteViews can read it without DataStore.
+ */
+ val widgetAppearanceStream: Flow =
+ dataStore.data
+ .catch { exception ->
+ if (exception is IOException) emit(emptyPreferences()) else throw exception
+ }.map { preferences ->
+ val stored = preferences[Keys.WIDGET_APPEARANCE]
+ if (stored != null) {
+ val appearance = WidgetAppearance.sanitize(stored)
+ syncPrefs.edit().putString(WidgetAppearance.PREF_KEY, appearance).apply()
+ appearance
+ } else {
+ cachedWidgetAppearance
+ }
+ }.distinctUntilChanged()
+
+ suspend fun setWidgetAppearance(appearance: String) {
+ val sanitized = WidgetAppearance.sanitize(appearance)
+ syncPrefs.edit().putString(WidgetAppearance.PREF_KEY, sanitized).apply()
+ dataStore.edit { preferences ->
+ preferences[Keys.WIDGET_APPEARANCE] = sanitized
+ }
+ }
+}
+
+private fun AppearancePreferences.hydrateArtworkThemePreferences(preferences: androidx.datastore.preferences.core.MutablePreferences) {
+ if (preferences[Keys.ARTWORK_COLORS] == null) preferences[Keys.ARTWORK_COLORS] = cachedArtworkColorsEnabled
+ cachedCustomTheme?.let { custom ->
+ if (preferences[Keys.CUSTOM_THEME_BRAND] == null) {
+ preferences[Keys.CUSTOM_THEME_BRAND] = custom.brand
+ preferences[Keys.CUSTOM_THEME_SURFACE] = custom.surfaceStyle
+ preferences[Keys.CUSTOM_THEME_DYNAMIC] = custom.wallpaperColors
+ }
+ }
+}
diff --git a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/EngagementPreferences.kt b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/EngagementPreferences.kt
new file mode 100644
index 000000000..604c904e6
--- /dev/null
+++ b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/EngagementPreferences.kt
@@ -0,0 +1,305 @@
+package cx.aswin.boxlore.core.prefs
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.emptyPreferences
+import androidx.datastore.preferences.core.stringPreferencesKey
+import java.io.IOException
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+
+private object TooltipKeys {
+ val HAS_SEEN_SWIPE_DISMISS_TIP =
+ androidx.datastore.preferences.core
+ .booleanPreferencesKey("has_seen_swipe_dismiss_tip")
+ val HAS_SEEN_TITLE_TAP_TIP =
+ androidx.datastore.preferences.core
+ .booleanPreferencesKey("has_seen_title_tap_tip")
+ val HAS_SEEN_SWIPE_MINIMIZE_TIP =
+ androidx.datastore.preferences.core
+ .booleanPreferencesKey("has_seen_swipe_minimize_tip")
+ val HAS_SEEN_MARK_PLAYED_TIP =
+ androidx.datastore.preferences.core
+ .booleanPreferencesKey("has_seen_mark_played_tip")
+ val HAS_SEEN_LISTENING_HISTORY_TRACKING_NOTICE =
+ androidx.datastore.preferences.core
+ .booleanPreferencesKey("has_seen_listening_history_tracking_notice")
+}
+
+private object AnalyticsKeys {
+ val HAS_LOGGED_FIRST_PLAY =
+ androidx.datastore.preferences.core
+ .booleanPreferencesKey("has_logged_first_play")
+ val REVIEW_LAST_PROMPT_AT =
+ androidx.datastore.preferences.core
+ .longPreferencesKey("review_last_prompt_at")
+ val REVIEW_PROMPT_COUNT =
+ androidx.datastore.preferences.core
+ .intPreferencesKey("review_prompt_count")
+ val REVIEW_HAS_REVIEWED =
+ androidx.datastore.preferences.core
+ .booleanPreferencesKey("review_has_reviewed")
+ val REVIEW_FIRST_LAUNCH_AT =
+ androidx.datastore.preferences.core
+ .longPreferencesKey("review_first_launch_at")
+
+ // NPS survey: milestone marks eligibility (pending); the event fires on
+ // the next app open so it never surfaces during background playback.
+ val NPS_SURVEY_PENDING =
+ androidx.datastore.preferences.core
+ .booleanPreferencesKey("nps_survey_pending")
+ val NPS_SURVEY_FIRED =
+ androidx.datastore.preferences.core
+ .booleanPreferencesKey("nps_survey_fired")
+ val NPS_SURVEY_COMPLETED_COUNT =
+ androidx.datastore.preferences.core
+ .intPreferencesKey("nps_survey_completed_count")
+ val ENGAGEMENT_LAST_PROMPT_AT =
+ androidx.datastore.preferences.core
+ .longPreferencesKey("engagement_last_prompt_at")
+ val NPS_LAST_SCORE =
+ androidx.datastore.preferences.core
+ .intPreferencesKey("nps_last_score")
+ val PROMOTER_REVIEW_PENDING =
+ androidx.datastore.preferences.core
+ .booleanPreferencesKey("promoter_review_pending")
+ val REVIEW_MILESTONE_PENDING =
+ androidx.datastore.preferences.core
+ .intPreferencesKey("review_milestone_pending")
+}
+
+private object FeatureKeys {
+ val DISMISSED_FEATURE_VERSION = stringPreferencesKey("dismissed_feature_version")
+}
+
+internal class TooltipPreferences(private val dataStore: DataStore) {
+ val hasSeenSwipeDismissTip: Flow =
+ dataStore.data
+ .catch { if (it is IOException) emit(emptyPreferences()) else throw it }
+ .map { it[TooltipKeys.HAS_SEEN_SWIPE_DISMISS_TIP] ?: false }
+ .distinctUntilChanged()
+
+ val hasSeenTitleTapTip: Flow =
+ dataStore.data
+ .catch { if (it is IOException) emit(emptyPreferences()) else throw it }
+ .map { it[TooltipKeys.HAS_SEEN_TITLE_TAP_TIP] ?: false }
+ .distinctUntilChanged()
+
+ val hasSeenSwipeMinimizeTip: Flow =
+ dataStore.data
+ .catch { if (it is IOException) emit(emptyPreferences()) else throw it }
+ .map { it[TooltipKeys.HAS_SEEN_SWIPE_MINIMIZE_TIP] ?: false }
+ .distinctUntilChanged()
+
+ suspend fun markSwipeDismissTipSeen() {
+ dataStore.edit { it[TooltipKeys.HAS_SEEN_SWIPE_DISMISS_TIP] = true }
+ }
+
+ suspend fun markTitleTapTipSeen() {
+ dataStore.edit { it[TooltipKeys.HAS_SEEN_TITLE_TAP_TIP] = true }
+ }
+
+ suspend fun markSwipeMinimizeTipSeen() {
+ dataStore.edit { it[TooltipKeys.HAS_SEEN_SWIPE_MINIMIZE_TIP] = true }
+ }
+
+ val hasSeenMarkPlayedTip: Flow =
+ dataStore.data
+ .catch { if (it is IOException) emit(emptyPreferences()) else throw it }
+ .map { it[TooltipKeys.HAS_SEEN_MARK_PLAYED_TIP] ?: false }
+ .distinctUntilChanged()
+
+ suspend fun markMarkPlayedTipSeen() {
+ dataStore.edit { it[TooltipKeys.HAS_SEEN_MARK_PLAYED_TIP] = true }
+ }
+
+ val hasSeenListeningHistoryTrackingNotice: Flow =
+ dataStore.data
+ .catch { if (it is IOException) emit(emptyPreferences()) else throw it }
+ .map { it[TooltipKeys.HAS_SEEN_LISTENING_HISTORY_TRACKING_NOTICE] ?: false }
+ .distinctUntilChanged()
+
+ suspend fun markListeningHistoryTrackingNoticeSeen() {
+ dataStore.edit { it[TooltipKeys.HAS_SEEN_LISTENING_HISTORY_TRACKING_NOTICE] = true }
+ }
+}
+
+internal class ReviewPromptPreferences(private val dataStore: DataStore) {
+ // --- APP REVIEW LOGIC ---
+ val reviewHasReviewed: Flow =
+ dataStore.data
+ .catch { if (it is IOException) emit(emptyPreferences()) else throw it }
+ .map { it[AnalyticsKeys.REVIEW_HAS_REVIEWED] ?: false }
+ .distinctUntilChanged()
+
+ suspend fun markReviewed() {
+ dataStore.edit { it[AnalyticsKeys.REVIEW_HAS_REVIEWED] = true }
+ }
+
+ suspend fun markReviewPromptShown() {
+ dataStore.edit { pref ->
+ val count = pref[AnalyticsKeys.REVIEW_PROMPT_COUNT] ?: 0
+ pref[AnalyticsKeys.REVIEW_PROMPT_COUNT] = count + 1
+ pref[AnalyticsKeys.REVIEW_LAST_PROMPT_AT] = System.currentTimeMillis()
+ pref[AnalyticsKeys.ENGAGEMENT_LAST_PROMPT_AT] = System.currentTimeMillis()
+ pref.remove(AnalyticsKeys.REVIEW_MILESTONE_PENDING)
+ }
+ }
+
+ /**
+ * Rules to show milestone Play review:
+ * - A milestone (5/15/30) was reached and stored as pending (survives playback gaps)
+ * - NPS survey already fired; skip detractors (score <= 7)
+ * - Shared 14-day engagement cooldown
+ * - User has NOT reviewed yet; app installed 2+ days; max 3 lifetime; 30-day review gap
+ * - Never during playback
+ */
+ suspend fun shouldShowReviewPrompt(isPlaying: Boolean): Boolean {
+ if (isPlaying) return false
+
+ val prefs = dataStore.data.first()
+ if (!hasEligibleReviewMilestone(prefs)) return false
+
+ if (!isEngagementCooldownElapsed(prefs)) return false
+
+ val promptCount = prefs[AnalyticsKeys.REVIEW_PROMPT_COUNT] ?: 0
+ if (promptCount >= 3) return false
+
+ val firstLaunch = prefs[AnalyticsKeys.REVIEW_FIRST_LAUNCH_AT]
+ if (firstLaunch == null) {
+ dataStore.edit { it[AnalyticsKeys.REVIEW_FIRST_LAUNCH_AT] = System.currentTimeMillis() }
+ return false
+ }
+
+ val daysSinceInstall = (System.currentTimeMillis() - firstLaunch) / (1000 * 60 * 60 * 24)
+ if (daysSinceInstall < 2) return false
+
+ val lastPrompt = prefs[AnalyticsKeys.REVIEW_LAST_PROMPT_AT] ?: 0L
+ val daysSinceLastPrompt = (System.currentTimeMillis() - lastPrompt) / (1000 * 60 * 60 * 24)
+ return lastPrompt == 0L || daysSinceLastPrompt >= 30
+ }
+
+ /** Remember the highest unreached milestone so prompts survive playback gaps. */
+ suspend fun syncReviewMilestonePending(completedCount: Int) {
+ val milestone =
+ when {
+ completedCount >= 30 -> 30
+ completedCount >= 15 -> 15
+ completedCount >= 5 -> 5
+ else -> return
+ }
+ dataStore.edit { pref ->
+ if (pref[AnalyticsKeys.REVIEW_HAS_REVIEWED] == true) return@edit
+ val current = pref[AnalyticsKeys.REVIEW_MILESTONE_PENDING]
+ if (current == null || milestone > current) {
+ pref[AnalyticsKeys.REVIEW_MILESTONE_PENDING] = milestone
+ }
+ }
+ }
+
+ suspend fun reviewMilestonePending(): Int? = dataStore.data.first()[AnalyticsKeys.REVIEW_MILESTONE_PENDING]
+
+ /** Clears a stored milestone after the review prompt is shown or dismissed. */
+ suspend fun clearReviewMilestonePending() {
+ dataStore.edit { it.remove(AnalyticsKeys.REVIEW_MILESTONE_PENDING) }
+ }
+
+ /** Synchronous read of whether the user has completed the Play Store review flow. */
+ suspend fun hasReviewedSync(): Boolean = dataStore.data.first()[AnalyticsKeys.REVIEW_HAS_REVIEWED] ?: false
+
+ private fun hasEligibleReviewMilestone(prefs: Preferences): Boolean {
+ val milestone = prefs[AnalyticsKeys.REVIEW_MILESTONE_PENDING]
+ val score = prefs[AnalyticsKeys.NPS_LAST_SCORE]
+ return milestone in setOf(5, 15, 30) &&
+ prefs[AnalyticsKeys.REVIEW_HAS_REVIEWED] != true &&
+ prefs[AnalyticsKeys.NPS_SURVEY_FIRED] == true &&
+ (score == null || score > EngagementPromptConstants.DETRACTOR_SCORE_MAX)
+ }
+}
+
+internal class EngagementPreferences(private val dataStore: DataStore) {
+ val hasLoggedFirstPlay: Flow =
+ dataStore.data
+ .catch { if (it is IOException) emit(emptyPreferences()) else throw it }
+ .map { it[AnalyticsKeys.HAS_LOGGED_FIRST_PLAY] ?: false }
+ .distinctUntilChanged()
+
+ suspend fun markFirstPlayLogged() {
+ dataStore.edit { it[AnalyticsKeys.HAS_LOGGED_FIRST_PLAY] = true }
+ }
+
+ // --- FEATURE ANNOUNCEMENT (version-specific one-time dialog) ---
+ val dismissedFeatureVersion: Flow =
+ dataStore.data
+ .catch { if (it is IOException) emit(emptyPreferences()) else throw it }
+ .map { it[FeatureKeys.DISMISSED_FEATURE_VERSION] ?: "" }
+ .distinctUntilChanged()
+
+ suspend fun dismissFeatureAnnouncement(version: String) {
+ dataStore.edit { it[FeatureKeys.DISMISSED_FEATURE_VERSION] = version }
+ }
+ suspend fun recordEngagementPromptShown() {
+ dataStore.edit { pref ->
+ pref[AnalyticsKeys.ENGAGEMENT_LAST_PROMPT_AT] = System.currentTimeMillis()
+ }
+ }
+
+ /** True when at least [EngagementPromptConstants.ENGAGEMENT_COOLDOWN_DAYS] have passed since the last prompt. */
+ suspend fun isEngagementCooldownElapsed(): Boolean = isEngagementCooldownElapsed(dataStore.data.first())
+
+ /** Persists the most recent NPS score for milestone gating and promoter handoff. */
+ suspend fun setNpsLastScore(score: Int) {
+ dataStore.edit { it[AnalyticsKeys.NPS_LAST_SCORE] = score }
+ }
+
+ suspend fun npsLastScore(): Int? = dataStore.data.first()[AnalyticsKeys.NPS_LAST_SCORE]
+
+ /** Sets whether a promoter Play review should show on the next eligible app open. */
+ suspend fun setPromoterReviewPending(pending: Boolean) {
+ dataStore.edit { it[AnalyticsKeys.PROMOTER_REVIEW_PENDING] = pending }
+ }
+
+ suspend fun isPromoterReviewPending(): Boolean = dataStore.data.first()[AnalyticsKeys.PROMOTER_REVIEW_PENDING] ?: false
+
+ // --- NPS SURVEY (PostHog) TRIGGER STATE ---
+ // The eligibility milestone (e.g. 3rd completed episode) can be reached
+ // while playback runs in the background. Rather than fire immediately, we
+ // mark the survey "pending" and let MainActivity fire the trigger event on
+ // the next app open. Firing happens at most once (guarded by the fired flag).
+
+ /** Mark the NPS survey pending (no-op if it has already fired). */
+ suspend fun markNpsSurveyPending(completedCount: Int) {
+ dataStore.edit { pref ->
+ if (pref[AnalyticsKeys.NPS_SURVEY_FIRED] == true) return@edit
+ pref[AnalyticsKeys.NPS_SURVEY_PENDING] = true
+ pref[AnalyticsKeys.NPS_SURVEY_COMPLETED_COUNT] = completedCount
+ }
+ }
+
+ suspend fun isNpsSurveyPending(): Boolean = dataStore.data.first()[AnalyticsKeys.NPS_SURVEY_PENDING] ?: false
+
+ /** Whether the NPS trigger event has already fired for this install. */
+ suspend fun hasNpsSurveyFired(): Boolean = dataStore.data.first()[AnalyticsKeys.NPS_SURVEY_FIRED] ?: false
+
+ /** Completed-episode count captured when the survey became pending. */
+ suspend fun npsSurveyCompletedCount(): Int? = dataStore.data.first()[AnalyticsKeys.NPS_SURVEY_COMPLETED_COUNT]
+
+ /** Mark the NPS survey as fired and clear the pending flag. */
+ suspend fun markNpsSurveyFired() {
+ dataStore.edit { pref ->
+ pref[AnalyticsKeys.NPS_SURVEY_FIRED] = true
+ pref[AnalyticsKeys.NPS_SURVEY_PENDING] = false
+ }
+ }
+}
+
+private fun isEngagementCooldownElapsed(pref: Preferences): Boolean {
+ val last = pref[AnalyticsKeys.ENGAGEMENT_LAST_PROMPT_AT] ?: 0L
+ if (last == 0L) return true
+ val days = (System.currentTimeMillis() - last) / (1000 * 60 * 60 * 24)
+ return days >= EngagementPromptConstants.ENGAGEMENT_COOLDOWN_DAYS
+}
diff --git a/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/LibraryPreferences.kt b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/LibraryPreferences.kt
new file mode 100644
index 000000000..87a1f6aab
--- /dev/null
+++ b/core/prefs/src/main/java/cx/aswin/boxlore/core/prefs/LibraryPreferences.kt
@@ -0,0 +1,264 @@
+package cx.aswin.boxlore.core.prefs
+
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.emptyPreferences
+import androidx.datastore.preferences.core.stringPreferencesKey
+import java.io.IOException
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+
+/** Internal storage helper; the repository remains the public entry point. */
+internal class LibraryPreferences(
+ private val dataStore: DataStore,
+) {
+ private companion object {
+ const val LAST_SEEN_EPISODE_ID_PREFIX = "last_seen_episode_id_"
+ }
+
+ // SORTING PREFERENCES
+ suspend fun setSort(key: Preferences.Key, sort: String) {
+ dataStore.edit { it[key] = sort }
+ }
+
+ val subscriptionSortStream: Flow =
+ dataStore.data
+ .catch { exception ->
+ if (exception is IOException) emit(emptyPreferences()) else throw exception
+ }.map { preferences ->
+ preferences[Keys.SUBSCRIPTION_SORT] ?: "SmartRank"
+ }.distinctUntilChanged()
+
+ val subscriptionFolderSortStream: Flow =
+ dataStore.data
+ .catch { exception ->
+ if (exception is IOException) emit(emptyPreferences()) else throw exception
+ }.map { preferences ->
+ preferences[Keys.SUBSCRIPTION_FOLDER_SORT] ?: "Inherit"
+ }.distinctUntilChanged()
+
+ val subscriptionIntraFolderSortStream: Flow =
+ dataStore.data
+ .catch { exception ->
+ if (exception is IOException) emit(emptyPreferences()) else throw exception
+ }.map { preferences ->
+ preferences[Keys.SUBSCRIPTION_INTRA_FOLDER_SORT] ?: "Inherit"
+ }.distinctUntilChanged()
+
+ val subscriptionManualOrderStream: Flow> =
+ dataStore.data
+ .catch { exception ->
+ if (exception is IOException) emit(emptyPreferences()) else throw exception
+ }.map { preferences ->
+ PreferenceIdList.decode(preferences[Keys.SUBSCRIPTION_MANUAL_ORDER])
+ }.distinctUntilChanged()
+
+ suspend fun setSubscriptionManualOrder(ids: List) {
+ dataStore.edit { preferences ->
+ preferences[Keys.SUBSCRIPTION_MANUAL_ORDER] = PreferenceIdList.encode(ids)
+ }
+ }
+
+ val subscriptionFolderManualOrderStream: Flow> =
+ dataStore.data
+ .catch { exception ->
+ if (exception is IOException) emit(emptyPreferences()) else throw exception
+ }.map { preferences ->
+ PreferenceIdList.decode(preferences[Keys.SUBSCRIPTION_FOLDER_MANUAL_ORDER])
+ }.distinctUntilChanged()
+
+ suspend fun setSubscriptionFolderManualOrder(ids: List) {
+ dataStore.edit { preferences ->
+ preferences[Keys.SUBSCRIPTION_FOLDER_MANUAL_ORDER] = PreferenceIdList.encode(ids)
+ }
+ }
+
+ val homePinnedPodcastIdsStream: Flow> =
+ dataStore.data
+ .catch { exception ->
+ if (exception is IOException) emit(emptyPreferences()) else throw exception
+ }.map { preferences ->
+ HomePinnedShows.sanitize(PreferenceIdList.decode(preferences[Keys.HOME_PINNED_PODCAST_IDS]))
+ }.distinctUntilChanged()
+
+ suspend fun setHomePinnedPodcastIds(ids: List) {
+ dataStore.edit { preferences ->
+ preferences[Keys.HOME_PINNED_PODCAST_IDS] =
+ PreferenceIdList.encode(HomePinnedShows.sanitize(ids))
+ }
+ }
+
+ /**
+ * Sanitizes, toggles, and persists Home pins in one DataStore write.
+ * [HomePinnedShows.ToggleResult.AtCapacity] leaves the stored list unchanged.
+ */
+ suspend fun toggleHomePinnedPodcastId(podcastId: String): HomePinnedShows.ToggleResult {
+ var result = HomePinnedShows.ToggleResult.Unpinned
+ dataStore.edit { preferences ->
+ val current =
+ HomePinnedShows.sanitize(
+ PreferenceIdList.decode(preferences[Keys.HOME_PINNED_PODCAST_IDS]),
+ )
+ val (next, toggleResult) = HomePinnedShows.toggle(current, podcastId)
+ result = toggleResult
+ if (toggleResult != HomePinnedShows.ToggleResult.AtCapacity) {
+ preferences[Keys.HOME_PINNED_PODCAST_IDS] = PreferenceIdList.encode(next)
+ }
+ }
+ return result
+ }
+
+ /** Drops an unsubscribed show from Manual order and Home pins without touching other prefs. */
+ suspend fun removePodcastIdFromManualOrderAndPins(podcastId: String) {
+ val id = podcastId.trim()
+ if (id.isEmpty()) return
+ dataStore.edit { preferences ->
+ val prevOrder = PreferenceIdList.decode(preferences[Keys.SUBSCRIPTION_MANUAL_ORDER])
+ if (id in prevOrder) {
+ preferences[Keys.SUBSCRIPTION_MANUAL_ORDER] =
+ PreferenceIdList.encode(prevOrder.filter { it != id })
+ }
+ val prevPins =
+ HomePinnedShows.sanitize(PreferenceIdList.decode(preferences[Keys.HOME_PINNED_PODCAST_IDS]))
+ if (id in prevPins) {
+ preferences[Keys.HOME_PINNED_PODCAST_IDS] =
+ PreferenceIdList.encode(prevPins.filter { it != id })
+ }
+ }
+ }
+
+ /** Versioned one-time repair gate; a failed/incomplete pass deliberately leaves this unchanged. */
+ suspend fun legacyRssRepairVersion(): Int = dataStore.data
+ .catch { exception ->
+ if (exception is IOException) emit(emptyPreferences()) else throw exception
+ }.first()[Keys.LEGACY_RSS_REPAIR_VERSION] ?: 0
+
+ suspend fun markLegacyRssRepairVersion(version: Int) {
+ dataStore.edit { preferences ->
+ val current = preferences[Keys.LEGACY_RSS_REPAIR_VERSION] ?: 0
+ if (version > current) preferences[Keys.LEGACY_RSS_REPAIR_VERSION] = version
+ }
+ }
+
+ /**
+ * Journals the cross-store ID rewrite before the Room transaction. If the process stops after
+ * Room commits, the next launch can still repair Manual order, pins, and last-seen state.
+ */
+ suspend fun beginPodcastIdRepair(oldPodcastId: String, newPodcastId: String,) {
+ dataStore.edit { preferences ->
+ preferences[Keys.LEGACY_RSS_REPAIR_PENDING_OLD_ID] = oldPodcastId
+ preferences[Keys.LEGACY_RSS_REPAIR_PENDING_NEW_ID] = newPodcastId
+ }
+ }
+
+ suspend fun pendingPodcastIdRepair(): PendingPodcastIdRepair? = dataStore.data
+ .catch { exception ->
+ if (exception is IOException) emit(emptyPreferences()) else throw exception
+ }.first()
+ .let { preferences ->
+ val oldId = preferences[Keys.LEGACY_RSS_REPAIR_PENDING_OLD_ID]
+ val newId = preferences[Keys.LEGACY_RSS_REPAIR_PENDING_NEW_ID]
+ if (oldId.isNullOrBlank() || newId.isNullOrBlank()) {
+ null
+ } else {
+ PendingPodcastIdRepair(oldId, newId)
+ }
+ }
+
+ /** Atomically rewrites every DataStore preference keyed by a podcast ID and clears the journal. */
+ suspend fun finishPodcastIdRepair(oldPodcastId: String, newPodcastId: String,) {
+ dataStore.edit { preferences ->
+ if (preferences[Keys.LEGACY_RSS_REPAIR_PENDING_OLD_ID] != oldPodcastId ||
+ preferences[Keys.LEGACY_RSS_REPAIR_PENDING_NEW_ID] != newPodcastId
+ ) {
+ return@edit
+ }
+ val manualOrder = PreferenceIdList.decode(preferences[Keys.SUBSCRIPTION_MANUAL_ORDER])
+ preferences[Keys.SUBSCRIPTION_MANUAL_ORDER] =
+ PreferenceIdList.encode(manualOrder.map { if (it == oldPodcastId) newPodcastId else it }.distinct())
+
+ val pins =
+ HomePinnedShows.sanitize(
+ PreferenceIdList.decode(preferences[Keys.HOME_PINNED_PODCAST_IDS]),
+ )
+ preferences[Keys.HOME_PINNED_PODCAST_IDS] =
+ PreferenceIdList.encode(
+ HomePinnedShows.sanitize(
+ pins.map { if (it == oldPodcastId) newPodcastId else it },
+ ),
+ )
+ if (preferences[Keys.OVERRIDDEN_REC_PODCAST_ID] == oldPodcastId) {
+ preferences[Keys.OVERRIDDEN_REC_PODCAST_ID] = newPodcastId
+ }
+
+ val oldLastSeenKey = stringPreferencesKey("$LAST_SEEN_EPISODE_ID_PREFIX$oldPodcastId")
+ val newLastSeenKey = stringPreferencesKey("$LAST_SEEN_EPISODE_ID_PREFIX$newPodcastId")
+ val oldLastSeen = preferences[oldLastSeenKey]
+ if (oldLastSeen != null && preferences[newLastSeenKey] == null) {
+ preferences[newLastSeenKey] = oldLastSeen
+ }
+ preferences.remove(oldLastSeenKey)
+ preferences.remove(Keys.LEGACY_RSS_REPAIR_PENDING_OLD_ID)
+ preferences.remove(Keys.LEGACY_RSS_REPAIR_PENDING_NEW_ID)
+ }
+ }
+
+ suspend fun cancelPodcastIdRepair(oldPodcastId: String, newPodcastId: String,) {
+ dataStore.edit { preferences ->
+ if (preferences[Keys.LEGACY_RSS_REPAIR_PENDING_OLD_ID] == oldPodcastId &&
+ preferences[Keys.LEGACY_RSS_REPAIR_PENDING_NEW_ID] == newPodcastId
+ ) {
+ preferences.remove(Keys.LEGACY_RSS_REPAIR_PENDING_OLD_ID)
+ preferences.remove(Keys.LEGACY_RSS_REPAIR_PENDING_NEW_ID)
+ }
+ }
+ }
+
+ val overriddenRecPodcastIdStream: Flow =
+ dataStore.data
+ .catch { exception ->
+ if (exception is IOException) emit(emptyPreferences()) else throw exception
+ }.map { preferences ->
+ preferences[Keys.OVERRIDDEN_REC_PODCAST_ID]
+ }.distinctUntilChanged()
+
+ suspend fun setOverriddenRecPodcastId(podcastId: String?) {
+ dataStore.edit { preferences ->
+ if (podcastId == null) {
+ preferences.remove(Keys.OVERRIDDEN_REC_PODCAST_ID)
+ } else {
+ preferences[Keys.OVERRIDDEN_REC_PODCAST_ID] = podcastId
+ }
+ }
+ }
+
+ val lastSeenEpisodesStream: Flow