Fix foreground service

This commit is contained in:
minibits-cash
2026-06-27 23:55:08 +02:00
parent 670c0f5c98
commit 74c1b77765
3 changed files with 135 additions and 56 deletions

View File

@@ -81,7 +81,11 @@ notifee.onForegroundEvent(async ({ type, detail }) => {
if (detail.pressAction && detail.pressAction.id === 'stop') {
log.trace('[onForegroundEvent] Stopping foreground service')
await notifee.stopForegroundService()
// Route through closeNwcListener (not raw stopForegroundService) so pressing
// Stop also tears down the adaptive poll and releases any in-flight NWC
// pay_invoice immediately. It's a superset of stopping the service, and is
// harmless for non-NWC task foreground services (which share the one service).
await NotificationService.closeNwcListener()
}
})

View File

@@ -37,6 +37,53 @@ export type NotifyNwcRequestData = {
let _nwcQueue: any = undefined
// Tracks the active NWC foreground-service teardown loop so it can never run
// twice and a stray poll can always be cleared. The poll is the ONLY automatic
// closer of the shared foreground service, so its teardown must be idempotent
// and exception-safe — a missed stop leaves the service to hit Android's
// shortService timeout → ANR.
let _nwcListenerPoll: ReturnType<typeof setInterval> | undefined = undefined
let _nwcListenerClosing = false
let _nwcListenerNotificationId: string | undefined = undefined
// Tear down the NWC listener / shared foreground service. Shared by the adaptive
// poll AND the notification "Stop" action (index.js), so the Stop press releases
// any in-flight pay_invoice immediately instead of waiting for the next poll tick.
// Idempotent for the duration of a single teardown (guards a poll/Stop race), then
// resets so a later task foreground service can still be stopped by its own Stop.
const closeNwcListener = async function (): Promise<void> {
if (_nwcListenerClosing) return
_nwcListenerClosing = true
if (_nwcListenerPoll) {
clearInterval(_nwcListenerPoll)
_nwcListenerPoll = undefined
}
// Tell any in-flight NWC pay_invoice that the background context is going away,
// so it can stop waiting for its async melt result (no separate timeout needed —
// the wait is bound to this listener's lifetime).
EventEmitter.emit('ev_nwcListenerClosing', undefined)
try {
if(Platform.OS === 'android') {
log.trace('[closeNwcListener] Terminating Android foreground service')
await stopForegroundService()
} else {
log.trace('[closeNwcListener] Closing iOS nwcSubscription')
rootStoreInstance.nwcStore.resetSubscription()
if(_nwcListenerNotificationId) {
await cancelNotification(_nwcListenerNotificationId)
}
}
} catch (e: any) {
log.warn('[closeNwcListener] Failed to close NWC listener', {message: e.message})
} finally {
_nwcListenerNotificationId = undefined
_nwcListenerClosing = false
}
}
const getNwcQueue = function () {
if(!_nwcQueue) {
_nwcQueue = new TaskQueue({
@@ -383,31 +430,44 @@ const createNwcListenerNotification = async function () {
}
}
const listenerNotification = await notifee.displayNotification({
title: NWC_LISTENER_NAME,
body: 'Listening for NWC commands...',
android: {
channelId: NWC_CHANNEL_ID,
asForegroundService: true,
largeIcon: minibitsPngIcon,
importance: AndroidImportance.HIGH,
/*progress: {
indeterminate: true,
},*/
actions: [
{
title: 'Stop',
pressAction: {
id: 'stop',
},
},
],
},
ios: {
categoryId: NWC_CHANNEL_ID,
},
data: {task: LISTEN_FOR_NWC_EVENTS}
})
let listenerNotification: string | undefined = undefined
try {
listenerNotification = await notifee.displayNotification({
title: NWC_LISTENER_NAME,
body: 'Listening for NWC commands...',
android: {
channelId: NWC_CHANNEL_ID,
asForegroundService: true,
largeIcon: minibitsPngIcon,
importance: AndroidImportance.HIGH,
/*progress: {
indeterminate: true,
},*/
actions: [
{
title: 'Stop',
pressAction: {
id: 'stop',
},
},
],
},
ios: {
categoryId: NWC_CHANNEL_ID,
},
data: {task: LISTEN_FOR_NWC_EVENTS}
})
} catch (e: any) {
// Most likely ForegroundServiceStartNotAllowedException: the OS background
// FGS-start grace window (granted by the high-priority FCM push) expired
// before we reached here. Nothing started, so release any pay_invoice that
// may be waiting on the listener lifetime and bail — do NOT start the poll,
// or it would call stopForegroundService() on a service that never ran.
log.warn('[createNwcListenerNotification] Failed to start foreground service', {message: e.message})
EventEmitter.emit('ev_nwcListenerClosing', undefined)
return
}
if(Platform.OS === 'ios') {
nwcStore.listenForNwcEvents()
@@ -423,23 +483,17 @@ const createNwcListenerNotification = async function () {
const IDLE_GRACE_MS = 10 * 1000
let lastBusyAt = Date.now()
const closeListener = async () => {
// Tell any in-flight NWC pay_invoice that the background context is going
// away, so it can stop waiting for its async melt result (no separate
// timeout needed — the wait is bound to this listener's lifetime).
EventEmitter.emit('ev_nwcListenerClosing', undefined)
if(Platform.OS === 'android') {
log.trace('[createNwcListenerNotification] Terminating Android foreground service')
await stopForegroundService()
} else {
log.trace('[createNwcListenerNotification] Closing iOS nwcSubscription')
nwcStore.resetSubscription()
cancelNotification(listenerNotification)
}
// Defensive: kill any leftover poll from a prior listener so we never run two
// teardown loops against the one shared foreground service. Record this
// listener's notification id so closeNwcListener (poll or Stop action) can
// cancel it on iOS.
if (_nwcListenerPoll) {
clearInterval(_nwcListenerPoll)
_nwcListenerPoll = undefined
}
_nwcListenerNotificationId = listenerNotification
const poll = setInterval(async () => {
_nwcListenerPoll = setInterval(async () => {
const inFlight = SyncQueue.getSyncQueue().getAllTasksDetails(['idle', 'running']).length
const now = Date.now()
if (inFlight > 0) {
@@ -449,12 +503,10 @@ const createNwcListenerNotification = async function () {
const elapsed = now - startedAt
if (elapsed >= HARD_CAP_MS || (inFlight === 0 && idleFor >= IDLE_GRACE_MS)) {
clearInterval(poll)
log.trace('[createNwcListenerNotification] Closing NWC listener', {elapsed, idleFor, inFlight})
await closeListener()
await closeNwcListener()
}
}, 2000)
}
@@ -532,9 +584,16 @@ const areNotificationsEnabled = async function (): Promise<boolean> {
}
const stopForegroundService = async function (): Promise<void> {
await notifee.stopForegroundService()
log.trace('[stopForegroundService] completed')
const stopForegroundService = async function (): Promise<void> {
try {
await notifee.stopForegroundService()
log.trace('[stopForegroundService] completed')
} catch (e: any) {
// notifee throws if no foreground service is currently running (already
// stopped by a finished task, the Stop action, or a prior teardown).
// Harmless — callers treat stopping as best-effort and idempotent.
log.warn('[stopForegroundService] Failed to stop foreground service', {message: e.message})
}
}
@@ -552,5 +611,6 @@ export const NotificationService = {
onForegroundNotification,
areNotificationsEnabled,
getDisplayedNotifications,
stopForegroundService
stopForegroundService,
closeNwcListener
}

View File

@@ -81,14 +81,29 @@ const _handleTaskResult = async (taskId: TaskId, result: WalletTaskResult | Tran
if(queue.getAllTasksDetails(['idle', 'running']).length === 0) {
const notifications = await NotificationService.getDisplayedNotifications()
// Do not stop the listener for NWC events, this one is handled via timeout
for (const n of notifications) {
if(n.notification.android?.asForegroundService) {
if(n.notification.title && n.notification.title !== NWC_LISTENER_NAME) {
log.trace('[_handleTaskResult] Stopping foreground service')
await NotificationService.stopForegroundService()
}
}
// The NWC listener and task notifications share ONE Android foreground
// service, and stopForegroundService() is all-or-nothing. If the NWC
// listener is up, never stop here — it owns teardown via its own adaptive
// poll, and stopping now would kill it mid-listen. Otherwise stop once for
// a finished task's foreground service.
const hasNwcListener = notifications.some(
n => n.notification.android?.asForegroundService &&
n.notification.title === NWC_LISTENER_NAME
)
if(hasNwcListener) {
log.trace('[_handleTaskResult] NWC listener active, leaving foreground service running')
return
}
const hasTaskForegroundService = notifications.some(
n => n.notification.android?.asForegroundService
)
if(hasTaskForegroundService) {
log.trace('[_handleTaskResult] Stopping foreground service')
await NotificationService.stopForegroundService()
}
}
}