Newer
Older
navi-1 / webclient / src / composables / usePush.js
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 }
}