This release alert will not appear on Google Play installs. Enable Include Google Play in Delivery to send it to them.
+
Live app layout. Preview controls don’t send messages or download files.
+What this preview covers
The selected colors, lettering, content dimensions and text size use the app’s renderer. System bars are excluded. Custom themes and Android system notifications vary by phone.
+
+
+
+
diff --git a/admin-panel/public/notifyappusers/dashboard.js b/admin-panel/public/notifyappusers/dashboard.js
new file mode 100644
index 000000000..7b220a8ea
--- /dev/null
+++ b/admin-panel/public/notifyappusers/dashboard.js
@@ -0,0 +1,60 @@
+'use strict';
+const REPOSITORY = 'boxcreate/boxlore';
+const WORKFLOW = `https://api.github.com/repos/${REPOSITORY}/actions/workflows/send-notification.yml`;
+const FIELDS = {title:'inputTitle',body:'inputBody',type:'inputType',route:'inputRoute',image:'inputImage',target:'inputTarget',collapse_key:'inputCollapseKey',sound:'inputSound',action_label:'inputActionLabel',category:'inputCategory',presentation:'inputPresentation',tone:'inputTone',image_style:'inputImageStyle',release_version_code:'inputReleaseCode',release_url:'inputReleaseUrl'};
+const BOOLS = {dry_run:'inputDryRun',test_mode:'inputTestMode',show_action_in_push:'inputShowPush',show_action_in_app:'inputShowApp',release_alert:'inputReleaseAlert',include_play:'inputIncludePlay'};
+let reviewedDraft = null;
+function readDraft(){const result={};for(const [key,id] of Object.entries(FIELDS))result[key]=document.getElementById(id).value;for(const[key,id]of Object.entries(BOOLS))result[key]=String(document.getElementById(id).checked);return result;}
+function loadDraft(draft){for(const[key,id]of Object.entries(FIELDS))if(draft[key]!==undefined)document.getElementById(id).value=draft[key];for(const[key,id]of Object.entries(BOOLS))if(draft[key]!==undefined)document.getElementById(id).checked=String(draft[key])==='true';syncKind();pvUpdate();}
+function draftUrl(value,message){try{return new URL(value);}catch(_){throw Error(message);}}
+function plan(draft){
+ for(const field of Object.keys(BOOLS))if(!['true','false'].includes(draft[field]))throw Error(`Choose a supported ${field.replaceAll('_',' ')} value.`);
+ for(const[field,values]of Object.entries({target:['all_users','prod_users','debug_users','test_users','direct_users'],type:['push','in-app','both'],sound:['default','chime','announcement','silent'],presentation:['compact','fullscreen'],tone:['primary','secondary','tertiary','error'],image_style:['banner','cover']}))if(!values.includes(draft[field]))throw Error(`Choose a supported ${field.replaceAll('_',' ')}.`);
+ if(!draft.title.trim()||!draft.body.trim())throw Error('Add a title and message.');
+ if(draft.test_mode==='true'&&!['test_users','debug_users'].includes(draft.target))throw Error('Test mode only permits isolated test or debug builds.');
+ if(draft.release_alert==='true'&&!['direct_users','test_users','debug_users'].includes(draft.target)&&draft.include_play!=='true')throw Error('Choose Direct installs for a release alert, or explicitly include Google Play.');
+ if(draft.target==='direct_users'&&draft.include_play==='true')throw Error('Choose All installs to include Google Play.');
+ if(!/^\d+$/.test(draft.release_version_code)||Number(draft.release_version_code)>2147483647)throw Error('Use a release version code from 0 to 2,147,483,647.');
+ if(draft.release_alert==='true'&&draft.release_url){const url=draftUrl(draft.release_url,'Use a boxlore GitHub release page.');if(url.protocol!=='https:'||url.hostname!=='github.com'||url.port||url.username||url.password||url.search||url.hash||!(url.pathname==='/boxcreate/boxlore/releases/latest'||url.pathname.startsWith('/boxcreate/boxlore/releases/tag/')))throw Error('Use a boxlore GitHub release page.');}
+ if(draft.image){const url=draftUrl(draft.image,'Use an HTTPS image URL without embedded credentials.');if(url.protocol!=='https:'||url.username||url.password)throw Error('Use an HTTPS image URL without embedded credentials.');}
+ const data={};for(const key of ['title','body','type','sound','action_label','show_action_in_push','show_action_in_app','category','presentation','tone','image_style','release_alert','include_play','release_version_code','test_mode'])data[key]=draft[key];
+ data.body=data.body.replace(/\\n/g,'\n');data.release_version_code=String(Number(data.release_version_code));
+ for(const key of ['route','image'])if(draft[key])data[key]=draft[key];
+ if(draft.release_alert==='true'&&draft.release_url)data.release_url=draft.release_url;
+ const encoder=new TextEncoder();const bytes=Object.entries(data).reduce((n,[key,value])=>n+encoder.encode(key).length+encoder.encode(value).length,0);
+ if(bytes>2048)throw Error(`Message uses ${bytes} bytes; the limit is 2,048. Shorten it or link to the full notes.`);
+ return {topic:draft.target,data,collapse_key:draft.collapse_key,dry_run:draft.dry_run==='true',test_mode:draft.test_mode==='true',payload_bytes:bytes};
+}
+function parsePreview(value){const draft=value?.draft||{...value?.data,target:value?.topic,collapse_key:value?.collapse_key,dry_run:String(value?.dry_run),test_mode:String(value?.test_mode)};if(typeof draft.title!=='string'||typeof draft.body!=='string')throw Error('Choose an exported announcement preview.');const normalized={route:'',image:'',release_url:'',collapse_key:'',...draft};plan(normalized);return normalized;}
+function canonical(value){if(Array.isArray(value))return value.map(canonical);if(value&&typeof value==='object')return Object.fromEntries(Object.keys(value).sort().map(key=>[key,canonical(value[key])]));return value;}
+async function digest(value){const bytes=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(JSON.stringify(canonical(value))));return [...new Uint8Array(bytes)].map(b=>b.toString(16).padStart(2,'0')).join('');}
+const PALETTES={light:{surface:'#F8F8FB',container:'#EDEDF4',text:'#1A1B23',muted:'#494854',primary:['#5145D8','#FFFFFF','#E5E0FF','#261D58'],secondary:['#55647C','#FFFFFF','#DFE6F2','#202D42'],tertiary:['#397B6D','#FFFFFF','#CDEDE1','#123C32'],error:['#B3261E','#FFFFFF','#F9DEDC','#410E0B']},dark:{surface:'#121318',container:'#20212A',text:'#E6E6EE',muted:'#C8C5D3',primary:['#9C8CFF','#21164B','#3F347B','#ECE7FF'],secondary:['#A3B1CA','#253248','#354258','#E2E8F3'],tertiary:['#89C6B5','#12392F','#274C41','#D0EFE3'],error:['#F2B8B5','#601410','#8C1D18','#F9DEDC']}};
+function profile(draft){const colors=PALETTES[document.getElementById('previewMode').value];const accent=colors[draft.tone]||colors.primary;return {colors:{surface:colors.surface,container:colors.container,text:colors.text,muted:colors.muted,'tonal-container':colors.secondary[2],'on-tonal-container':colors.secondary[3],accent:accent[0],'on-accent':accent[1],'accent-container':accent[2],'on-container':accent[3]},fontScale:Number(document.getElementById('previewScale').value),roundness:Number(document.getElementById('previewRoundness').value),rtl:document.getElementById('previewRtl').checked,play:document.getElementById('previewPlay').checked};}
+function fitPreview(){const stage=document.getElementById('previewStage');const device=stage.querySelector('.preview-device');const frame=stage.querySelector('.preview-phone');const width=Number(document.getElementById('previewWidth').value)+18;const height=Number(document.getElementById('previewHeight').value)+18;const zoom=document.getElementById('previewZoom').value;const scale=zoom==='fit'?Math.max(0,Math.min(1,(stage.clientWidth-28)/width,(stage.clientHeight-28)/height)):1;frame.style.width=width+'px';frame.style.height=height+'px';device.style.width=width*scale+'px';device.style.height=height*scale+'px';frame.style.transform=`scale(${scale})`;if(document.getElementById('reviewDialog').open){const review=document.getElementById('reviewPreview');const maxHeight=window.innerHeight*(window.matchMedia('(max-width:920px)').matches ? 0.5 : 0.7);const ratio=Math.min(1,maxHeight/(height-18),document.getElementById('reviewStage').clientWidth/(width-18));review.style.transform=`scale(${ratio})`;document.getElementById('reviewStage').style.height=(height-18)*ratio+'px';}}
+function isPreviewExcluded(draft,play){return draft.type==='push'||(play&&draft.release_alert==='true'&&draft.include_play!=='true');}
+function updateFrame(frame,draft){const excluded=isPreviewExcluded(draft,document.getElementById('previewPlay').checked);frame.style.visibility=excluded?'hidden':'';frame.setAttribute('aria-hidden',String(excluded));const notice=document.getElementById(frame.id==='appPreview'?'appPreviewExcluded':'reviewPreviewExcluded');notice.hidden=!excluded;notice.textContent=draft.type==='push'?'This message only sends a system notification. No in-app alert will appear.':'This release alert will not appear on Google Play installs. Enable Include Google Play in Delivery to send it to them.';frame.width=document.getElementById('previewWidth').value;frame.height=document.getElementById('previewHeight').value;frame.contentWindow.postMessage({source:'boxlore-preview',payload:{...draft,body:draft.body.replace(/\\n/g,'\n')},profile:profile(draft)},location.origin);fitPreview();}
+function syncKind(){const release=document.getElementById('inputReleaseAlert').checked;document.getElementById('inputKind').value=release?'release':'announcement';for(const node of document.querySelectorAll('[data-release-only]'))node.hidden=!release;for(const node of document.querySelectorAll('[data-normal-only]'))node.hidden=release;const route=document.getElementById('actionRouteGroup');(release?document.getElementById('legacyRouteMount'):document.querySelector('[data-normal-only]')).prepend(route);}
+function chooseKind(){const release=document.getElementById('inputKind').value==='release';document.getElementById('inputReleaseAlert').checked=release;document.getElementById('inputCategory').value=release?'NEW RELEASE':'ANNOUNCEMENT';document.getElementById('inputPresentation').value=release?'fullscreen':'compact';document.getElementById('inputActionLabel').value=release?'Download':'Open';if(release&&!document.getElementById('inputTestMode').checked)document.getElementById('inputTarget').value='direct_users';syncKind();pvUpdate();}
+function showEditorTab(tab){for(const node of document.querySelectorAll('[data-panel]'))node.hidden=node.dataset.panel!==tab;for(const button of document.querySelectorAll('[data-tab]')){button.classList.toggle('selected',button.dataset.tab===tab);button.setAttribute('aria-pressed',String(button.dataset.tab===tab));}}
+function formatCopy(before,after){const input=document.getElementById('inputBody');const start=input.selectionStart,end=input.selectionEnd;input.setRangeText(before+input.value.slice(start,end)+after,start,end,'select');input.focus();pvUpdate();}
+function pvUpdate(){reviewedDraft=null;document.getElementById('approveDraft').checked=false;const draft=readDraft();updateFrame(document.getElementById('appPreview'),draft);document.getElementById('previewInteraction').textContent=isPreviewExcluded(draft,document.getElementById('previewPlay').checked)?'No in-app alert for this delivery. Review the notification text before sending.':'Live app layout. Preview controls don’t send messages or download files.';document.getElementById('pushTitle').textContent=draft.title;document.getElementById('pushBody').textContent=draft.body;document.getElementById('previewProfileLabel').textContent=`Classic ${document.getElementById('previewMode').value} · ${document.getElementById('previewWidth').value} × ${document.getElementById('previewHeight').value} dp · ${Math.round(Number(document.getElementById('previewScale').value)*100)}% text`;try{document.getElementById('payloadCount').textContent=`${plan(draft).payload_bytes} / 2,048 payload bytes`;document.getElementById('sendBtn').disabled=false;}catch(error){document.getElementById('payloadCount').textContent=error.message;document.getElementById('sendBtn').disabled=true;}}
+function reviewRows(draft,audience){
+ const inApp=draft.type!=='push',push=draft.type!=='in-app';
+ return [['Audience',audience],['Delivery',{push:'System notification','in-app':'In-app alert',both:'System notification and in-app alert'}[draft.type]],['Send mode',draft.dry_run==='true'?'Provider validation only — no delivery':'Live delivery'],['Appearance',inApp?(draft.presentation==='fullscreen'?'Full screen':'Compact alert'):'Android system notification'],['In-app actions',inApp?(draft.release_alert==='true'?'Download update · View on GitHub · Dismiss':(draft.show_action_in_app==='true'&&draft.route?`${draft.action_label} · Dismiss`:'Dismiss')):'No in-app alert'],['Notification action',push&&draft.show_action_in_push==='true'&&draft.route?draft.action_label:'None'],['Play release alerts',draft.release_alert==='true'?(draft.include_play==='true'?'Explicitly included':'Excluded'):'Ordinary announcement'],[inApp&&draft.release_alert==='true'?'GitHub page':'Action destination',inApp&&draft.release_alert==='true'?(draft.release_url||'Latest release'):(draft.route||'None')]];
+}
+function handleSend(event){event.preventDefault();try{const draft=readDraft();plan(draft);reviewedDraft=JSON.parse(JSON.stringify(draft));const audience=document.getElementById('inputTarget').selectedOptions[0].textContent;document.getElementById('reviewSummary').textContent=draft.dry_run==='true'?`Validate with Firebase for ${audience}. No message will be delivered.`:`Send this announcement to ${audience}. ${draft.test_mode==='true'?'Production builds are excluded.':'This delivers a message to the selected audience.'}`;document.getElementById('confirmSend').textContent=draft.dry_run==='true'?'Approve validation':'Approve and send';document.getElementById('reviewError').textContent='';const details=document.getElementById('reviewDetails');details.replaceChildren();for(const[label,value]of reviewRows(draft,audience)){const term=document.createElement('dt'),definition=document.createElement('dd');term.textContent=label;definition.textContent=value;details.append(term,definition);}document.getElementById('reviewPushCopy').hidden=draft.type==='in-app';document.getElementById('reviewPushTitle').textContent=draft.title;document.getElementById('reviewPushBody').textContent=draft.body.replace(/\\n/g,'\n');document.getElementById('reviewDialog').showModal();updateFrame(document.getElementById('reviewPreview'),reviewedDraft);document.getElementById('reviewDialog').scrollTop=0;document.getElementById('reviewHeading').focus({preventScroll:true});}catch(error){document.getElementById('statusText').textContent=error.message;}}
+function closeReview(){document.getElementById('reviewDialog').close();reviewedDraft=null;}
+async function approveSend(){
+ const button=document.getElementById('confirmSend');if(!reviewedDraft||!document.getElementById('approveDraft').checked){document.getElementById('reviewError').textContent='Review this message and its audience, then tick the approval box.';return;}
+ if(JSON.stringify(readDraft())!==JSON.stringify(reviewedDraft)){document.getElementById('reviewError').textContent='The draft changed. Close this review and review it again.';return;}
+ if(!window.__ADMIN_PAT){document.getElementById('reviewError').textContent='Sign in again before sending.';return;}
+ button.disabled=true;
+ try{const inputs={...reviewedDraft,preview_only:'false',approval_digest:await digest(plan(reviewedDraft))};const response=await fetch(WORKFLOW+'/dispatches',{method:'POST',headers:{Accept:'application/vnd.github+json',Authorization:`token ${window.__ADMIN_PAT}`,'Content-Type':'application/json'},body:JSON.stringify({ref:'master',inputs})});if(!response.ok){const error=await response.json().catch(()=>({}));throw Error(error.message||'Could not start the workflow. Try again.');}closeReview();document.getElementById('statusText').textContent='Workflow requested. Check Recent runs for validation or delivery results; device receipt is not confirmed.';fetchWorkflowHistory();}catch(error){document.getElementById('reviewError').textContent=error.message;}finally{button.disabled=false;}
+}
+async function exportDraft(){try{const draft=readDraft();const output={...plan(draft),approval_digest:await digest(plan(draft)),draft,mode:'offline-preview',sent:false};const url=URL.createObjectURL(new Blob([JSON.stringify(output,null,2)],{type:'application/json'}));const a=document.createElement('a');a.href=url;a.download='boxlore-announcement-preview.json';a.click();URL.revokeObjectURL(url);}catch(error){document.getElementById('statusText').textContent=error.message;}}
+function saveTemplate(){const name=document.getElementById('templateName').value.trim();if(!name)return;const templates=JSON.parse(localStorage.getItem('boxlore-announcement-drafts')||'{}');templates[name]=readDraft();localStorage.setItem('boxlore-announcement-drafts',JSON.stringify(templates));loadTemplates();}
+function loadTemplates(){const parent=document.getElementById('templatesList');parent.replaceChildren();for(const[name,draft]of Object.entries(JSON.parse(localStorage.getItem('boxlore-announcement-drafts')||'{}'))){const button=document.createElement('button');button.textContent=name;button.onclick=()=>loadDraft(draft);parent.append(button);}}
+async function fetchWorkflowHistory(){const parent=document.getElementById('historyList');if(!window.__ADMIN_PAT){parent.textContent='Sign in to view workflow runs.';return;}try{const response=await fetch(WORKFLOW+'/runs?per_page=5',{headers:{Accept:'application/vnd.github+json',Authorization:`token ${window.__ADMIN_PAT}`}});if(!response.ok)throw Error('Could not load runs. Try Refresh runs.');const data=await response.json();parent.replaceChildren();for(const run of data.workflow_runs){const link=document.createElement('a');link.href=run.html_url;link.target='_blank';link.rel='noopener';link.textContent=`${run.display_title} · ${run.conclusion||run.status}`;parent.append(link);}}catch(error){parent.textContent=error.message;}}
+function initApp(){document.getElementById('notifyForm').addEventListener('input',pvUpdate);document.getElementById('notifyForm').addEventListener('change',pvUpdate);for(const id of ['previewMode','previewScale','previewWidth','previewHeight','previewZoom','previewRtl','previewRoundness','previewPlay'])document.getElementById(id).addEventListener('change',pvUpdate);window.addEventListener('message',event=>{if(event.origin!==location.origin||event.data?.source!=='boxlore-announcement')return;for(const id of ['appPreview','reviewPreview']){const frame=document.getElementById(id);if(event.source!==frame.contentWindow)continue;if(event.data.action==='ready')updateFrame(frame,reviewedDraft||readDraft());else{const descriptions={download:'In the app: opens the updater and downloads the verified release.',github:'In the app: opens this release on GitHub.',dismiss:'In the app: dismisses this announcement.',action:'In the app: opens the configured action destination.'};document.getElementById('previewInteraction').textContent=descriptions[event.data.action]||'Live app layout.';}}});document.getElementById('importDraft').onchange=async event=>{try{const data=JSON.parse(await event.target.files[0].text());loadDraft(parsePreview(data));}catch(error){document.getElementById('statusText').textContent='Could not open this preview. Choose an announcement-preview JSON file.';}};new ResizeObserver(fitPreview).observe(document.getElementById('previewStage'));window.addEventListener('resize',fitPreview);syncKind();loadTemplates();fetchWorkflowHistory();pvUpdate();}
+function logout(){window.__ADMIN_PAT=null;if(typeof firebase!=='undefined'&&firebase.auth)firebase.auth().signOut().then(()=>location.reload());else location.reload();}
+if(typeof module!=='undefined')module.exports={plan,canonical,PALETTES,isPreviewExcluded,parsePreview,reviewRows};
diff --git a/admin-panel/public/notifyappusers/preview.html b/admin-panel/public/notifyappusers/preview.html
new file mode 100644
index 000000000..5c7c071dd
--- /dev/null
+++ b/admin-panel/public/notifyappusers/preview.html
@@ -0,0 +1 @@
+boxlore announcement preview
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 1203c2469..b9027ade7 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -49,8 +49,44 @@ Run commands from the repository root. Start with the affected behavior and broa
For Android code changes, the full CI command is `./gradlew detekt testDebugUnitTest :koverVerifyMerged :app:dependencyGuard :core:catalog:dependencyGuard :core:playback:dependencyGuard --continue`, with `./gradlew lintDebug` in a separate job. The unit workflow also runs the architecture shell guards and Python release-tooling tests. `ktlintCheck` is a local check; it is not part of the current PR workflow.
+For release-copy and announcement changes, the Python suite covers required copy, preserved wording/order, historical backfill, batch failure before writes, UTF-8 payload limits, provider dry-run flags and test-mode audience rejection with a fake sender. **Offline release rehearsal** (manual dispatch) runs the same suite and writes reviewable candidate files without credentials, publishing or messages. **Release copy** checks the PR's body and impact labels without an Android build. See [release-copy and rehearsal recovery](../scripts/README.md#authored-release-copy-and-isolated-rehearsals) before testing live notifications. These are offline checks; APK installation and actual notification receipt require separate device acceptance.
+
Report which checks completed and distinguish automated coverage from manual checks or paths that still need verification. Script tests exercise local logic; they do not verify a production deployment.
+For updater work, run `./gradlew :app:testDebugUnitTest --tests 'cx.aswin.boxlore.updates.*'` for offline/fake HTTP fixtures. These tests never publish releases, deliver FCM or install APKs. The optimized preflight builds the direct APK and `bundlePlayRelease`, checks each variant's R8 contracts, and rejects installer code/permission in Play. A real new-version installation still needs device acceptance with a separately approved staged APK; do not create a production release or alert for testing. On-device acceptance: Settings → Check for updates → inspect available/current/error state; for an approved available candidate, Download update → verify progress → Install update → confirm Android installation. Repeat interruption, permission return and cancellation, checking that listening data survives.
+
+## Isolated update and announcement testing
+
+No production release or shared production alert is needed. Use a dedicated test phone where possible. updateTest keeps the normal package/signature: installation preserves listening data, but installing a higher-code test candidate can prevent returning to an older production APK without a downgrade. Plan the exit build before testing installation; never uninstall merely to bypass this.
+
+```sh
+./gradlew :app:assembleUpdateTest
+cp app/build/outputs/apk/updateTest/app-updateTest.apk /tmp/boxlore-test-current.apk
+./gradlew :app:assembleUpdateTest -PboxloreUpdateTestVersionCode=
+cp app/build/outputs/apk/updateTest/app-updateTest.apk /tmp/boxlore-test-next.apk
+adb install -r /tmp/boxlore-test-current.apk
+adb reverse tcp:8765 tcp:8765
+python3 .github/scripts/serve_update_test.py --apk /tmp/boxlore-test-next.apk --version-code
+```
+
+Install the current test APK, open boxlore, then Settings → Check for updates. The available icon appears only after a valid newer compatible result. Open it, download, pause/resume, and install. When Android opens Allow from this source, enable it yourself, return to boxlore, and tap Install update again. The screen and verified file remain; returning never auto-installs. Cancel Android installation and check that Install remains available. Process restart re-verifies the existing file; removing its cache file returns to Download without a new automatic transfer.
+
+Restart the local server with `--state unavailable`, `--state incompatible`, or `--state corrupt` to test failures. Use Settings' manual check to bypass intervals. A manifest at the installed code should hide the icon. Integrity/signature/package/version checks must reject invalid candidates. The shipping app rejects testOnly/loopback manifests; Play bundles exclude the direct installer and install permission.
+
+For a long release-note test, add `--notes-file /tmp/boxlore-test-notes.txt` with UTF-8 sample text. The fixture rejects notes beyond the app's 24,000 UTF-16 character or 64 KiB response bounds. Scroll the notes to the end and confirm the close control and download/install footer remain visible. The `/notes` link shows the same sample text. This does not publish release notes or send an alert.
+
+For local visual review, export an announcement from the admin's Download preview, then run:
+
+```sh
+python3 .github/scripts/preview_announcement_on_device.py --preview /tmp/announcement-preview.json --serial
+```
+
+This uses an ADB-only receiver present in updateTest and no FCM. The preview clears version eligibility locally so current devices can show its layout; delivery filtering is tested separately. Open the app and check Compact/Full screen, large text, light/dark, RTL, long titles, imagery and scrollable body. Release actions: Download update starts the native updater, View on GitHub keeps the message available on return, and Dismiss clears it. Permission/settings round-trips retain the updater.
+
+For a real transport test after workflows/panel are deployed, keep Test mode on and audience Isolated test builds. First retain Validate without delivery, review and approve provider validation. Only then turn validation off, review the changed draft and approve one actual test_users send. Production builds neither subscribe to this audience nor accept test-only payloads. Test Ordinary alert versus Release, both presentations, installed-version suppression, explicit Play override and normal episode notification independence. Do not select all_users/prod_users for an isolated test.
+
+Run `node --test .github/scripts/test_announcement_admin.cjs` and the Python suite for approval, payload bounds, audience restrictions, preview action semantics and font parity. Announcements render natively; verify preview style against phone screenshots. The optimized artifact gate rejects the isolated receiver/marker in shipping outputs. Report local/device/provider verification separately.
+
## Stack
- JUnit 5 (+ Vintage where leftovers remain)
@@ -191,6 +227,8 @@ See [`docs/screenshots/README.md`](screenshots/README.md).
| Workflow | Runs | When | Status |
| :--- | :--- | :--- | :--- |
| `unit-tests.yml` | Architecture + detekt + unit + Kover + lint + Dependency Guard + Python release tests | PR / master push / dispatch | Done |
+| `release-copy.yml` | Authored release regions and exactly one impact label | PR, including body / label edits | Configured |
+| `release-rehearsal.yml` | Hermetic script tests and offline candidate previews | Manual | Configured |
| `coderabbit-threads-resolved.yml` | Fail unless all non-outdated CodeRabbit review threads are Resolved | PR / review | Done |
| `gitleaks.yml` | Secret scan | PR / push to master | Done |
| `maestro-nightly.yml` | Validate Maestro YAML; optional Cloud device job when configured | Nightly / manual | Done |
@@ -237,11 +275,13 @@ The gate derives contracts from pre-R8 release class files and the merged manife
Gson roots are intentionally declared in `verify_release_contracts.py`; add a new reflective JSON entry point there when introducing one. Nested model/field additions, new workers, new manifest components, and new API methods are discovered from compiled release classes automatically. This compares optimization against current source; it does not detect an intentional source-level schema rename or prove SDK/network behavior. A successful build alone is never release acceptance.
```sh
-./gradlew :app:assembleRelease :app:bundleRelease :app:lintRelease
+./gradlew :app:assembleRelease :app:bundlePlayRelease :app:lintRelease :app:lintPlayRelease
python3 .github/scripts/verify_release_contracts.py --sdk \
--report app/build/reports/release-contracts.json \
- app/build/outputs/apk/release/app-release.apk \
- app/build/outputs/bundle/release/app-release.aab
+ app/build/outputs/apk/release/app-release.apk
+python3 .github/scripts/verify_release_contracts.py --sdk --app-variant playRelease \
+ --report app/build/reports/play-release-contracts.json \
+ app/build/outputs/bundle/playRelease/app-playRelease.aab
python3 -m unittest discover -s .github/scripts -p 'test_*.py' -v
```
diff --git a/scripts/README.md b/scripts/README.md
index 594b16d14..50826747e 100644
--- a/scripts/README.md
+++ b/scripts/README.md
@@ -48,6 +48,37 @@ These commands test local logic; running a production script requires the intern
CI configuration helpers, maintenance tools, and data files also live here. Check the owning workflow or runbook before using a tool; its presence in this directory does not mean it runs automatically.
+### Authored release copy and isolated rehearsals
+
+The publication and same-version refresh jobs build the direct `assembleRelease` APK and installer-free `bundlePlayRelease` AAB, validate their optimized contracts separately, and verify the downloadable APK before generating/uploading `update.json`. The manifest identifies the immutable release APK URL, version code/name, Android minimum, byte size, checksum and full reviewed listener bullets. A refresh updates its checksum without creating a newer version or sending an update alert. If manifest publication fails after verified assets, rerun publication/refresh to repair the manifest; do not bump again solely for a failed upload. No manifest is advertised before the download check passes. App checks remain read-only and do not rely on FCM delivery.
+
+Release tooling lives in `.github/scripts/`. The PR author or coding agent writes the exact developer and listener copy in the [PR template](../.github/PULL_REQUEST_TEMPLATE.md). `update_changelog.py` normalizes historical impact-label aliases consistently with tag resolution, requires exactly one recognized impact label, rejects multiple labels even when they normalize to the same level, requires developer copy for every PR and listener copy for all user-impact labels, and never calls a text-generation service. The **Release copy** check refreshes on PR body and label edits. It is a fast standalone check; no Android build or repository-rule change is involved.
+
+README order is Critical → New features → Improvements → Fixes → Security → Other. Within a category, higher impact leads, then newer PRs; bullet order within each PR is preserved. CHANGELOG uses Keep a Changelog categories, then impact and PR order. All authored bullets are retained. `no-user-impact` stays in CHANGELOG only. Existing release notes and legacy markers remain readable.
+
+If copy is missing, fill that PR's release-copy region. For a merged PR, run **Changelog and Release → backfill-changelog** with its number, then retry the failed operation. Do not hand-edit generated regions. Release/tooling PRs can still use the established `[skip changelog]` exception.
+
+**Offline release rehearsal** writes candidate version files, release notes and an announcement payload to an artifact without editing source files, creating a release, using credentials or sending FCM. Same-version refresh previews keep the version unchanged and produce no announcement. Locally:
+
+```bash
+python3 .github/scripts/rehearse_release.py --output /tmp/boxlore-release-preview
+python3 .github/scripts/rehearse_release.py --output /tmp/boxlore-refresh-preview --operation refresh-latest-artifacts
+```
+
+For a custom announcement, **Send boxlore Notification** separates three steps:
+
+1. `preview_only=true` writes `announcement-preview.json` without Firebase access or delivery. Import it in the admin composer or use Download preview there.
+2. `dry_run=true` validates with Firebase without device delivery. Test mode permits only debug_users or isolated test_users.
+3. Real delivery requires the exact preview's approval digest. In the admin, Review announcement snapshots the text, appearance, destinations and audience; tick the review box and approve. Changing the draft requires another review. Automatic release publication prepares this artifact and does not send until approved.
+
+The composer offers compact/fullscreen alerts, controlled accent/image styles and exact selected-profile rendering, with a separate system-notification text preview. Release alerts have native Download update, View on GitHub and Dismiss actions. Optional fields retain old payload compatibility. Direct release delivery targets the new direct_users topic and excludes play_users/test_users; old clients sharing prod_users are not silently included. Ordinary announcements still support both channels, and manual include-Play is explicit. Topic payloads are checked against the 2,048-byte UTF-8 limit. FCM acceptance is not device receipt confirmation.
+
+If preview validation fails, correct the reported field or shorten the message, then preview again. If approval is stale, review the current draft and approve it again. If Firebase validation fails, inspect that workflow step; no message was delivered. If delivery is accepted but the device shows nothing, check the selected build/audience, notification permission, release code and Play exclusion. Retry a live message only after confirming that a previous request was not accepted.
+
+The public composer and renderer are versioned here; its existing private gateway/auth/hosting configuration remains unchanged and ignored. Deploying the panel is a separate operation. See [admin composer](../admin-panel/README.md) and [isolated app testing](../docs/TESTING.md#isolated-update-and-announcement-testing).
+
+The local updater fixture accepts `--notes-file` for long UTF-8 sample release notes. It preserves the supplied text in the test manifest and `/notes` page, validates the Android character/response bounds, and never publishes or sends anything.
+
### Check New Episodes
[`.github/workflows/new-episode-check.yml`](../.github/workflows/new-episode-check.yml) runs approximately every 30 minutes on GitHub Actions. It polls notification registrations independently of catalog sync.
@@ -85,15 +116,21 @@ Repair considers catalog rows without a valid HTTPS `feedUrl`. It uses the authe
### Release artifact contract guard
-The release workflow runs `.github/scripts/verify_release_contracts.py` after optimized APK/AAB builds and before either publication path uploads artifacts. It compares pre-R8 project classes with optimized DEX using Android SDK `dexdump`, checks dynamic resource names with `aapt2`, and verifies named raw-resource payloads in both artifacts. Literal resource URIs are discovered from main/release Kotlin sources, including notification sounds. Run locally after `assembleRelease bundleRelease`:
+The release workflow runs `.github/scripts/verify_release_contracts.py` after optimized APK/AAB builds and before either publication path uploads artifacts. It compares pre-R8 project classes with optimized DEX using Android SDK `dexdump`, checks dynamic resource names with `aapt2`, and verifies named raw-resource payloads in both artifacts. Literal resource URIs are discovered from main/release Kotlin sources, including notification sounds. Run locally after `assembleRelease bundlePlayRelease`:
```sh
python3 .github/scripts/verify_release_contracts.py --sdk \
--report app/build/reports/release-contracts.json \
- app/build/outputs/apk/release/app-release.apk \
- app/build/outputs/bundle/release/app-release.aab
+ app/build/outputs/apk/release/app-release.apk
+python3 .github/scripts/verify_release_contracts.py --sdk --app-variant playRelease \
+ --report app/build/reports/play-release-contracts.json \
+ app/build/outputs/bundle/playRelease/app-playRelease.aab
```
`release_classfile.py` reads compiled JVM contracts; `release_dex.py` reads optimized contracts and R8 class names. `test_release_contracts.py` covers missing/private constructors, persisted names, nested JSON graphs, field/default changes, generic erasure, annotations, serializers, resource loss, and workflow wiring. These fast tests run in PR CI. Actions → Manual optimized release validation → Run workflow optionally exercises the real optimizer on the selected branch without release credentials. It does not run automatically on PRs, publish a release or send notifications. See [optimized-release validation](../docs/TESTING.md#optimized-release-validation) for scope and manual acceptance.
All three artifact-build jobs explicitly request `platform-tools` during Android SDK setup; the pinned setup action's default also requests the removed `tools` package and fails before compilation. The workflow regression check covers the manual preflight and both signed publication paths.
+
+The update manifest preserves authored listener-note emphasis and links as Markdown instead of flattening them to plain text. The app renders the supported listener Markdown subset.
+
+Local update fixtures support `--upcoming-file` for testing an unreleased Markdown preview. `upcoming_notes` extracts authored listener copy from README Upcoming Changes. The approved workflow refreshes only optional upcoming text in the latest verified update feed after README synchronization. Metadata writers share a concurrency group to prevent stale overwrites. This does not change APK metadata or send FCM; it becomes active only when these workflow changes are shipped.