import { ref, computed } from 'vue'
import { getVapidKey, subscribePush, unsubscribePush } from '@/api'
import {
bridgeAvailable,
nativeBridge,
nativePushFlag,
requestPermissionViaBridge,
} from '@/composables/nativeBridge.js'
/** URL-safe base64 → Uint8Array (the format PushManager expects for VAPID keys). */
export function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const raw = atob(base64)
const output = new Uint8Array(raw.length)
for (let i = 0; i < raw.length; i += 1) {
output[i] = raw.charCodeAt(i)
}
return output
}
// Feature checks are lazy functions, not module-load constants: test
// environments stub navigator/Notification after import time, and SSR-ish
// contexts may not have the globals at all.
function swSupported() {
return typeof navigator !== 'undefined' && 'serviceWorker' in navigator
}
function pmSupported() {
return typeof window !== 'undefined' && 'PushManager' in window
}
function notifSupported() {
return typeof Notification !== 'undefined'
}
/** Supported = the Android bridge (notifications natively, no web push in
* WebView) or the browser trio SW + PushManager + Notification APIs. */
export const supported = computed(() => bridgeAvailable() || (pmSupported() && notifSupported()))
/** The Android-app path replaces the web-push machinery entirely. */
export const bridgeMode = computed(() => bridgeAvailable())
const permission = ref(initialPermission())
const subscribed = ref(false)
const busy = ref(false)
function initialPermission() {
const bridge = nativeBridge()
if (bridge && typeof bridge.hasNotificationPermission === 'function') {
try {
return bridge.hasNotificationPermission() ? 'granted' : 'default'
} catch { /* fall through to web checks */ }
}
return notifSupported() ? Notification.permission : 'denied'
}
function bridgePermission() {
const bridge = nativeBridge()
if (!bridge) return 'denied'
try {
return bridge.hasNotificationPermission() ? 'granted' : 'default'
} catch {
return 'default'
}
}
async function currentSubscription() {
if (!swSupported() || !pmSupported()) return null
const reg = await navigator.serviceWorker.getRegistration()
if (!reg) return null
return reg.pushManager.getSubscription()
}
async function syncState() {
if (bridgeMode.value) {
subscribed.value = nativePushFlag()
permission.value = bridgePermission()
return
}
if (!swSupported() || !pmSupported()) return
try {
const sub = await currentSubscription()
subscribed.value = !!sub
permission.value = notifSupported() ? Notification.permission : 'denied'
} catch {
subscribed.value = false
}
}
/** Ask for permission, subscribe via the SW and register the endpoint server-side. */
async function enable() {
if (!supported.value || busy.value) return
if (bridgeMode.value) {
busy.value = true
try {
if (bridgePermission() !== 'granted') {
const ok = await requestPermissionViaBridge()
if (!ok) return false
}
const bridge = nativeBridge()
// Hold the process (and the WebView WebSocket) open when minimized.
bridge.enableBackgroundMode()
if (
typeof bridge.isIgnoringBatteryOptimizations === 'function' &&
!bridge.isIgnoringBatteryOptimizations()
) {
// One-time ask for the Doze exemption — the system shows its own dialog.
bridge.requestIgnoreBatteryOptimizations()
}
try { localStorage.setItem('navi_push_android', '1') } catch { /* memory only */ }
subscribed.value = true
permission.value = 'granted'
return true
} finally {
busy.value = false
}
}
busy.value = true
try {
permission.value = await Notification.requestPermission()
if (permission.value !== 'granted') return false
const { public_key: vapidKey } = await getVapidKey()
if (!vapidKey) return false
const reg = await navigator.serviceWorker.ready
let sub = await reg.pushManager.getSubscription()
if (!sub) {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidKey),
})
}
const raw = sub.toJSON()
await subscribePush({
endpoint: raw.endpoint,
keys: raw.keys,
user_agent: navigator.userAgent,
})
subscribed.value = true
return true
} finally {
busy.value = false
}
}
/** Unsubscribe from the push service and unregister the endpoint server-side. */
async function disable() {
if (bridgeMode.value) {
if (busy.value) return
busy.value = true
try {
nativeBridge()?.disableBackgroundMode?.()
try { localStorage.removeItem('navi_push_android') } catch { /* memory only */ }
subscribed.value = false
return true
} finally {
busy.value = false
}
}
if (!swSupported() || !pmSupported() || busy.value) return
busy.value = true
try {
const sub = await currentSubscription()
if (sub) {
try {
await unsubscribePush(sub.endpoint)
} catch { /* server-side cleanup is best-effort */ }
await sub.unsubscribe()
}
subscribed.value = false
} finally {
busy.value = false
}
}
export function usePush() {
return { supported, bridgeMode, permission, subscribed, busy, enable, disable, syncState }
}