OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface-android / lib / host / lifecycle.js

115 lines · javascript ref:
// Whether the node keeps running when the app is not on screen.
//
// ⛔ THE DEFAULT IS TO STOP, AND IT IS NOT ANDROID DOING IT. react-native-bare-kit registers, at import
// time, `AppState.addEventListener('change', BareKitWorklet.update)` — and `update` is
// `case 'background': return this.suspend()`. So every worklet in the process is suspended the moment the
// app is backgrounded, which on a phone is every screen lock. That policy is right for a worklet that
// serves a UI and does nothing else; it is exactly wrong for one that IS a peer, holds swarm connections
// and owes other people replication. It is also why a foreground service ALONE fixes nothing: it would
// faithfully keep a suspended node alive.
//
// So this module takes the policy over. The rule it replaces "is the screen on" with:
//
//   suspend when we are NOT ALLOWED to keep running — i.e. when there is no live foreground service.
//
// Both halves are load-bearing. Without the override, the service keeps a suspended thread alive. Without
// the service, an unsuspended thread runs in a CACHED process: Android kills it under memory pressure and
// on task-swipe, and the CPU it burns until then is a battery bug with nothing to show for it. Hence the
// fallback: no service, library behaviour.
//
// ⛔ RUNTIME-AGNOSTIC ON PURPOSE, like lib/ipc-client.js. No react-native import, so the policy is
// testable off-device (test/lifecycle.test.mjs) instead of only being observable on a handset overnight.
// app/worklet.js is the only file that knows AppState and the native module exist.

/**
 * @param {object} opts
 * @param {{ suspend(): void, resume(): void, suspended: boolean, terminated: boolean, update?: Function }} opts.worklet
 *   the bare-kit worklet. `update` is overwritten — see takeOver below.
 * @param {{ start(): Promise<boolean>, stop(): Promise<any> }} opts.service
 *   the foreground service. `start` resolves truthy only if the service is actually running.
 * @param {(msg: string) => void} [opts.log]
 */
export function createLifecycle ({ worklet, service, log = () => {} }) {
  let foreground = false
  let appState = 'active'

  // Take the library's hands off the wheel.
  //
  // The AppState listener is registered on the CLASS at module import, before any of our code runs, and
  // there is no handle to remove it — but all it does is call `worklet.update(state)` per worklet, so an
  // own property on the instance shadows the prototype method and the listener becomes inert for us.
  // Deliberately NOT `Worklet._worklets.delete(worklet)`: that reaches into a private static and would
  // also disable the static suspend/resume/wakeup helpers, which is a bigger change than the one needed.
  function takeOver () {
    worklet.update = () => {}
  }

  function suspend () {
    if (worklet.terminated || worklet.suspended) return
    try { worklet.suspend() } catch (e) { log('could not suspend the node: ' + ((e && e.message) || e)) }
  }

  function resume () {
    if (worklet.terminated || !worklet.suspended) return
    try { worklet.resume() } catch (e) { log('could not resume the node: ' + ((e && e.message) || e)) }
  }

  function apply () {
    if (foreground) return resume() // running in the background is the point; only ever undo a suspend
    if (appState === 'background') suspend()
    else resume()
  }

  return {
    /** Is the node currently exempt from suspend-on-background? */
    get foreground () { return foreground },

    /**
     * Take over the lifecycle and ask for the foreground service.
     *
     * Resolves with whether the service is running. FALSE IS NOT A BOOT FAILURE: the node still works
     * while the app is open, it just goes back to being suspended in the background. A phone where the
     * user denied notifications, or an OEM build that refuses the FGS start, lands here — and it must
     * say so rather than silently behave like a different product.
     */
    async start () {
      takeOver()
      try {
        foreground = !!(await service.start())
        if (!foreground) log('the foreground service did not start — the node will suspend in the background')
      } catch (e) {
        foreground = false
        log('the foreground service did not start — the node will suspend in the background: ' + ((e && e.message) || e))
      }
      // The ~40s boot is long enough to lock the screen in, so the library's listener may already have
      // suspended us before we got here. apply() undoes a suspend we would not have made.
      apply()
      return foreground
    },

    /** AppState changes, ours rather than the library's. */
    async onAppState (state) {
      if (state !== 'active' && state !== 'background') return // 'inactive' is iOS-only; ignore, do not guess
      appState = state
      apply()
    },

    /**
     * Called when the service tells us it is going away — the notification's stop action, or Android
     * revoking it. The node is NOT terminated here: stopping it is the app's decision, and a policy
     * module that also tore down the node would make one failure look like the other.
     */
    onServiceStopped () {
      foreground = false
      apply()
    },

    /** Drop the notification and go back to the library policy. */
    async stop () {
      foreground = false
      try { await service.stop() } catch (e) { log('could not stop the foreground service: ' + ((e && e.message) || e)) }
      apply()
    }
  }
}