/* Android-only bridge. FCM tokens stay in memory and are never logged or persisted in JS. */ (() => { const config = document.getElementById('native-push-config'); const cap = window.Capacitor; if (!config || !cap || cap.getPlatform() !== 'android') return; const push = cap.Plugins.PushNotifications; const device = cap.Plugins.MetalCircleDevice; if (!push || !device) return; const texts = JSON.parse(config.textContent); let binding = ''; let info; let stopped = false; let running = false; let ready = false; let sendQueue = Promise.resolve(); let panel; function notice(message, offerPermission = false) { if (!panel) { panel = document.createElement('section'); panel.className = 'native-push-panel'; (document.querySelector('main') || document.querySelector('.container') || document.body).prepend(panel); } panel.replaceChildren(); const label = document.createElement('p'); label.textContent = message; label.setAttribute('role', 'status'); panel.append(label); if (offerPermission) { const allow = document.createElement('button'); allow.type = 'button'; allow.className = 'button'; allow.textContent = texts.enable; allow.onclick = async () => { allow.disabled = true; try { await push.requestPermissions(); localStorage.setItem('metalcircle_push_prompt', 'seen'); await synchronize(); } catch (_) { notice(texts.failed); } }; const later = document.createElement('button'); later.type = 'button'; later.className = 'button button-secondary'; later.textContent = texts.later; later.onclick = () => { localStorage.setItem('metalcircle_push_prompt', 'seen'); panel.remove(); panel = null; }; panel.append(allow, later); } if (ready && info?.debug && location.pathname === '/profile') { const debug = document.createElement('button'); debug.type = 'button'; debug.className = 'button button-secondary'; debug.textContent = texts.debug; debug.onclick = () => device.showDebugToken({language: document.documentElement.lang}).catch(() => notice(texts.failed)); panel.append(debug); } } async function sendToken(token) { if (stopped || !binding || !info) return; const expected = binding; const response = await fetch('/api/push/devices', { method: 'POST', credentials: 'same-origin', redirect: 'error', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({device_id: info.deviceId, token, platform: 'android', app_version: info.appVersion, session_tag: expected}) }); if (stopped || binding !== expected) return; ready = response.ok; if (!response.ok) { notice(texts.failed); return; } if (location.pathname === '/profile') notice(texts.ready); else if (panel) { panel.remove(); panel = null; } } async function synchronize() { if (stopped || running) return; running = true; try { const response = await fetch('/api/push/session', {credentials: 'same-origin', cache: 'no-store', redirect: 'error'}); if (!response.ok) return; const session = await response.json(); info = await device.getInfo(); if (stopped) return; if (!session.authenticated) { binding = ''; ready = false; await device.prepareSession({binding: ''}); return; } const permissions = await push.checkPermissions(); if (permissions.receive !== 'granted') { binding = ''; ready = false; await fetch('/api/push/devices/' + encodeURIComponent(info.deviceId), {method: 'DELETE', credentials: 'same-origin'}); await device.prepareSession({binding: ''}); const prompt = permissions.receive === 'prompt' || permissions.receive === 'prompt-with-rationale'; if (location.pathname === '/profile' || (!localStorage.getItem('metalcircle_push_prompt') && prompt)) { notice(prompt ? texts.permission : texts.denied, prompt); } return; } // Invalidate the previous account/session's FCM token before acquiring a new one. binding = ''; await device.prepareSession({binding: session.session_tag}); if (stopped) return; binding = session.session_tag; await push.register(); } catch (_) { if (!stopped && location.pathname === '/profile') notice(texts.failed); } finally { running = false; } } document.addEventListener('submit', async event => { const form = event.target; if (!(form instanceof HTMLFormElement) || !['/logout', '/profile/delete'].includes(new URL(form.action).pathname)) return; event.preventDefault(); stopped = true; binding = ''; ready = false; try { await sendQueue; await device.prepareSession({binding: ''}); } catch (_) { /* Server-side session deletion still revokes every bound device. */ } form.submit(); }, true); Promise.all([ push.addListener('registration', event => { sendQueue = sendQueue.then(() => sendToken(event.value)).catch(() => { if (!stopped) notice(texts.failed); }); }), push.addListener('registrationError', () => { if (!stopped) notice(texts.failed); }), push.addListener('pushNotificationActionPerformed', () => { // Do not navigate to arbitrary URLs supplied by a notification payload. location.assign('/'); }) ]).then(() => { synchronize(); document.addEventListener('visibilitychange', () => { if (!document.hidden) synchronize(); }); document.addEventListener('resume', synchronize); window.addEventListener('online', synchronize); window.addEventListener('pageshow', synchronize); }).catch(() => { /* The website remains usable when the bridge is unavailable. */ }); })();