diff --git a/go/bind/keybase.go b/go/bind/keybase.go
index 4aa2ea2827de..729cf6e0077d 100644
--- a/go/bind/keybase.go
+++ b/go/bind/keybase.go
@@ -23,7 +23,6 @@ import (
"github.com/keybase/client/go/chat/globals"
"github.com/keybase/client/go/chat/types"
"github.com/keybase/client/go/status"
- "golang.org/x/sync/errgroup"
"github.com/keybase/client/go/externals"
"github.com/keybase/client/go/kbfs/env"
@@ -32,6 +31,7 @@ import (
"github.com/keybase/client/go/kbfs/libkbfs"
"github.com/keybase/client/go/kbfs/simplefs"
"github.com/keybase/client/go/libkb"
+ "github.com/keybase/client/go/libkb/lifecycle"
"github.com/keybase/client/go/logger"
"github.com/keybase/client/go/protocol/chat1"
"github.com/keybase/client/go/protocol/keybase1"
@@ -875,65 +875,23 @@ func FlushLogs() {
logger.FlushLogFile()
}
-func SetAppStateForeground() {
+// AppUIActive reports the app on screen and receiving events: iOS didBecomeActive, Android process resume.
+func AppUIActive() {
if !isInited() {
return
}
- defer kbCtx.Trace("SetAppStateForeground", nil)()
- kbCtx.MobileAppState.Update(keybase1.MobileAppState_FOREGROUND)
+ defer kbCtx.Trace("AppUIActive", nil)()
+ kbCtx.MobileLifecycle.UIActive()
}
-func SetAppStateBackground() {
+// AppUIInactive reports the app on screen but not active: iOS willEnterForeground and
+// willResignActive, Android process start.
+func AppUIInactive() {
if !isInited() {
return
}
- defer kbCtx.Trace("SetAppStateBackground", nil)()
- kbCtx.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND)
- flushLocalDbs()
-}
-
-// flushLocalDbs flushes the leveldb memtables in the background. An unclean
-// kill while suspended (routine on iOS) with a non-empty journal forces a
-// journal replay — or a whole-DB recovery — during the next launch, which is
-// the main cold-start cost. Called when the app heads to the background so
-// the journals are empty if the OS kills the process.
-func flushLocalDbs() {
- if kbCtx == nil {
- return
- }
- flush := func(name string, db *libkb.JSONLocalDb) {
- if db == nil {
- return
- }
- ldb, ok := db.GetEngine().(*libkb.LevelDb)
- if !ok {
- return
- }
- begin := time.Now()
- if err := ldb.Flush(); err != nil {
- log("Go: flushLocalDbs: %s flush error: %v", name, err)
- return
- }
- log("Go: flushLocalDbs: %s flushed in %s", name, time.Since(begin))
- }
- go flush("LocalDb", kbCtx.LocalDb)
- go flush("LocalChatDb", kbCtx.LocalChatDb)
-}
-
-func SetAppStateInactive() {
- if !isInited() {
- return
- }
- defer kbCtx.Trace("SetAppStateInactive", nil)()
- kbCtx.MobileAppState.Update(keybase1.MobileAppState_INACTIVE)
-}
-
-func SetAppStateBackgroundActive() {
- if !isInited() {
- return
- }
- defer kbCtx.Trace("SetAppStateBackgroundActive", nil)()
- kbCtx.MobileAppState.Update(keybase1.MobileAppState_BACKGROUNDACTIVE)
+ defer kbCtx.Trace("AppUIInactive", nil)()
+ kbCtx.MobileLifecycle.UIInactive()
}
func waitForInit(maxDur time.Duration) error {
@@ -962,39 +920,7 @@ func BackgroundSync() string {
return fmt.Sprintf("waitForInit timeout: %v", err)
}
defer kbCtx.Trace("BackgroundSync", nil)()
-
- // Skip the sync if we aren't in the background
- if state := kbCtx.MobileAppState.State(); state != keybase1.MobileAppState_BACKGROUND {
- msg := fmt.Sprintf("skipping, app not in background state: %v", state)
- kbCtx.Log.Debug("BackgroundSync: %s", msg)
- return msg
- }
-
- // Flip to BACKGROUNDACTIVE only if still BACKGROUND, so a foreground
- // transition that lands after the check above isn't overwritten. If the
- // check fails, NextUpdate below fires immediately and we bail out.
- nextState := keybase1.MobileAppState_BACKGROUNDACTIVE
- kbCtx.MobileAppState.UpdateWithCheck(nextState, func(s keybase1.MobileAppState) bool {
- return s == keybase1.MobileAppState_BACKGROUND
- })
- select {
- case <-kbCtx.MobileAppState.NextUpdate(nextState):
- // if literally anything happens, let's get out of here
- state := kbCtx.MobileAppState.State()
- msg := fmt.Sprintf("bailing out early, appstate change: %v", state)
- kbCtx.Log.Debug("BackgroundSync: %s", msg)
- return msg
- case <-time.After(10 * time.Second):
- // Drop back to BACKGROUND only if we still hold BACKGROUNDACTIVE;
- // the app may have foregrounded between the timer firing and this
- // update, and clobbering FOREGROUND would cancel live RPCs and
- // strand the service in BACKGROUND while the user is in the app.
- kbCtx.MobileAppState.UpdateWithCheck(keybase1.MobileAppState_BACKGROUND,
- func(s keybase1.MobileAppState) bool {
- return s == keybase1.MobileAppState_BACKGROUNDACTIVE
- })
- return "completed 10s window"
- }
+ return kbCtx.MobileLifecycle.BackgroundSync()
}
// pushPendingMessageFailure sends at most one notification that a message
@@ -1020,136 +946,107 @@ func AppWillExit(pusher PushNotifier) {
return
}
defer kbCtx.Trace("AppWillExit", nil)()
- ctx := context.Background()
- obrs, err := kbChatCtx.MessageDeliverer.ActiveDeliveries(ctx)
+ kbCtx.MobileLifecycle.WillTerminate(func() { notifyPendingMessageFailure(pusher) })
+}
+
+// AppBackgroundTaskExpired is called when the OS is about to suspend the app
+// before the background task started by AppUIBackground finished. It
+// ends every background task hold, and warns about messages still waiting to
+// send if one was open.
+func AppBackgroundTaskExpired(pusher PushNotifier) {
+ if !isInited() {
+ return
+ }
+ defer kbCtx.Trace("AppBackgroundTaskExpired", nil)()
+ kbCtx.MobileLifecycle.BackgroundTaskExpired(func() { notifyPendingMessageFailure(pusher) })
+}
+
+// notifyPendingMessageFailure warns the user that messages still waiting to
+// send will get stuck, since we are about to be killed or suspended.
+func notifyPendingMessageFailure(pusher PushNotifier) {
+ obrs, err := kbChatCtx.MessageDeliverer.ActiveDeliveries(context.Background())
if err == nil {
- // We are about to get killed with messages still to send, let the user
- // know they will get stuck
pushPendingMessageFailure(obrs, pusher)
}
- kbCtx.MobileAppState.Update(keybase1.MobileAppState_BACKGROUND)
- flushLocalDbs()
}
-// AppDidEnterBackground notifies the service that the app is in the background
-// [iOS] returning true will request about ~3mins from iOS to continue execution
-func AppDidEnterBackground() bool {
- if !isInited() {
- return false
- }
- defer kbCtx.Trace("AppDidEnterBackground", nil)()
+func shouldStayRunningInBackground() bool {
ctx := context.Background()
convs, err := kbChatCtx.MessageDeliverer.ActiveDeliveries(ctx)
if err != nil {
- kbCtx.Log.Debug("AppDidEnterBackground: failed to get active deliveries: %s", err)
+ kbCtx.Log.Debug("shouldStayRunningInBackground: failed to get active deliveries: %s", err)
convs = nil
}
- stayRunning := false
switch {
case len(convs) > 0:
- kbCtx.Log.Debug("AppDidEnterBackground: active deliveries in progress")
- stayRunning = true
+ kbCtx.Log.Debug("shouldStayRunningInBackground: active deliveries in progress")
+ return true
case kbChatCtx.LiveLocationTracker.ActivelyTracking(ctx):
- kbCtx.Log.Debug("AppDidEnterBackground: active live location in progress")
- stayRunning = true
+ kbCtx.Log.Debug("shouldStayRunningInBackground: active live location in progress")
+ return true
case kbChatCtx.CoinFlipManager.HasActiveGames(ctx):
- kbCtx.Log.Debug("AppDidEnterBackground: active coin flip games in progress")
- stayRunning = true
- }
- if stayRunning {
- kbCtx.Log.Debug("AppDidEnterBackground: setting background active")
- kbCtx.MobileAppState.Update(keybase1.MobileAppState_BACKGROUNDACTIVE)
- flushLocalDbs()
+ kbCtx.Log.Debug("shouldStayRunningInBackground: active coin flip games in progress")
return true
}
- SetAppStateBackground()
return false
}
-func AppBeginBackgroundTaskNonblock(pusher PushNotifier) {
+// AppUIBackground reports the app off screen. It returns at once, with a
+// token for AppWaitBackgroundTask, which returns once Go needs no more time
+// in the background. It is 0 before Init, and when the UI was already in the
+// background with no background task running.
+func AppUIBackground(pusher PushNotifier) int64 {
if !isInited() {
- return
+ return 0
}
- defer kbCtx.Trace("AppBeginBackgroundTaskNonblock", nil)()
- go AppBeginBackgroundTask(pusher)
+ defer kbCtx.Trace("AppUIBackground", nil)()
+ return kbCtx.MobileLifecycle.UIBackground(backgroundTaskDeps(pusher))
}
-// AppBeginBackgroundTask notifies us that an app background task has been started on our behalf. This
-// function will return once we no longer need any time in the background.
-func AppBeginBackgroundTask(pusher PushNotifier) {
+// AppWaitBackgroundTask returns once the background task whose token
+// AppUIBackground returned no longer needs any time in the background.
+func AppWaitBackgroundTask(token int64) {
if !isInited() {
return
}
- defer kbCtx.Trace("AppBeginBackgroundTask", nil)()
- ctx := context.Background()
- // Poll active deliveries in case we can shutdown early
- beginTime := libkb.ForceWallClock(time.Now())
- ticker := time.NewTicker(5 * time.Second)
- defer ticker.Stop()
- appState := kbCtx.MobileAppState.State()
- if appState != keybase1.MobileAppState_BACKGROUNDACTIVE {
- kbCtx.Log.Debug("AppBeginBackgroundTask: not in background mode, early out")
+ defer kbCtx.Trace("AppWaitBackgroundTask", nil)()
+ kbCtx.MobileLifecycle.WaitBackgroundTask(token)
+}
+
+// AppPushWindowBegin holds a backgrounded app up while native handles a push or
+// a notification action. It returns the window's token, or 0 when the app is
+// active and nothing needs holding.
+//
+// Transitional: it exists only because Android still opens the push window from
+// Kotlin. It goes away once the bind layer wraps push handling in the window
+// itself, which is where the decision belongs -- iOS needs no window at all,
+// since it suspends the app at the push's completion handler.
+func AppPushWindowBegin() int64 {
+ if !isInited() {
+ return 0
+ }
+ defer kbCtx.Trace("AppPushWindowBegin", nil)()
+ return kbCtx.MobileLifecycle.PushWindowBegin()
+}
+
+// AppPushWindowEnd ends the window AppPushWindowBegin opened. If the UI is
+// still in the background it first hands over to a background task, which keeps
+// the app up while work must keep going; pusher warns about messages that
+// won't send. Transitional, for the same reason as AppPushWindowBegin.
+func AppPushWindowEnd(token int64, pusher PushNotifier) {
+ if !isInited() {
return
}
- var g *errgroup.Group
- g, ctx = errgroup.WithContext(ctx)
- g.Go(func() error {
- select {
- case <-kbCtx.MobileAppState.NextUpdate(appState):
- appState = kbCtx.MobileAppState.State()
- kbCtx.Log.Debug(
- "AppBeginBackgroundTask: app state change, aborting with no task shutdown: %v", appState)
- return errors.New("app state change")
- case <-ctx.Done():
- return ctx.Err()
- }
- })
- g.Go(func() error {
- ch, cancel := kbChatCtx.MessageDeliverer.NextFailure()
- defer cancel()
- select {
- case obrs := <-ch:
- kbCtx.Log.Debug(
- "AppBeginBackgroundTask: failure received, alerting the user: %d marked", len(obrs))
- pushPendingMessageFailure(obrs, pusher)
- return errors.New("failure received")
- case <-ctx.Done():
- return ctx.Err()
- }
- })
- g.Go(func() error {
- successCount := 0
- for {
- select {
- case <-ticker.C:
- obrs, err := kbChatCtx.MessageDeliverer.ActiveDeliveries(ctx)
- if err != nil {
- kbCtx.Log.Debug("AppBeginBackgroundTask: failed to query active deliveries: %s", err)
- continue
- }
- if len(obrs) == 0 {
- kbCtx.Log.Debug("AppBeginBackgroundTask: delivered everything: successCount: %d",
- successCount)
- // We can race the failure case here, so lets go a couple passes of no pending
- // convs before we abort due to ths condition.
- if successCount > 1 {
- return errors.New("delivered everything")
- }
- successCount++
- }
- curTime := libkb.ForceWallClock(time.Now())
- if curTime.Sub(beginTime) >= 10*time.Minute {
- kbCtx.Log.Debug("AppBeginBackgroundTask: failed to deliver and time is up, aborting")
- pushPendingMessageFailure(obrs, pusher)
- return errors.New("time expired")
- }
- case <-ctx.Done():
- return ctx.Err()
- }
- }
- })
- if err := g.Wait(); err != nil {
- kbCtx.Log.Debug("AppBeginBackgroundTask: dropped out of wait because: %s", err)
+ defer kbCtx.Trace("AppPushWindowEnd", nil)()
+ kbCtx.MobileLifecycle.PushWindowEnd(token, backgroundTaskDeps(pusher))
+}
+
+func backgroundTaskDeps(pusher PushNotifier) lifecycle.BackgroundTaskDeps {
+ return lifecycle.BackgroundTaskDeps{
+ Stay: shouldStayRunningInBackground,
+ ActiveDeliveries: kbChatCtx.MessageDeliverer.ActiveDeliveries,
+ NextFailure: kbChatCtx.MessageDeliverer.NextFailure,
+ NotifyFailure: func(obrs []chat1.OutboxRecord) { pushPendingMessageFailure(obrs, pusher) },
}
}
diff --git a/go/chat/maps/livelocation.go b/go/chat/maps/livelocation.go
index 54dd586b89bf..6b8f6fe24a38 100644
--- a/go/chat/maps/livelocation.go
+++ b/go/chat/maps/livelocation.go
@@ -373,15 +373,13 @@ func (l *LiveLocationTracker) LocationUpdate(ctx context.Context, coord chat1.Co
defer l.Trace(ctx, nil, "LocationUpdate")()
l.Lock()
defer l.Unlock()
- if l.G().IsMobileAppType() {
- // if the app is woken up as the result of a location update, and we think we are currently
- // backgrounded, then go ahead and mark us as background active so that we can get
- // location updates out
- l.G().MobileAppState.UpdateWithCheck(keybase1.MobileAppState_BACKGROUNDACTIVE,
- func(curState keybase1.MobileAppState) bool {
- return curState == keybase1.MobileAppState_BACKGROUND
- })
- }
+ // A fix that arrives while the app is backgrounded no longer keeps the app
+ // out of BACKGROUND: the service derives its state from the UI reports
+ // native makes and the holds background work opens, and nothing may write
+ // the state directly any more. Tracking does not yet open a hold of its
+ // own, so a long background track can go quiet once the background task
+ // that started when the UI left the screen has ended. Deliberate and
+ // temporary -- the hold arrives with the live-location rework.
if l.lastCoord.Eq(coord) {
l.Debug(ctx, "LocationUpdate: ignoring dup coordinate")
return
diff --git a/go/chat/types/interfaces.go b/go/chat/types/interfaces.go
index fed2ad4751e4..3fc7a7da8d6e 100644
--- a/go/chat/types/interfaces.go
+++ b/go/chat/types/interfaces.go
@@ -293,11 +293,6 @@ type PushHandler interface {
OobmHandler
}
-type MobileAppState interface {
- State() keybase1.MobileAppState
- NextUpdate() chan keybase1.MobileAppState
-}
-
type TeamChannelSource interface {
GetLastActiveForTLF(context.Context, gregor1.UID, chat1.TLFID, chat1.TopicType) (gregor1.Time, error)
GetLastActiveForTeams(context.Context, gregor1.UID, chat1.TopicType) (chat1.LastActiveTimeAll, error)
diff --git a/go/kbhttp/manager/manager.go b/go/kbhttp/manager/manager.go
index 50b14ae21425..a5db6f19e570 100644
--- a/go/kbhttp/manager/manager.go
+++ b/go/kbhttp/manager/manager.go
@@ -119,9 +119,12 @@ func (r *Srv) monitorAppState() {
<-r.G().MobileAppState.NextUpdate(state)
state = r.G().MobileAppState.State()
switch state {
- case keybase1.MobileAppState_FOREGROUND, keybase1.MobileAppState_BACKGROUNDACTIVE:
+ // INACTIVE means the UI is on screen without receiving events, so the
+ // server has to stay up; only BACKGROUND takes it down.
+ case keybase1.MobileAppState_FOREGROUND, keybase1.MobileAppState_BACKGROUNDACTIVE,
+ keybase1.MobileAppState_INACTIVE:
r.startHTTPSrv()
- case keybase1.MobileAppState_BACKGROUND, keybase1.MobileAppState_INACTIVE:
+ case keybase1.MobileAppState_BACKGROUND:
r.httpSrv.Stop()
}
}
diff --git a/go/libkb/appstate.go b/go/libkb/appstate.go
index c278bce2fd79..aec3d1ddfabf 100644
--- a/go/libkb/appstate.go
+++ b/go/libkb/appstate.go
@@ -1,6 +1,7 @@
package libkb
import (
+ "context"
"fmt"
"runtime"
"sync"
@@ -38,18 +39,25 @@ type MobileAppState struct {
}
func NewMobileAppState(g *GlobalContext) *MobileAppState {
- state := keybase1.MobileAppState_FOREGROUND
- if runtime.GOOS == "android" {
- // we need this so cold notifications work on android
- state = keybase1.MobileAppState_BACKGROUNDACTIVE
- }
return &MobileAppState{
Contextified: NewContextified(g),
- state: state,
+ state: initialMobileAppState(runtime.GOOS),
changed: make(chan struct{}),
}
}
+func initialMobileAppState(goos string) keybase1.MobileAppState {
+ switch goos {
+ case "android", "ios":
+ // The OS starts the process without UI for pushes, notification
+ // actions and background refresh; the first UI report, or a push
+ // window, moves it out of BACKGROUND.
+ return keybase1.MobileAppState_BACKGROUND
+ default:
+ return keybase1.MobileAppState_FOREGROUND
+ }
+}
+
// NextUpdate returns a channel that will be closed the next time the app
// state changes. If lastState does not match the current state, an
// already-closed channel is returned so the caller wakes immediately and can
@@ -69,44 +77,55 @@ func (a *MobileAppState) NextUpdate(lastState keybase1.MobileAppState) <-chan st
}
func (a *MobileAppState) updateLocked(state keybase1.MobileAppState) {
- if a.state != state {
- a.G().Log.Debug("MobileAppState.Update: useful update: %v, we are currently in state: %v",
- state, a.state)
- a.G().PerfLog.Debug("MobileAppState.Update: useful update: %v, we are currently in state: %v",
- state, a.state)
- a.state = state
- t := time.Now()
- a.mtime = &t // only update mtime if we're changing state
- close(a.changed)
- a.changed = make(chan struct{})
-
- // cancel RPCs if we go into the background
- switch a.state {
- case keybase1.MobileAppState_BACKGROUND:
- a.G().RPCCanceler.CancelLiveContexts(RPCCancelerReasonBackground)
- default:
- // Nothing to do for other states.
- }
- } else {
- a.G().Log.Debug("MobileAppState.Update: ignoring update: %v, we are currently in state: %v",
- state, a.state)
+ if a.state == state {
+ a.G().Log.Debug("MobileAppState.Update: same-value update: %v", state)
+ return
}
-}
+ a.G().Log.Debug("MobileAppState.Update: useful update: %v, we are currently in state: %v",
+ state, a.state)
+ a.G().PerfLog.Debug("MobileAppState.Update: useful update: %v, we are currently in state: %v",
+ state, a.state)
+ a.state = state
+ t := time.Now()
+ a.mtime = &t // only update mtime if we're changing state
+ close(a.changed)
+ a.changed = make(chan struct{})
-func (a *MobileAppState) UpdateWithCheck(state keybase1.MobileAppState,
- check func(keybase1.MobileAppState) bool,
-) {
- defer a.G().Trace(fmt.Sprintf("MobileAppState.UpdateWithCheck(%v)", state), nil)()
- a.Lock()
- defer a.Unlock()
- if check(a.state) {
- a.updateLocked(state)
- } else {
- a.G().Log.Debug("MobileAppState.UpdateWithCheck: skipping update, failed check")
+ // cancel RPCs if we go into the background
+ switch a.state {
+ case keybase1.MobileAppState_BACKGROUND:
+ a.G().RPCCanceler.CancelLiveContexts(RPCCancelerReasonBackground)
+ default:
+ // Nothing to do for other states.
}
+
+ // Tell connected clients from here, the one place the value changes, so no
+ // writer can add a path that moves the state without announcing it. Held
+ // under the lock on purpose: the announce is queued in the same critical
+ // section that wrote the state, and nothing it touches reads app state, so
+ // it cannot re-enter this lock.
+ //
+ // What this does not yet give a client is order. The router still hands
+ // each notification to its own goroutine, so two changes in quick
+ // succession can reach a client in either order, and the last one it
+ // applies may not be the latest. Update has a single writer --
+ // lifecycle.Controller, under Controller.mu -- so the queueing
+ // order is already the writing order; what is missing is one ordered
+ // stream per connection to carry it.
+ a.G().NotifyRouter.HandleMobileAppState(context.Background(), state)
}
-// Update updates the current app state, and notifies any waiting calls from NextUpdate
+// Update sets the current app state; only a change wakes NextUpdate callers
+// and has side effects.
+//
+// Connected clients are told from here, the one place the value changes, which
+// is also before lifecycle's Flush hook runs. On iOS that is as early as a
+// client can be told, but it is not a guarantee of delivery before suspension:
+// native keeps the app alive only until Go's background task has ended and its
+// state is written (AppDelegate.swift ends its UIKit background task once
+// AppWaitBackgroundTask returns), not until clients have received it. A client
+// acting on the notification is racing the OS, and what it can lose is bounded
+// by whatever it last wrote of its own accord.
func (a *MobileAppState) Update(state keybase1.MobileAppState) {
defer a.G().Trace(fmt.Sprintf("MobileAppState.Update(%v)", state), nil)()
a.Lock()
@@ -356,3 +375,31 @@ func (a *DesktopAppState) resetLocked() {
a.suspendChanged = make(chan struct{})
}
}
+
+// flushLocalDbs flushes the leveldb memtables in the background. An unclean
+// kill while suspended (routine on iOS) with a non-empty journal forces a
+// journal replay — or a whole-DB recovery — during the next launch, which is
+// the main cold-start cost. lifecycle calls it whenever the UI leaves the
+// screen, a hold ends or an exit event arrives while the app is in the
+// background, so the journals are empty if the OS kills the process.
+// Flushing that often, never skipping one so every write is covered, relies
+// on LevelDb.Flush being a cheap memtable flush rather than a compaction.
+func (g *GlobalContext) flushLocalDbs() {
+ flush := func(name string, db *JSONLocalDb) {
+ if db == nil {
+ return
+ }
+ ldb, ok := db.GetEngine().(*LevelDb)
+ if !ok {
+ return
+ }
+ begin := time.Now()
+ if err := ldb.Flush(); err != nil {
+ g.Log.Info("flushLocalDbs: %s flush error: %v", name, err)
+ return
+ }
+ g.Log.Info("flushLocalDbs: %s flushed in %s", name, time.Since(begin))
+ }
+ go flush("LocalDb", g.LocalDb)
+ go flush("LocalChatDb", g.LocalChatDb)
+}
diff --git a/go/libkb/appstate_test.go b/go/libkb/appstate_test.go
new file mode 100644
index 000000000000..5c33f1e1d764
--- /dev/null
+++ b/go/libkb/appstate_test.go
@@ -0,0 +1,78 @@
+// Copyright 2026 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package libkb
+
+import (
+ "context"
+ "testing"
+
+ "github.com/keybase/client/go/protocol/keybase1"
+ "github.com/stretchr/testify/require"
+)
+
+func requireClosed(t *testing.T, ch <-chan struct{}) {
+ t.Helper()
+ select {
+ case <-ch:
+ default:
+ require.Fail(t, "expected channel to be closed")
+ }
+}
+
+func requireOpen(t *testing.T, ch <-chan struct{}) {
+ t.Helper()
+ select {
+ case <-ch:
+ require.Fail(t, "expected channel to be open")
+ default:
+ }
+}
+
+func TestMobileAppStateInitialState(t *testing.T) {
+ require.Equal(t, keybase1.MobileAppState_BACKGROUND, initialMobileAppState("ios"))
+ require.Equal(t, keybase1.MobileAppState_BACKGROUND, initialMobileAppState("android"))
+ require.Equal(t, keybase1.MobileAppState_FOREGROUND, initialMobileAppState("darwin"))
+ require.Equal(t, keybase1.MobileAppState_FOREGROUND, initialMobileAppState("linux"))
+}
+
+func TestMobileAppStateSideEffectsOnlyOnChange(t *testing.T) {
+ tc := SetupTest(t, "MobileAppStateSideEffects", 0)
+ defer tc.Cleanup()
+ a := NewMobileAppState(tc.G)
+
+ a.Update(keybase1.MobileAppState_BACKGROUND)
+ _, mtime := a.StateAndMtime()
+ require.NotNil(t, mtime)
+
+ next := a.NextUpdate(keybase1.MobileAppState_BACKGROUND)
+ a.Update(keybase1.MobileAppState_BACKGROUND)
+ requireOpen(t, next)
+ _, mtime2 := a.StateAndMtime()
+ require.Same(t, mtime, mtime2)
+
+ // A stale lastState wakes immediately.
+ requireClosed(t, a.NextUpdate(keybase1.MobileAppState_FOREGROUND))
+
+ a.Update(keybase1.MobileAppState_FOREGROUND)
+ requireClosed(t, next)
+}
+
+func TestMobileAppStateBackgroundCancelsRPCsOnlyOnChange(t *testing.T) {
+ tc := SetupTest(t, "MobileAppStateCancel", 0)
+ defer tc.Cleanup()
+ a := NewMobileAppState(tc.G)
+
+ register := func() context.Context {
+ ctx, _ := tc.G.RPCCanceler.RegisterContext(context.Background(), RPCCancelerReasonBackground)
+ return ctx
+ }
+
+ first := register()
+ a.Update(keybase1.MobileAppState_BACKGROUND)
+ requireClosed(t, first.Done())
+
+ second := register()
+ a.Update(keybase1.MobileAppState_BACKGROUND)
+ requireOpen(t, second.Done())
+}
diff --git a/go/libkb/globals.go b/go/libkb/globals.go
index 68e8f6c9233a..48b622bbd4cc 100644
--- a/go/libkb/globals.go
+++ b/go/libkb/globals.go
@@ -27,6 +27,7 @@ import (
"sync"
"time"
+ "github.com/keybase/client/go/libkb/lifecycle"
logger "github.com/keybase/client/go/logger"
keybase1 "github.com/keybase/client/go/protocol/keybase1"
clockwork "github.com/keybase/clockwork"
@@ -69,6 +70,7 @@ type GlobalContext struct {
DNSNSFetcher DNSNameServerFetcher // The mobile apps potentially pass an implementor of this interface which is used to grab currently configured DNS name servers
MobileNetState *MobileNetState // The kind of network connection for the currently running instance of the app
MobileAppState *MobileAppState // The state of focus for the currently running instance of the app
+ MobileLifecycle *lifecycle.Controller // Derives MobileAppState from native UI reports and background-work holds
DesktopAppState *DesktopAppState // The state of focus for the currently running instance of the app
ChatHelper ChatHelper // conveniently send chat messages
RPCCanceler *RPCCanceler // register live RPCs so they can be cancelleed en masse
@@ -305,6 +307,10 @@ func (g *GlobalContext) Init() *GlobalContext {
g.localSigchainGuard = NewLocalSigchainGuard(g)
g.MobileNetState = NewMobileNetState(g)
g.MobileAppState = NewMobileAppState(g)
+ g.MobileLifecycle = lifecycle.New(g.MobileAppState, lifecycle.Config{
+ Flush: g.flushLocalDbs,
+ Debug: func(format string, args ...interface{}) { g.Log.Debug(format, args...) },
+ })
g.DesktopAppState = NewDesktopAppState(g)
g.RPCCanceler = NewRPCCanceler()
g.IdentifyDispatch = NewIdentifyDispatch()
@@ -836,6 +842,12 @@ func (g *GlobalContext) Shutdown(mctx MetaContext) error {
g.hiddenTeamChainManager.Shutdown(mctx)
}
+ // Ends the background tasks the controller runs before the chat
+ // services they poll go away.
+ if g.MobileLifecycle != nil {
+ g.MobileLifecycle.Close()
+ }
+
if g.NotifyRouter != nil {
g.NotifyRouter.Shutdown()
}
diff --git a/go/libkb/lifecycle/controller_test.go b/go/libkb/lifecycle/controller_test.go
new file mode 100644
index 000000000000..4043bbd1b538
--- /dev/null
+++ b/go/libkb/lifecycle/controller_test.go
@@ -0,0 +1,467 @@
+// Copyright 2026 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package lifecycle_test
+
+import (
+ "context"
+ "errors"
+ "math/rand"
+ "runtime"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/keybase/client/go/libkb"
+ "github.com/keybase/client/go/libkb/lifecycle"
+ "github.com/keybase/client/go/libkb/lifecycle/lifecycletest"
+ "github.com/keybase/client/go/protocol/chat1"
+ "github.com/keybase/client/go/protocol/keybase1"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ foreground = keybase1.MobileAppState_FOREGROUND
+ background = keybase1.MobileAppState_BACKGROUND
+ backgroundActive = keybase1.MobileAppState_BACKGROUNDACTIVE
+ inactive = keybase1.MobileAppState_INACTIVE
+)
+
+func noop() {}
+
+// noDeliveries starts a task that finds nothing to deliver; stay says whether
+// it keeps polling first.
+func noDeliveries(stay bool) lifecycle.BackgroundTaskDeps {
+ return lifecycle.BackgroundTaskDeps{
+ Stay: func() bool { return stay },
+ ActiveDeliveries: func(context.Context) ([]chat1.OutboxRecord, error) { return nil, nil },
+ NextFailure: func() (chan []chat1.OutboxRecord, func()) {
+ return make(chan []chat1.OutboxRecord), func() {}
+ },
+ NotifyFailure: func([]chat1.OutboxRecord) {},
+ }
+}
+
+// An id is never reused, so releasing an old hold again can't end a newer one.
+func TestHoldReleaseIsIdempotent(t *testing.T) {
+ appState, _ := newAppState(t)
+ c := lifecycle.New(appState, lifecycle.Config{})
+ token := c.UIBackground(noDeliveries(false))
+ require.Positive(t, token)
+ c.WaitBackgroundTask(token)
+ require.Equal(t, background, appState.State())
+ first := c.AcquireBackgroundWork()
+ require.Equal(t, backgroundActive, appState.State())
+ require.True(t, first.Release())
+ require.Zero(t, lifecycle.Holds(c))
+ require.Equal(t, background, appState.State())
+ second := c.AcquireBackgroundWork()
+ require.False(t, first.Release())
+ // The stale Release left the newer hold alone.
+ require.Equal(t, 1, lifecycle.Holds(c))
+ require.Equal(t, backgroundActive, appState.State())
+ require.True(t, second.Release())
+ require.Equal(t, background, appState.State())
+}
+
+// With the resulting state BACKGROUNDACTIVE or BACKGROUND, every change from
+// anything but BACKGROUND, every hold's end and every exit event flushes, once
+// per event. Nothing flushes in FOREGROUND or INACTIVE.
+func TestFlushRule(t *testing.T) {
+ appState, _ := newAppState(t)
+ flushes := 0
+ c := lifecycle.New(appState, lifecycle.Config{Flush: func() { flushes++ }})
+ defer c.Close()
+ c.UIActive()
+ flushesAfter := func(what string, do func(), want keybase1.MobileAppState, n int) {
+ t.Helper()
+ before := flushes
+ do()
+ require.Equal(t, want, appState.State(), what)
+ require.Equal(t, n, flushes-before, what)
+ }
+ var hold *lifecycle.Hold
+ acquire := func() { hold = c.AcquireBackgroundWork() }
+ release := func() { require.True(t, hold.Release()) }
+
+ // The background task finds nothing to keep running and ends at once.
+ flushesAfter("plain backgrounding, then the task's end", func() { lifecycletest.ToBackground(c) }, background, 2)
+ flushesAfter("a hold starting", acquire, backgroundActive, 0)
+ flushesAfter("a hold's end", release, background, 1)
+ flushesAfter("a hold's end right after the last flush", func() { acquire(); release() }, background, 1)
+ flushesAfter("termination already in the background", func() { c.WillTerminate(noop) }, background, 1)
+ flushesAfter("expiration already in the background", func() { c.BackgroundTaskExpired(noop) }, background, 1)
+
+ outer := c.AcquireBackgroundWork()
+ flushesAfter("a hold starting under another", acquire, backgroundActive, 0)
+ flushesAfter("a hold's end while another stays open", release, backgroundActive, 1)
+ flushesAfter("the last hold's end", func() { require.True(t, outer.Release()) }, background, 1)
+
+ flushesAfter("coming to the foreground", c.UIActive, foreground, 0)
+ flushesAfter("expiration in the foreground", func() { c.BackgroundTaskExpired(noop) }, foreground, 0)
+ flushesAfter("a hold's end in the foreground", func() { acquire(); release() }, foreground, 0)
+ flushesAfter("going inactive", c.UIInactive, inactive, 0)
+ flushesAfter("expiration while inactive", func() { c.BackgroundTaskExpired(noop) }, inactive, 0)
+ flushesAfter("coming back from inactive", c.UIActive, foreground, 0)
+ flushesAfter("leaving the UI while a hold keeps the app running, then the task's end", func() {
+ acquire()
+ lifecycletest.ToBackground(c)
+ }, backgroundActive, 2)
+ flushesAfter("expiration with a hold still open", func() { c.BackgroundTaskExpired(noop) }, backgroundActive, 1)
+ flushesAfter("that hold's end", release, background, 1)
+ c.UIActive()
+ flushesAfter("termination from the foreground", func() { c.WillTerminate(noop) }, background, 1)
+}
+
+// Close waits for the running background tasks, so no later call may start
+// one: a task that joined the wait afterwards would be a WaitGroup misuse.
+func TestNoTaskStartsAfterClose(t *testing.T) {
+ appState, _ := newAppState(t)
+ appState.Update(background)
+ c := lifecycle.New(appState, lifecycle.Config{})
+ c.Close()
+
+ c.UIInactive()
+ require.Zero(t, c.UIBackground(noDeliveries(true)), "UIBackground started a task after Close")
+ require.Zero(t, lifecycle.Holds(c))
+ require.Equal(t, background, appState.State())
+
+ // PushWindowEnd's hand-over to a task is gated the same way; the push
+ // window's own hold is not.
+ push := c.PushWindowBegin()
+ require.Positive(t, push)
+ require.Equal(t, backgroundActive, appState.State())
+ require.Zero(t, c.PushWindowEnd(push, noDeliveries(true)), "PushWindowEnd started a task after Close")
+ require.Zero(t, lifecycle.Holds(c))
+ require.Equal(t, background, appState.State())
+}
+
+func TestExpirationEndsOnlyBackgroundTaskHolds(t *testing.T) {
+ appState, _ := newAppState(t)
+ appState.Update(background)
+ c := lifecycle.New(appState, lifecycle.Config{})
+ defer c.Close()
+ c.UIInactive()
+ require.Positive(t, c.UIBackground(noDeliveries(true)))
+ push := c.PushWindowBegin()
+ live := c.AcquireBackgroundWork()
+ notified := 0
+ c.BackgroundTaskExpired(func() { notified++ })
+ require.Equal(t, 1, notified)
+ require.Equal(t, 2, lifecycle.Holds(c))
+ require.Equal(t, backgroundActive, appState.State())
+ c.BackgroundTaskExpired(func() { notified++ })
+ require.Equal(t, 1, notified, "nothing was left to expire")
+ c.WaitBackgroundTask(c.PushWindowEnd(push, noDeliveries(false)))
+ require.True(t, live.Release())
+ require.Equal(t, background, appState.State())
+}
+
+// A start while a background task runs, from a duplicate didEnterBackground
+// or a push window's end, joins that task: one task keeps the app up, and a
+// failed message is warned about once.
+func TestBackgroundTaskStartsJoinTheRunningTask(t *testing.T) {
+ appState, _ := newAppState(t)
+ appState.Update(background)
+ c := lifecycle.New(appState, lifecycle.Config{})
+ defer c.Close()
+ var mu sync.Mutex
+ var subscribers []chan []chat1.OutboxRecord
+ subscribed := make(chan struct{}, 10)
+ var notified atomic.Int32
+ deps := noDeliveries(true)
+ deps.NextFailure = func() (chan []chat1.OutboxRecord, func()) {
+ ch := make(chan []chat1.OutboxRecord, 1)
+ mu.Lock()
+ subscribers = append(subscribers, ch)
+ mu.Unlock()
+ subscribed <- struct{}{}
+ return ch, func() {}
+ }
+ deps.NotifyFailure = func([]chat1.OutboxRecord) { notified.Add(1) }
+
+ c.UIInactive()
+ first := c.UIBackground(deps)
+ require.Positive(t, first)
+ select {
+ case <-subscribed:
+ case <-time.After(5 * time.Second):
+ require.Fail(t, "the background task never watched for failures")
+ }
+ require.Equal(t, first, c.UIBackground(deps), "a duplicate didEnterBackground")
+ push := c.PushWindowBegin()
+ require.Equal(t, first, c.PushWindowEnd(push, deps), "a push window's end")
+ require.Equal(t, 1, lifecycle.Holds(c))
+
+ // The outbox tells every watcher about a failure.
+ mu.Lock()
+ for _, ch := range subscribers {
+ ch <- make([]chat1.OutboxRecord, 1)
+ }
+ mu.Unlock()
+ c.WaitBackgroundTask(first)
+ require.Equal(t, background, appState.State())
+ require.EqualValues(t, 1, notified.Load())
+}
+
+// startPolledTask starts a background task on a fake clock. Its done closes
+// once the task has ended.
+func startPolledTask(t *testing.T, maxDuration time.Duration, deps lifecycle.BackgroundTaskDeps) (
+ appState *libkb.MobileAppState, clock *lifecycletest.FakeClock, done chan struct{},
+) {
+ appState, _ = newAppState(t)
+ appState.Update(background)
+ clock = lifecycletest.NewFakeClock()
+ c := lifecycle.New(appState, lifecycle.Config{
+ Clock: clock,
+ BackgroundTaskPollInterval: pollInterval,
+ BackgroundTaskMaxDuration: maxDuration,
+ })
+ t.Cleanup(c.Close)
+ c.UIInactive()
+ token := c.UIBackground(deps)
+ require.Positive(t, token)
+ done = make(chan struct{})
+ go func() {
+ defer close(done)
+ c.WaitBackgroundTask(token)
+ }()
+ return appState, clock, done
+}
+
+const pollInterval = 5 * time.Second
+
+// advancePolls lets a background task poll until it ends, at most limit
+// times, and returns how many polls it took.
+func advancePolls(t *testing.T, clock *lifecycletest.FakeClock, done chan struct{}, limit int) int {
+ for n := range limit {
+ if !clock.WaitForAfter(t, pollInterval, done) {
+ return n
+ }
+ clock.Advance(pollInterval)
+ }
+ return limit
+}
+
+// The maximum duration holds even while the outbox can't be read.
+func TestBackgroundTaskTimesOutWhileDeliveriesFail(t *testing.T) {
+ var notified atomic.Int32
+ deps := noDeliveries(true)
+ deps.ActiveDeliveries = func(context.Context) ([]chat1.OutboxRecord, error) {
+ return nil, errors.New("outbox unavailable")
+ }
+ deps.NotifyFailure = func([]chat1.OutboxRecord) { notified.Add(1) }
+ appState, clock, done := startPolledTask(t, 3*pollInterval, deps)
+ require.Equal(t, 3, advancePolls(t, clock, done, 10), "the task outlived its maximum duration")
+ require.EqualValues(t, 1, notified.Load())
+ require.Equal(t, background, appState.State())
+}
+
+// Work that reappears starts the count of idle polls over.
+func TestBackgroundTaskNeedsIdlePollsInARow(t *testing.T) {
+ // The first answer is the task's check before it polls.
+ stay := []bool{true, false, false, true, false, false, false}
+ var asked atomic.Int32
+ var notified atomic.Int32
+ deps := noDeliveries(true)
+ deps.Stay = func() bool {
+ if i := int(asked.Add(1)) - 1; i < len(stay) {
+ return stay[i]
+ }
+ return false
+ }
+ deps.NotifyFailure = func([]chat1.OutboxRecord) { notified.Add(1) }
+ appState, clock, done := startPolledTask(t, lifecycle.DefaultBackgroundTaskMaxDuration, deps)
+ require.Equal(t, len(stay)-1, advancePolls(t, clock, done, 10), "the task ended with work still running")
+ require.Zero(t, notified.Load())
+ require.Equal(t, background, appState.State())
+}
+
+// Work other than a delivery, such as a coin flip or live location, keeps the
+// task holding with an empty outbox until its maximum duration.
+func TestBackgroundTaskHoldsWhileStayWithNothingToDeliver(t *testing.T) {
+ var notified atomic.Int32
+ deps := noDeliveries(true)
+ deps.NotifyFailure = func([]chat1.OutboxRecord) { notified.Add(1) }
+ appState, clock, done := startPolledTask(t, 6*pollInterval, deps)
+ require.Equal(t, 6, advancePolls(t, clock, done, 10), "the task ended while work had to keep running")
+ require.EqualValues(t, 1, notified.Load())
+ require.Equal(t, background, appState.State())
+}
+
+// Native gives these last events only a short wait, so the state change and
+// any flush must happen before the slow pending-message warning.
+func TestExitEventsApplyBeforeNotifying(t *testing.T) {
+ backgroundTask := func(c *lifecycle.Controller) {
+ require.Positive(t, c.UIBackground(noDeliveries(true)))
+ }
+ events := map[string]struct {
+ prepare func(c *lifecycle.Controller)
+ do func(c *lifecycle.Controller, notifyPending func())
+ flushes int
+ }{
+ "willTerminate": {
+ prepare: func(c *lifecycle.Controller) { c.UIActive() },
+ do: func(c *lifecycle.Controller, notifyPending func()) { c.WillTerminate(notifyPending) },
+ flushes: 1,
+ },
+ "willTerminate in the background": {
+ prepare: backgroundTask,
+ do: func(c *lifecycle.Controller, notifyPending func()) { c.WillTerminate(notifyPending) },
+ flushes: 1,
+ },
+ "backgroundTaskExpired": {
+ prepare: backgroundTask,
+ do: func(c *lifecycle.Controller, notifyPending func()) { c.BackgroundTaskExpired(notifyPending) },
+ flushes: 1,
+ },
+ }
+ for name, event := range events {
+ t.Run(name, func(t *testing.T) {
+ appState, _ := newAppState(t)
+ var flushes int
+ c := lifecycle.New(appState, lifecycle.Config{Flush: func() { flushes++ }})
+ defer c.Close()
+ event.prepare(c)
+ flushesBefore := flushes
+ notified := false
+ event.do(c, func() {
+ notified = true
+ require.Equal(t, background, appState.State())
+ require.Equal(t, flushesBefore+event.flushes, flushes)
+ })
+ require.True(t, notified)
+ })
+ }
+}
+
+// Hold owners run concurrently with UI reports, then each phase ends on known
+// last reports and checks nothing is left holding the app up: FOREGROUND
+// stays FOREGROUND and a background UI with no work is BACKGROUND. Owner
+// goroutines and the background tasks the controller runs must all exit.
+func TestHoldsStress(t *testing.T) {
+ appState, _ := newAppState(t)
+ appState.Update(background)
+ c := lifecycle.New(appState, lifecycle.Config{
+ BackgroundSyncWindow: 200 * time.Microsecond,
+ BackgroundTaskPollInterval: time.Millisecond,
+ BackgroundTaskMaxDuration: time.Minute,
+ })
+ defer c.Close()
+ baseline := runtime.NumGoroutine()
+
+ chaos := func(t *testing.T, iterations int) {
+ var owners sync.WaitGroup
+ lifecycleDone := make(chan struct{})
+ runOwner := func(f func(r *rand.Rand)) {
+ owners.Add(1)
+ go func(seed int64) {
+ defer owners.Done()
+ r := rand.New(rand.NewSource(seed))
+ for {
+ select {
+ case <-lifecycleDone:
+ return
+ default:
+ }
+ f(r)
+ }
+ }(rand.Int63())
+ }
+ for range 3 {
+ runOwner(func(r *rand.Rand) {
+ token := c.PushWindowBegin()
+ if r.Intn(2) == 0 {
+ time.Sleep(time.Duration(r.Intn(100)) * time.Microsecond)
+ }
+ c.PushWindowEnd(token, noDeliveries(r.Intn(3) == 0))
+ })
+ runOwner(func(*rand.Rand) { c.BackgroundSync() })
+ runOwner(func(*rand.Rand) { c.BackgroundTaskExpired(noop) })
+ runOwner(func(r *rand.Rand) {
+ h := c.AcquireBackgroundWork()
+ time.Sleep(time.Duration(r.Intn(100)) * time.Microsecond)
+ h.Release()
+ })
+ }
+
+ r := rand.New(rand.NewSource(time.Now().UnixNano()))
+ for range iterations {
+ switch r.Intn(5) {
+ case 0:
+ c.UIActive()
+ case 1:
+ c.UIInactive()
+ case 2:
+ c.UIBackground(noDeliveries(r.Intn(2) == 0))
+ case 3:
+ c.UIBackground(noDeliveries(false))
+ case 4:
+ if r.Intn(10) == 0 {
+ c.WillTerminate(noop)
+ }
+ }
+ time.Sleep(time.Duration(r.Intn(50)) * time.Microsecond)
+ }
+ c.UIActive()
+ close(lifecycleDone)
+ waitGroupWithin(t, &owners, "owners deadlocked")
+ require.Equal(t, foreground, appState.State())
+ require.Equal(t, 0, lifecycle.Holds(c))
+ }
+
+ t.Run("ends in foreground", func(t *testing.T) {
+ chaos(t, 300)
+ })
+
+ t.Run("ends in background", func(t *testing.T) {
+ chaos(t, 300)
+ c.WaitBackgroundTask(c.UIBackground(noDeliveries(false)))
+ require.Equal(t, background, appState.State())
+ require.Equal(t, 0, lifecycle.Holds(c))
+ })
+
+ t.Run("concurrent holds end in background", func(t *testing.T) {
+ for range 50 {
+ chaos(t, 20)
+ c.WaitBackgroundTask(c.UIBackground(noDeliveries(false)))
+ var holders sync.WaitGroup
+ for range 4 {
+ holders.Add(1)
+ go func() {
+ defer holders.Done()
+ c.AcquireBackgroundWork().Release()
+ }()
+ }
+ waitGroupWithin(t, &holders, "holders deadlocked")
+ require.Equal(t, background, appState.State())
+ require.Equal(t, 0, lifecycle.Holds(c))
+ }
+ })
+
+ // require.Eventually runs its condition on extra goroutines, so poll by hand.
+ settled := runtime.NumGoroutine()
+ for deadline := time.Now().Add(5 * time.Second); settled > baseline && time.Now().Before(deadline); {
+ time.Sleep(10 * time.Millisecond)
+ settled = runtime.NumGoroutine()
+ }
+ require.LessOrEqual(t, settled, baseline, "leaked goroutines")
+ t.Logf("goroutines: baseline %d, settled %d", baseline, settled)
+}
+
+func waitGroupWithin(t *testing.T, wg *sync.WaitGroup, msg string) {
+ t.Helper()
+ done := make(chan struct{})
+ go func() {
+ wg.Wait()
+ close(done)
+ }()
+ select {
+ case <-done:
+ case <-time.After(30 * time.Second):
+ require.Fail(t, msg)
+ }
+}
+
+var _ lifecycle.AppState = (*libkb.MobileAppState)(nil)
diff --git a/go/libkb/lifecycle/export_test.go b/go/libkb/lifecycle/export_test.go
new file mode 100644
index 000000000000..cd2cd9673b58
--- /dev/null
+++ b/go/libkb/lifecycle/export_test.go
@@ -0,0 +1,10 @@
+// Copyright 2026 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package lifecycle
+
+func Holds(c *Controller) int {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return len(c.holds)
+}
diff --git a/go/libkb/lifecycle/lifecycle.go b/go/libkb/lifecycle/lifecycle.go
new file mode 100644
index 000000000000..cce4f73ff955
--- /dev/null
+++ b/go/libkb/lifecycle/lifecycle.go
@@ -0,0 +1,528 @@
+// Copyright 2026 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+// Package lifecycle derives the mobile app's MobileAppState from the UI state
+// native code reports and the background work that must keep running:
+// FOREGROUND and INACTIVE follow the UI, and a background UI is
+// BACKGROUNDACTIVE while any hold is open and BACKGROUND otherwise.
+//
+// It must not import libkb: libkb holds a Controller, and libkb's own tests
+// drive it.
+package lifecycle
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/keybase/client/go/protocol/chat1"
+ "github.com/keybase/client/go/protocol/keybase1"
+ "github.com/keybase/clockwork"
+ "golang.org/x/sync/errgroup"
+)
+
+type UIState string
+
+const (
+ UIBackground UIState = "background"
+ UIInactive UIState = "inactive"
+ UIActive UIState = "active"
+)
+
+// Reason says what a hold keeps running, and so which events end it.
+type Reason string
+
+const (
+ ReasonBackgroundTask Reason = "backgroundTask"
+ ReasonBackgroundSync Reason = "backgroundSync"
+ ReasonPushWindow Reason = "pushWindow"
+ ReasonLiveLocation Reason = "liveLocation"
+)
+
+// AppState is the part of libkb.MobileAppState the controller drives.
+type AppState interface {
+ State() keybase1.MobileAppState
+ Update(state keybase1.MobileAppState)
+ NextUpdate(lastState keybase1.MobileAppState) <-chan struct{}
+}
+
+const (
+ DefaultBackgroundSyncWindow = 10 * time.Second
+ DefaultBackgroundTaskPollInterval = 5 * time.Second
+ DefaultBackgroundTaskMaxDuration = 10 * time.Minute
+)
+
+// Config holds the controller's dependencies. Zero fields get defaults: the
+// real clock, the default durations, and no-op hooks.
+type Config struct {
+ Clock clockwork.Clock
+ BackgroundSyncWindow time.Duration
+ BackgroundTaskPollInterval time.Duration
+ BackgroundTaskMaxDuration time.Duration
+ // Flush runs on every move into the background, and on every hold's end
+ // and exit event in the background (see applyLocked). It runs under the
+ // controller's lock, so it must not block, and often, so it must be cheap:
+ // a memtable flush, not a compaction.
+ Flush func()
+ Debug func(format string, args ...interface{})
+}
+
+type BackgroundTaskDeps struct {
+ // Stay reports whether any work must keep a backgrounded app running. A
+ // task asks it at the start and on every poll, off the controller's lock:
+ // answering reads the outbox.
+ Stay func() bool
+ // ActiveDeliveries names the messages still sending when the task runs out
+ // of time, for the failure notice.
+ ActiveDeliveries func(context.Context) ([]chat1.OutboxRecord, error)
+ NextFailure func() (chan []chat1.OutboxRecord, func())
+ NotifyFailure func([]chat1.OutboxRecord)
+}
+
+// Hold keeps a backgrounded app BACKGROUNDACTIVE until it is released or the
+// controller ends it.
+type Hold struct {
+ c *Controller
+ id int64
+ reason Reason
+ // done is closed once the hold has ended, by Release or by the controller.
+ done chan struct{}
+}
+
+// Released reports whether the hold has ended, by Release or by the
+// controller. Release cannot stand in for it: on a hold that is still open,
+// asking that way would end it.
+func (h *Hold) Released() bool {
+ select {
+ case <-h.done:
+ return true
+ default:
+ return false
+ }
+}
+
+// Release ends the hold. It reports whether this call ended it; ending a hold
+// again, or one the controller already ended, does nothing.
+func (h *Hold) Release() bool { return h.c.release(h) }
+
+type Controller struct {
+ appState AppState
+ cfg Config
+ // ctx ends the background tasks the controller runs; see Close.
+ ctx context.Context
+ cancel context.CancelFunc
+
+ // wg counts the background task goroutines Close waits for.
+ wg sync.WaitGroup
+
+ // mu serializes every UI report and hold change with the state it writes.
+ mu sync.Mutex
+ ui UIState
+ nextID int64
+ holds map[int64]*Hold
+ // closed stops new tasks once Close is waiting for the running ones, so
+ // nothing joins wg while Close waits on it.
+ closed bool
+ // holdEnded records that dropLocked ended a hold since the last
+ // applyLocked, which flushes for it.
+ holdEnded bool
+}
+
+func New(appState AppState, cfg Config) *Controller {
+ if cfg.Clock == nil {
+ cfg.Clock = clockwork.NewRealClock()
+ }
+ if cfg.BackgroundSyncWindow == 0 {
+ cfg.BackgroundSyncWindow = DefaultBackgroundSyncWindow
+ }
+ if cfg.BackgroundTaskPollInterval == 0 {
+ cfg.BackgroundTaskPollInterval = DefaultBackgroundTaskPollInterval
+ }
+ if cfg.BackgroundTaskMaxDuration == 0 {
+ cfg.BackgroundTaskMaxDuration = DefaultBackgroundTaskMaxDuration
+ }
+ if cfg.Flush == nil {
+ cfg.Flush = func() {}
+ }
+ if cfg.Debug == nil {
+ cfg.Debug = func(string, ...interface{}) {}
+ }
+ c := &Controller{appState: appState, cfg: cfg, holds: make(map[int64]*Hold)}
+ c.ctx, c.cancel = context.WithCancel(context.Background())
+ switch appState.State() {
+ case keybase1.MobileAppState_FOREGROUND:
+ c.ui = UIActive
+ case keybase1.MobileAppState_INACTIVE:
+ c.ui = UIInactive
+ default:
+ c.ui = UIBackground
+ }
+ return c
+}
+
+// Close ends the background tasks the controller runs and waits for them to
+// return. No task starts after it.
+func (c *Controller) Close() {
+ c.cancel()
+ c.mu.Lock()
+ c.closed = true
+ c.mu.Unlock()
+ c.wg.Wait()
+}
+
+func derive(ui UIState, holds int) keybase1.MobileAppState {
+ switch {
+ case ui == UIActive:
+ return keybase1.MobileAppState_FOREGROUND
+ case ui == UIInactive:
+ return keybase1.MobileAppState_INACTIVE
+ case holds > 0:
+ return keybase1.MobileAppState_BACKGROUNDACTIVE
+ default:
+ return keybase1.MobileAppState_BACKGROUND
+ }
+}
+
+func (c *Controller) debugLocked(event string, format string, args ...interface{}) {
+ c.cfg.Debug("lifecycle: %s: %s (ui: %v, holds: %d, state: %v)", event, fmt.Sprintf(format, args...),
+ c.ui, len(c.holds), c.appState.State())
+}
+
+func isUIState(state keybase1.MobileAppState) bool {
+ return state == keybase1.MobileAppState_FOREGROUND || state == keybase1.MobileAppState_INACTIVE
+}
+
+// applyLocked writes the derived state and flushes the local DBs, so an
+// unclean kill doesn't cost a journal replay at the next launch. It flushes
+// when the resulting state is BACKGROUNDACTIVE or BACKGROUND and one of these
+// happened: the state changed from anything but BACKGROUND (the UI leaving the
+// screen covers the foreground session's writes), a hold ended (covering the
+// hold's writes, even while another hold keeps the app running, since a live
+// location hold can last for hours), or exit is set (the process is about to
+// be killed or suspended, so writes of holds still open are covered too). One
+// event flushes once, whichever of these apply. A hold starting (BACKGROUND
+// to BACKGROUNDACTIVE) has written nothing yet. In FOREGROUND and INACTIVE
+// nothing flushes: the UI's own departure will.
+//
+// Every write is covered only because no flush is skipped, which relies on
+// Flush being cheap.
+func (c *Controller) applyLocked(event string, exit bool) {
+ prev := c.appState.State()
+ state := derive(c.ui, len(c.holds))
+ c.appState.Update(state)
+ holdEnded := c.holdEnded
+ c.holdEnded = false
+ if isUIState(state) {
+ return
+ }
+ moved := state != prev && prev != keybase1.MobileAppState_BACKGROUND
+ if moved || holdEnded || exit {
+ c.debugLocked(event, "flushing: %v -> %v, hold ended: %v, exit: %v", prev, state, holdEnded, exit)
+ c.cfg.Flush()
+ }
+}
+
+func (c *Controller) acquireLocked(reason Reason) *Hold {
+ c.nextID++
+ h := &Hold{c: c, id: c.nextID, reason: reason, done: make(chan struct{})}
+ c.holds[h.id] = h
+ return h
+}
+
+// dropLocked ends every hold match selects and returns how many it ended. It
+// is the one place holds end; the next applyLocked flushes for them.
+func (c *Controller) dropLocked(match func(*Hold) bool) (dropped int) {
+ for id, h := range c.holds {
+ if match(h) {
+ delete(c.holds, id)
+ close(h.done)
+ c.holdEnded = true
+ dropped++
+ }
+ }
+ return dropped
+}
+
+// setUILocked records a UI report. Leaving the background ends the holds that
+// only keep a backgrounded app alive.
+func (c *Controller) setUILocked(ui UIState) {
+ if c.ui == UIBackground && ui != UIBackground {
+ c.dropLocked(func(h *Hold) bool { return h.reason == ReasonBackgroundTask || h.reason == ReasonBackgroundSync })
+ }
+ c.ui = ui
+}
+
+// runningTaskLocked returns the open background task hold's id, or 0.
+func (c *Controller) runningTaskLocked() int64 {
+ for id, h := range c.holds {
+ if h.reason == ReasonBackgroundTask {
+ return id
+ }
+ }
+ return 0
+}
+
+// startTaskLocked opens a background task hold and runs the task that keeps
+// it until the work is done. A background task hold that is already open is
+// reused instead, so one task at a time keeps the app up and warns about
+// failures, and a later start doesn't extend its maximum duration.
+func (c *Controller) startTaskLocked(deps BackgroundTaskDeps) int64 {
+ if c.closed {
+ return 0
+ }
+ if id := c.runningTaskLocked(); id != 0 {
+ return id
+ }
+ h := c.acquireLocked(ReasonBackgroundTask)
+ c.wg.Add(1)
+ go func() {
+ defer c.wg.Done()
+ c.runBackgroundTask(h, deps)
+ }()
+ return h.id
+}
+
+// AcquireBackgroundWork opens a live location hold, which keeps a backgrounded
+// app BACKGROUNDACTIVE until it is released. Of the controller's events only
+// WillTerminate ends it, which is why it is the one hold callers may open for
+// themselves -- and why a caller holding one past a WillTerminate must check
+// Released before it counts on it.
+func (c *Controller) AcquireBackgroundWork() *Hold {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ h := c.acquireLocked(ReasonLiveLocation)
+ c.applyLocked("acquire", false)
+ c.debugLocked("acquire", "%v hold %d", h.reason, h.id)
+ return h
+}
+
+func (c *Controller) release(h *Hold) bool {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.dropLocked(func(o *Hold) bool { return o == h }) == 0 {
+ return false
+ }
+ c.applyLocked("release", false)
+ c.debugLocked("release", "%v hold %d", h.reason, h.id)
+ return true
+}
+
+func (c *Controller) UIActive() {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.setUILocked(UIActive)
+ c.applyLocked("uiActive", false)
+ c.debugLocked("uiActive", "applied")
+}
+
+// UIInactive covers the app on screen without receiving events (Control
+// Center, alerts, the app switcher, iPad focus loss) and a scene or process
+// coming to the foreground before it is active.
+func (c *Controller) UIInactive() {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.setUILocked(UIInactive)
+ c.applyLocked("uiInactive", false)
+ c.debugLocked("uiInactive", "applied")
+}
+
+// UIBackground records the UI leaving the screen and starts a background task,
+// which keeps the app BACKGROUNDACTIVE while work must keep going and ends at
+// once when none does. It returns the task hold's token for
+// WaitBackgroundTask, or 0 once the controller is closed.
+//
+// A report while the UI is already in the background starts nothing -- a new
+// task would take the app through BACKGROUNDACTIVE and back for no reason. It
+// returns the running task's token, or 0. Android reports this after a
+// finishing activity's willExit, once the process stops.
+func (c *Controller) UIBackground(deps BackgroundTaskDeps) int64 {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.ui == UIBackground {
+ token := c.runningTaskLocked()
+ c.debugLocked("uiBackground", "already in the background, background task hold %d", token)
+ return token
+ }
+ c.setUILocked(UIBackground)
+ token := c.startTaskLocked(deps)
+ c.applyLocked("uiBackground", false)
+ c.debugLocked("uiBackground", "background task hold %d", token)
+ return token
+}
+
+// WaitBackgroundTask returns once the hold at token has ended, which is what
+// native is asking about: whether Go still needs background time. Ids are
+// never reused, so no entry means the hold has already ended.
+func (c *Controller) WaitBackgroundTask(token int64) {
+ c.mu.Lock()
+ h := c.holds[token]
+ c.mu.Unlock()
+ if h == nil {
+ return
+ }
+ <-h.done
+ // A hold's done closes under the lock, before the state its end derives is
+ // written; taking the lock again waits for that write, so a caller that
+ // gives up its background time never leaves a stale state behind.
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.debugLocked("waitBackgroundTask", "hold %d ended", token)
+}
+
+// WillTerminate ends every hold: the process is about to die. notifyPending
+// warns about messages that won't send; it runs last because it can take
+// seconds and native waits only briefly.
+func (c *Controller) WillTerminate(notifyPending func()) {
+ c.mu.Lock()
+ c.setUILocked(UIBackground)
+ c.dropLocked(func(*Hold) bool { return true })
+ c.applyLocked("willTerminate", true)
+ c.debugLocked("willTerminate", "ended every hold")
+ c.mu.Unlock()
+ notifyPending()
+}
+
+// BackgroundTaskExpired ends every background task hold: iOS is ending the
+// app's background time, which is per app, so every UIKit task still open
+// expires with it. Live location, push window and sync holds keep their own
+// lifetimes.
+func (c *Controller) BackgroundTaskExpired(notifyPending func()) {
+ c.mu.Lock()
+ ended := c.dropLocked(func(h *Hold) bool { return h.reason == ReasonBackgroundTask })
+ c.applyLocked("backgroundTaskExpired", true)
+ c.debugLocked("backgroundTaskExpired", "ended %d background task holds", ended)
+ c.mu.Unlock()
+ if ended > 0 {
+ notifyPending()
+ }
+}
+
+// PushWindowBegin holds the app up while a push is handled. It returns the
+// hold's token, or 0 when the app is active and nothing needs holding.
+func (c *Controller) PushWindowBegin() int64 {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.ui == UIActive {
+ c.debugLocked("pushWindowBegin", "skipped in the foreground")
+ return 0
+ }
+ h := c.acquireLocked(ReasonPushWindow)
+ c.applyLocked("pushWindowBegin", false)
+ c.debugLocked("pushWindowBegin", "hold %d", h.id)
+ return h.id
+}
+
+// PushWindowEnd ends the push window's hold. If the UI is still in the
+// background, it first hands over to a background task, which keeps the app
+// up while work must keep going. The token it returns is for the test harness;
+// native ignores it.
+func (c *Controller) PushWindowEnd(token int64, deps BackgroundTaskDeps) int64 {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ var task int64
+ if h, ok := c.holds[token]; ok && h.reason == ReasonPushWindow {
+ if c.ui == UIBackground {
+ task = c.startTaskLocked(deps)
+ }
+ c.dropLocked(func(o *Hold) bool { return o == h })
+ c.applyLocked("pushWindowEnd", false)
+ }
+ c.debugLocked("pushWindowEnd", "hold %d ended, background task hold %d", token, task)
+ return task
+}
+
+// BackgroundSync holds the app up for the sync window while the UI is in the
+// background. It returns a status for native logs.
+func (c *Controller) BackgroundSync() string {
+ c.mu.Lock()
+ if c.ui != UIBackground {
+ msg := "skipping, app not in background state: " + c.appState.State().String()
+ c.debugLocked("backgroundSyncBegin", "%s", msg)
+ c.mu.Unlock()
+ return msg
+ }
+ h := c.acquireLocked(ReasonBackgroundSync)
+ c.applyLocked("backgroundSyncBegin", false)
+ c.debugLocked("backgroundSyncBegin", "hold %d", h.id)
+ c.mu.Unlock()
+ var msg string
+ select {
+ case <-h.done:
+ msg = "bailing out early, hold ended: " + c.appState.State().String()
+ case <-c.cfg.Clock.After(c.cfg.BackgroundSyncWindow):
+ msg = "completed window"
+ }
+ h.Release()
+ c.cfg.Debug("lifecycle: backgroundSyncEnd: hold %d: %s", h.id, msg)
+ return msg
+}
+
+// runBackgroundTask keeps the background task hold h while work must keep
+// going: until Stay says nothing does, a message fails, time runs out, the
+// hold is ended (the UI left the background, expiration, termination) or the
+// controller is closed.
+func (c *Controller) runBackgroundTask(h *Hold, deps BackgroundTaskDeps) {
+ if !deps.Stay() {
+ released := h.Release()
+ c.cfg.Debug("lifecycle: backgroundTaskEnd: hold %d done because: nothing to keep running, released: %v",
+ h.id, released)
+ return
+ }
+ clock := c.cfg.Clock
+ // Round(0) drops the monotonic reading, so time the device spends asleep
+ // counts toward the maximum.
+ beginTime := clock.Now().Round(0)
+ g, ctx := errgroup.WithContext(c.ctx)
+ g.Go(func() error {
+ select {
+ case <-h.done:
+ return errors.New("hold ended")
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ })
+ g.Go(func() error {
+ ch, cancel := deps.NextFailure()
+ defer cancel()
+ select {
+ case obrs := <-ch:
+ deps.NotifyFailure(obrs)
+ return fmt.Errorf("failure received: %d marked", len(obrs))
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ })
+ g.Go(func() error {
+ // An empty outbox can race a failure, so it takes three polls in a row
+ // with nothing to keep running to end the task.
+ idlePolls := 0
+ for {
+ select {
+ case <-clock.After(c.cfg.BackgroundTaskPollInterval):
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ if deps.Stay() {
+ idlePolls = 0
+ } else {
+ idlePolls++
+ if idlePolls > 2 {
+ return errors.New("nothing to keep running")
+ }
+ }
+ if clock.Now().Round(0).Sub(beginTime) >= c.cfg.BackgroundTaskMaxDuration {
+ pending, err := deps.ActiveDeliveries(ctx)
+ if err != nil {
+ c.cfg.Debug("lifecycle: failed to query active deliveries: %s", err)
+ }
+ deps.NotifyFailure(pending)
+ return errors.New("time expired")
+ }
+ }
+ })
+ err := g.Wait()
+ released := h.Release()
+ c.cfg.Debug("lifecycle: backgroundTaskEnd: hold %d done because: %v, released: %v", h.id, err, released)
+}
diff --git a/go/libkb/lifecycle/lifecycletest/clock.go b/go/libkb/lifecycle/lifecycletest/clock.go
new file mode 100644
index 000000000000..283ef3ef441a
--- /dev/null
+++ b/go/libkb/lifecycle/lifecycletest/clock.go
@@ -0,0 +1,72 @@
+// Copyright 2026 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package lifecycletest
+
+import (
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/keybase/clockwork"
+)
+
+// FakeClock is a clockwork fake clock that also reports each After call, so a
+// test can advance time only once the code under test is waiting on it.
+type FakeClock struct {
+ clockwork.FakeClock
+ mu sync.Mutex
+ pending map[time.Duration]int
+ changed chan struct{}
+}
+
+func NewFakeClock() *FakeClock {
+ return &FakeClock{
+ FakeClock: clockwork.NewFakeClock(),
+ pending: make(map[time.Duration]int),
+ changed: make(chan struct{}),
+ }
+}
+
+func (c *FakeClock) After(d time.Duration) <-chan time.Time {
+ ch := c.FakeClock.After(d)
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.pending[d]++
+ close(c.changed)
+ c.changed = make(chan struct{})
+ return ch
+}
+
+// ForgetAfters drops unconsumed After calls, such as those of a goroutine
+// that has exited.
+func (c *FakeClock) ForgetAfters() {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.pending = make(map[time.Duration]int)
+}
+
+// WaitForAfter consumes one After(d) call, waiting for it if needed. It
+// returns false if done closes first.
+func (c *FakeClock) WaitForAfter(t testing.TB, d time.Duration, done <-chan struct{}) bool {
+ t.Helper()
+ timeout := time.After(5 * time.Second)
+ for {
+ c.mu.Lock()
+ if c.pending[d] > 0 {
+ c.pending[d]--
+ c.mu.Unlock()
+ return true
+ }
+ changed := c.changed
+ c.mu.Unlock()
+ select {
+ case <-changed:
+ case <-done:
+ return false
+ case <-timeout:
+ t.Fatalf("nothing waited on After(%v)", d)
+ return false
+ }
+ }
+}
diff --git a/go/libkb/lifecycle/lifecycletest/harness.go b/go/libkb/lifecycle/lifecycletest/harness.go
new file mode 100644
index 000000000000..9551074dd440
--- /dev/null
+++ b/go/libkb/lifecycle/lifecycletest/harness.go
@@ -0,0 +1,439 @@
+// Copyright 2026 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package lifecycletest
+
+import (
+ "context"
+ "fmt"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/keybase/client/go/libkb/lifecycle"
+ "github.com/keybase/client/go/protocol/chat1"
+ "github.com/keybase/client/go/protocol/keybase1"
+ "github.com/stretchr/testify/require"
+)
+
+type Platform int
+
+const (
+ IOS Platform = iota
+ Android
+)
+
+func (p Platform) String() string {
+ if p == Android {
+ return "android"
+ }
+ return "ios"
+}
+
+// InitialState is the state the service starts in on both platforms.
+const InitialState = keybase1.MobileAppState_BACKGROUND
+
+type Action int
+
+const (
+ // Nothing reports no event, as when an Android dialog, permission prompt
+ // or picker pauses the activity.
+ Nothing Action = iota + 1
+
+ // Native lifecycle events, as native reports them: willEnterForeground and
+ // willResignActive are UIInactive, didBecomeActive is UIActive,
+ // didEnterBackground is UIBackground. PushWindowBegin and PushWindowEnd
+ // bracket a push or notification action, as the bind layer handles one:
+ // on iOS they don't reach the controller and return false.
+ // DidEnterBackground, and PushWindowEnd when it hands over, start a
+ // background task: they wait until it is polling and return true, or until
+ // it has ended at once, with nothing to keep running, and return false. A
+ // DidEnterBackground while the UI is already in the background starts
+ // nothing: it returns true if it joined a running task, false otherwise.
+ WillEnterForeground
+ DidBecomeActive
+ WillResignActive
+ DidEnterBackground
+ WillTerminate
+ BackgroundTaskExpired
+ PushWindowBegin
+ PushWindowEnd
+ LiveLocationAcquire
+ LiveLocationRelease
+
+ // BackgroundSyncStart starts the blocking BackgroundSync call and waits
+ // until it is waiting out its window (returns true) or has skipped
+ // (returns false).
+ BackgroundSyncStart
+ // BackgroundSyncTimerFires advances the clock past the sync window and
+ // waits for BackgroundSync to return.
+ BackgroundSyncTimerFires
+ // BackgroundSyncWait waits for a BackgroundSync that bails out on its own.
+ BackgroundSyncWait
+
+ // BackgroundTaskDelivered finishes the pending work and polls until the
+ // task returns.
+ BackgroundTaskDelivered
+ BackgroundTaskFails
+ // BackgroundTaskTimesUp advances the clock past the task's maximum
+ // duration with a delivery still pending.
+ BackgroundTaskTimesUp
+ // BackgroundTaskWait waits for a task that exits on its own, after a
+ // state change.
+ BackgroundTaskWait
+
+ // WorkStarts makes a delivery pending, so the app must keep running in
+ // the background.
+ WorkStarts
+ // WorkStops clears pending work.
+ WorkStops
+)
+
+var actionNames = map[Action]string{
+ Nothing: "Nothing",
+ WillEnterForeground: "WillEnterForeground",
+ DidBecomeActive: "DidBecomeActive",
+ WillResignActive: "WillResignActive",
+ DidEnterBackground: "DidEnterBackground",
+ WillTerminate: "WillTerminate",
+ BackgroundTaskExpired: "BackgroundTaskExpired",
+ PushWindowBegin: "PushWindowBegin",
+ PushWindowEnd: "PushWindowEnd",
+ LiveLocationAcquire: "LiveLocationAcquire",
+ LiveLocationRelease: "LiveLocationRelease",
+ BackgroundSyncStart: "BackgroundSyncStart",
+ BackgroundSyncTimerFires: "BackgroundSyncTimerFires",
+ BackgroundSyncWait: "BackgroundSyncWait",
+ BackgroundTaskDelivered: "BackgroundTaskDelivered",
+ BackgroundTaskFails: "BackgroundTaskFails",
+ BackgroundTaskTimesUp: "BackgroundTaskTimesUp",
+ BackgroundTaskWait: "BackgroundTaskWait",
+ WorkStarts: "WorkStarts",
+ WorkStops: "WorkStops",
+}
+
+func (a Action) String() string {
+ if name, ok := actionNames[a]; ok {
+ return name
+ }
+ return fmt.Sprintf("Action(%d)", int(a))
+}
+
+type Return int
+
+const (
+ // ReturnNone: the action returns nothing to check.
+ ReturnNone Return = iota
+ ReturnTrue
+ ReturnFalse
+)
+
+// Step is one action and what must hold right after it.
+type Step struct {
+ Do Action
+ // Slot names the push window for PushWindowBegin/End.
+ Slot int
+ Want keybase1.MobileAppState
+ // Flushes: how many times local DBs were flushed.
+ Flushes int
+ // Warn: the user was warned about messages that won't send.
+ Warn bool
+ Returns Return
+}
+
+type Scenario struct {
+ Name string
+ Platform Platform
+ Steps []Step
+ // Observed is every state a consumer sees, starting with the initial
+ // state.
+ Observed []keybase1.MobileAppState
+}
+
+// Harness drives a Controller with a fake clock and fake chat deliveries, and
+// records what consumers of the app state observe.
+type Harness struct {
+ T testing.TB
+ Platform Platform
+ AppState lifecycle.AppState
+ Clock *FakeClock
+ Controller *lifecycle.Controller
+ Recorder *Recorder
+
+ flushes atomic.Int32
+ warnings atomic.Int32
+ stay atomic.Bool
+ pending atomic.Int32
+ failures chan []chat1.OutboxRecord
+ tokens map[int]int64
+ liveLocation *lifecycle.Hold
+
+ // A new background task asks Stay the first time only once stayGate lets
+ // it, so the recorder sees the BACKGROUNDACTIVE the task may leave at once.
+ stayGate chan struct{}
+ closing chan struct{}
+ task int64
+ syncDone chan struct{}
+ taskDone chan struct{}
+ running sync.WaitGroup
+}
+
+const (
+ syncWindow = 10 * time.Second
+ pollInterval = 5 * time.Second
+ maxDuration = 10 * time.Minute
+)
+
+// NewHarness moves appState to the initial state and starts
+// recording. Close it when done.
+func NewHarness(t testing.TB, appState lifecycle.AppState, platform Platform) *Harness {
+ appState.Update(InitialState)
+ h := &Harness{
+ T: t,
+ Platform: platform,
+ AppState: appState,
+ Clock: NewFakeClock(),
+ failures: make(chan []chat1.OutboxRecord, 1),
+ tokens: make(map[int]int64),
+ stayGate: make(chan struct{}),
+ closing: make(chan struct{}),
+ syncDone: closedChan(),
+ taskDone: closedChan(),
+ }
+ h.Controller = lifecycle.New(appState, lifecycle.Config{
+ Clock: h.Clock,
+ BackgroundSyncWindow: syncWindow,
+ BackgroundTaskPollInterval: pollInterval,
+ BackgroundTaskMaxDuration: maxDuration,
+ Flush: func() { h.flushes.Add(1) },
+ Debug: func(format string, args ...interface{}) { t.Logf(format, args...) },
+ })
+ h.Recorder = NewRecorder(appState)
+ return h
+}
+
+func closedChan() chan struct{} {
+ ch := make(chan struct{})
+ close(ch)
+ return ch
+}
+
+// Close ends any background task or sync still running, and the recorder.
+func (h *Harness) Close() {
+ close(h.closing)
+ h.Controller.Close()
+ h.Clock.Advance(maxDuration)
+ h.running.Wait()
+ h.Recorder.Stop()
+}
+
+func (h *Harness) Flushes() int { return int(h.flushes.Load()) }
+func (h *Harness) Warnings() int { return int(h.warnings.Load()) }
+
+func (h *Harness) warn() { h.warnings.Add(1) }
+
+func (h *Harness) deps() lifecycle.BackgroundTaskDeps {
+ var gated sync.Once
+ return lifecycle.BackgroundTaskDeps{
+ Stay: func() bool {
+ gated.Do(func() {
+ select {
+ case <-h.stayGate:
+ case <-h.closing:
+ }
+ })
+ return h.stay.Load()
+ },
+ ActiveDeliveries: func(context.Context) ([]chat1.OutboxRecord, error) {
+ return make([]chat1.OutboxRecord, h.pending.Load()), nil
+ },
+ NextFailure: func() (chan []chat1.OutboxRecord, func()) { return h.failures, func() {} },
+ NotifyFailure: func([]chat1.OutboxRecord) { h.warn() },
+ }
+}
+
+func (h *Harness) goRun(f func()) chan struct{} {
+ done := make(chan struct{})
+ h.running.Add(1)
+ go func() {
+ defer h.running.Done()
+ defer close(done)
+ f()
+ }()
+ return done
+}
+
+func (h *Harness) wait(done chan struct{}, what string) {
+ h.T.Helper()
+ select {
+ case <-done:
+ case <-time.After(5 * time.Second):
+ h.T.Fatalf("%s did not return", what)
+ }
+}
+
+// Do performs step and checks what must hold after it.
+func (h *Harness) Do(step Step) {
+ t := h.T
+ t.Helper()
+ flushes, warnings := h.Flushes(), h.Warnings()
+ ret := h.perform(step)
+ h.Recorder.Sync(t)
+ state := h.AppState.State()
+ if state != step.Want {
+ t.Fatalf("%v: state %v, want %v", step.Do, state, step.Want)
+ }
+ if got := h.Flushes() - flushes; got != step.Flushes {
+ t.Fatalf("%v: %d flushes, want %d", step.Do, got, step.Flushes)
+ }
+ if got := h.Warnings() - warnings; got != boolInt(step.Warn) {
+ t.Fatalf("%v: %d pending-message warnings, want %d", step.Do, got, boolInt(step.Warn))
+ }
+ if step.Returns != ReturnNone && ret != (step.Returns == ReturnTrue) {
+ t.Fatalf("%v: returned %v, want %v", step.Do, ret, step.Returns == ReturnTrue)
+ }
+}
+
+func boolInt(b bool) int {
+ if b {
+ return 1
+ }
+ return 0
+}
+
+func (h *Harness) perform(step Step) bool {
+ h.T.Helper()
+ c := h.Controller
+ switch step.Do {
+ case Nothing:
+ case WillEnterForeground, WillResignActive:
+ c.UIInactive()
+ case DidBecomeActive:
+ c.UIActive()
+ case DidEnterBackground:
+ return h.startsTask(func() int64 { return c.UIBackground(h.deps()) })
+ case WillTerminate:
+ c.WillTerminate(h.warn)
+ case BackgroundTaskExpired:
+ c.BackgroundTaskExpired(h.warn)
+ case PushWindowBegin:
+ if h.Platform != Android {
+ return false
+ }
+ h.tokens[step.Slot] = c.PushWindowBegin()
+ return h.tokens[step.Slot] > 0
+ case PushWindowEnd:
+ if h.Platform != Android {
+ return false
+ }
+ return h.startsTask(func() int64 { return c.PushWindowEnd(h.tokens[step.Slot], h.deps()) })
+ case LiveLocationAcquire:
+ h.liveLocation = c.AcquireBackgroundWork()
+ case LiveLocationRelease:
+ require.NotNil(h.T, h.liveLocation, "LiveLocationRelease without LiveLocationAcquire")
+ h.liveLocation.Release()
+ case BackgroundSyncStart:
+ h.Clock.ForgetAfters()
+ h.syncDone = h.goRun(func() { c.BackgroundSync() })
+ return h.Clock.WaitForAfter(h.T, syncWindow, h.syncDone)
+ case BackgroundSyncTimerFires:
+ h.Clock.Advance(syncWindow)
+ h.wait(h.syncDone, "BackgroundSync")
+ case BackgroundSyncWait:
+ h.wait(h.syncDone, "BackgroundSync")
+ case BackgroundTaskDelivered:
+ h.stay.Store(false)
+ h.pending.Store(0)
+ for {
+ h.Clock.Advance(pollInterval)
+ if !h.Clock.WaitForAfter(h.T, pollInterval, h.taskDone) {
+ break
+ }
+ }
+ h.wait(h.taskDone, "background task")
+ case BackgroundTaskFails:
+ h.failures <- make([]chat1.OutboxRecord, 1)
+ h.wait(h.taskDone, "background task")
+ case BackgroundTaskTimesUp:
+ h.Clock.Advance(maxDuration)
+ h.wait(h.taskDone, "background task")
+ case BackgroundTaskWait:
+ h.wait(h.taskDone, "background task")
+ case WorkStarts:
+ h.stay.Store(true)
+ h.pending.Store(1)
+ case WorkStops:
+ h.stay.Store(false)
+ h.pending.Store(0)
+ default:
+ h.T.Fatalf("unknown action %v", step.Do)
+ }
+ return false
+}
+
+// startsTask runs a call that may start a background task and, if it did,
+// waits until the task is polling or has ended. It reports whether the task
+// is running.
+func (h *Harness) startsTask(call func() int64) bool {
+ h.T.Helper()
+ h.Clock.ForgetAfters()
+ token := call()
+ if token == 0 {
+ return false
+ }
+ if token == h.task {
+ // The call reused the running task's hold; that task is past Stay.
+ select {
+ case <-h.taskDone:
+ return false
+ default:
+ return true
+ }
+ }
+ h.task = token
+ h.taskDone = h.goRun(func() { h.Controller.WaitBackgroundTask(token) })
+ h.Recorder.Sync(h.T)
+ select {
+ case h.stayGate <- struct{}{}:
+ case <-time.After(5 * time.Second):
+ h.T.Fatalf("background task %d never asked whether to stay", token)
+ }
+ return h.Clock.WaitForAfter(h.T, pollInterval, h.taskDone)
+}
+
+// Play runs every step of sc on a fresh harness and checks the observed
+// states. afterStep, if set, runs after each step's checks, for a consumer
+// test to check its own reaction.
+func Play(t *testing.T, appState lifecycle.AppState, sc Scenario, afterStep func(h *Harness, i int, step Step)) {
+ t.Helper()
+ h := NewHarness(t, appState, sc.Platform)
+ defer h.Close()
+ for i, step := range sc.Steps {
+ h.Do(step)
+ if afterStep != nil {
+ afterStep(h, i, step)
+ }
+ }
+ h.CheckObserved(sc.Observed)
+}
+
+func (h *Harness) CheckObserved(want []keybase1.MobileAppState) {
+ h.T.Helper()
+ got := h.Recorder.States()
+ if fmt.Sprint(got) != fmt.Sprint(want) {
+ h.T.Fatalf("observed states %v, want %v", got, want)
+ }
+}
+
+// NoWork is what a background task sees when nothing must keep a backgrounded
+// app running: it ends at once.
+func NoWork() lifecycle.BackgroundTaskDeps {
+ return lifecycle.BackgroundTaskDeps{Stay: func() bool { return false }}
+}
+
+// ToBackground reports the UI in the background with nothing to keep running,
+// and returns once the background task that starts, if any, has ended.
+func ToBackground(c *lifecycle.Controller) {
+ c.WaitBackgroundTask(c.UIBackground(NoWork()))
+}
diff --git a/go/libkb/lifecycle/lifecycletest/recorder.go b/go/libkb/lifecycle/lifecycletest/recorder.go
new file mode 100644
index 000000000000..447e31d229f5
--- /dev/null
+++ b/go/libkb/lifecycle/lifecycletest/recorder.go
@@ -0,0 +1,137 @@
+// Copyright 2026 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+// Package lifecycletest replays native lifecycle event sequences against a
+// lifecycle.Controller and records what an app-state consumer observes. It
+// doesn't import libkb, so libkb's own tests can use it too.
+package lifecycletest
+
+import (
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/keybase/client/go/protocol/keybase1"
+)
+
+// Source is what an app-state consumer watches; *libkb.MobileAppState
+// implements it.
+type Source interface {
+ State() keybase1.MobileAppState
+ NextUpdate(lastState keybase1.MobileAppState) <-chan struct{}
+}
+
+// Recorder watches a Source the way consumers do: it seeds from State() and
+// wakes on NextUpdate. Like any consumer it can miss a state that is replaced
+// before it wakes (X to Y and back to X records nothing); Sync only guarantees
+// the recorder has woken for every change so far.
+type Recorder struct {
+ src Source
+ mu sync.Mutex
+ states []keybase1.MobileAppState
+ // waiting is the NextUpdate channel the recorder is blocked on. Every
+ // real change closes and replaces the source's channel.
+ waiting <-chan struct{}
+ stop chan struct{}
+ done chan struct{}
+}
+
+func NewRecorder(src Source) *Recorder {
+ r := &Recorder{
+ src: src,
+ stop: make(chan struct{}),
+ done: make(chan struct{}),
+ }
+ state := src.State()
+ r.states = []keybase1.MobileAppState{state}
+ go r.loop(state)
+ return r
+}
+
+func (r *Recorder) loop(state keybase1.MobileAppState) {
+ defer close(r.done)
+ for {
+ ch := r.src.NextUpdate(state)
+ r.mu.Lock()
+ r.waiting = ch
+ r.mu.Unlock()
+ select {
+ case <-ch:
+ case <-r.stop:
+ return
+ }
+ state = r.src.State()
+ r.mu.Lock()
+ if r.states[len(r.states)-1] != state {
+ r.states = append(r.states, state)
+ }
+ r.mu.Unlock()
+ }
+}
+
+// Stop ends the recording and waits for the watcher goroutine to exit.
+func (r *Recorder) Stop() {
+ select {
+ case <-r.stop:
+ default:
+ close(r.stop)
+ }
+ <-r.done
+}
+
+// States returns the observed states, starting with the seed. Consecutive
+// entries always differ.
+func (r *Recorder) States() []keybase1.MobileAppState {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return append([]keybase1.MobileAppState(nil), r.states...)
+}
+
+func (r *Recorder) Last() keybase1.MobileAppState {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return r.states[len(r.states)-1]
+}
+
+// Teardowns counts observed entries into BACKGROUND after the seed: the only
+// state in which network and servers go down.
+func (r *Recorder) Teardowns() int {
+ n := 0
+ for _, s := range r.States()[1:] {
+ if s == keybase1.MobileAppState_BACKGROUND {
+ n++
+ }
+ }
+ return n
+}
+
+// Sync waits until the recorder has woken for every change to the source so
+// far: it is blocked on the source's current, still open, NextUpdate channel.
+// Comparing values alone would miss a change and its reversal within one step.
+// Only meaningful while nothing else is updating the state.
+func (r *Recorder) Sync(t testing.TB) {
+ t.Helper()
+ deadline := time.Now().Add(5 * time.Second)
+ for !r.synced() {
+ if time.Now().After(deadline) {
+ t.Fatalf("recorder stuck at %v, state is %v", r.Last(), r.src.State())
+ }
+ time.Sleep(time.Millisecond)
+ }
+}
+
+func (r *Recorder) synced() bool {
+ r.mu.Lock()
+ waiting := r.waiting
+ last := r.states[len(r.states)-1]
+ r.mu.Unlock()
+ if waiting == nil || waiting != r.src.NextUpdate(last) {
+ return false
+ }
+ select {
+ case <-waiting:
+ return false
+ default:
+ return true
+ }
+}
diff --git a/go/libkb/lifecycle/lifecycletest/recorder_test.go b/go/libkb/lifecycle/lifecycletest/recorder_test.go
new file mode 100644
index 000000000000..2bf56765b9f3
--- /dev/null
+++ b/go/libkb/lifecycle/lifecycletest/recorder_test.go
@@ -0,0 +1,86 @@
+// Copyright 2026 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package lifecycletest
+
+import (
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/keybase/client/go/protocol/keybase1"
+ "github.com/stretchr/testify/require"
+)
+
+// slowWakeSource is a Source whose replaced NextUpdate channels close only on
+// wake, standing in for a recorder goroutine that hasn't been scheduled yet.
+type slowWakeSource struct {
+ mu sync.Mutex
+ state keybase1.MobileAppState
+ changed chan struct{}
+ unwoken []chan struct{}
+}
+
+func (s *slowWakeSource) State() keybase1.MobileAppState {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.state
+}
+
+func (s *slowWakeSource) NextUpdate(last keybase1.MobileAppState) <-chan struct{} {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if last != s.state {
+ ch := make(chan struct{})
+ close(ch)
+ return ch
+ }
+ return s.changed
+}
+
+func (s *slowWakeSource) update(state keybase1.MobileAppState) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.state != state {
+ s.state = state
+ s.unwoken = append(s.unwoken, s.changed)
+ s.changed = make(chan struct{})
+ }
+}
+
+func (s *slowWakeSource) wake() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ for _, ch := range s.unwoken {
+ close(ch)
+ }
+ s.unwoken = nil
+}
+
+// A change and its reversal leave the value where it was; Sync must still
+// wait for the recorder to wake and re-arm.
+func TestRecorderSyncWaitsForChangeAndReversal(t *testing.T) {
+ src := &slowWakeSource{state: keybase1.MobileAppState_FOREGROUND, changed: make(chan struct{})}
+ r := NewRecorder(src)
+ defer r.Stop()
+ r.Sync(t)
+
+ src.update(keybase1.MobileAppState_BACKGROUND)
+ src.update(keybase1.MobileAppState_FOREGROUND)
+ synced := make(chan struct{})
+ go func() {
+ r.Sync(t)
+ close(synced)
+ }()
+ select {
+ case <-synced:
+ require.Fail(t, "Sync returned before the recorder woke for the change")
+ case <-time.After(50 * time.Millisecond):
+ }
+ src.wake()
+ select {
+ case <-synced:
+ case <-time.After(5 * time.Second):
+ require.Fail(t, "Sync never returned")
+ }
+}
diff --git a/go/libkb/lifecycle/lifecycletest/scenarios.go b/go/libkb/lifecycle/lifecycletest/scenarios.go
new file mode 100644
index 000000000000..11172ff03af7
--- /dev/null
+++ b/go/libkb/lifecycle/lifecycletest/scenarios.go
@@ -0,0 +1,541 @@
+// Copyright 2026 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package lifecycletest
+
+import "github.com/keybase/client/go/protocol/keybase1"
+
+const (
+ fg = keybase1.MobileAppState_FOREGROUND
+ bg = keybase1.MobileAppState_BACKGROUND
+ bga = keybase1.MobileAppState_BACKGROUNDACTIVE
+ ina = keybase1.MobileAppState_INACTIVE
+)
+
+func step(do Action, want keybase1.MobileAppState) Step { return Step{Do: do, Want: want} }
+
+func (s Step) flush() Step { return s.flushes(1) }
+
+func (s Step) flushes(n int) Step {
+ s.Flushes = n
+ return s
+}
+
+func (s Step) warn() Step {
+ s.Warn = true
+ return s
+}
+
+func (s Step) returns(b bool) Step {
+ if b {
+ s.Returns = ReturnTrue
+ } else {
+ s.Returns = ReturnFalse
+ }
+ return s
+}
+
+func (s Step) slot(n int) Step {
+ s.Slot = n
+ return s
+}
+
+func steps(parts ...[]Step) []Step {
+ var all []Step
+ for _, p := range parts {
+ all = append(all, p...)
+ }
+ return all
+}
+
+func states(s ...keybase1.MobileAppState) []keybase1.MobileAppState { return s }
+
+// toForeground brings the app to the foreground: an iOS scene connects and
+// becomes active, an Android process starts and resumes.
+var toForeground = []Step{
+ step(WillEnterForeground, ina),
+ step(DidBecomeActive, fg),
+}
+
+// iosToBackgroundTask backgrounds a foreground app with a message still
+// sending, which starts the background task.
+var iosToBackgroundTask = []Step{
+ step(WorkStarts, fg),
+ step(WillResignActive, ina),
+ step(DidEnterBackground, bga).flush().returns(true),
+}
+
+// Scenarios replays whole native event sequences. Consumers of the app state
+// can play them with their own checks (see Play).
+//
+// Each move into the background from the UI and each hold's end in the
+// background flushes, so a step that does two of these, such as a
+// backgrounding whose task ends at once, flushes twice.
+var Scenarios = []Scenario{
+ {Name: "ios cold foreground launch", Platform: IOS, Steps: toForeground, Observed: states(bg, ina, fg)},
+ {
+ // iOS handles a push within the time it grants for it, so nothing is held.
+ Name: "ios background launch by silent push stays in BACKGROUND, then foreground",
+ Platform: IOS,
+ Steps: steps([]Step{
+ step(PushWindowBegin, bg).returns(false),
+ step(PushWindowEnd, bg).returns(false),
+ }, toForeground),
+ Observed: states(bg, ina, fg),
+ },
+ {
+ Name: "ios silent push while active holds nothing",
+ Platform: IOS,
+ Steps: steps(toForeground, []Step{
+ step(PushWindowBegin, fg).returns(false),
+ step(PushWindowEnd, fg).returns(false),
+ }),
+ Observed: states(bg, ina, fg),
+ },
+ {
+ // iOS suspends the app once the push's completion handler runs.
+ Name: "ios silent push with a message still sending starts no background task",
+ Platform: IOS,
+ Steps: steps(toForeground, []Step{
+ step(WillResignActive, ina),
+ step(DidEnterBackground, bg).flushes(2).returns(false),
+ step(WorkStarts, bg),
+ step(PushWindowBegin, bg).returns(false),
+ step(PushWindowEnd, bg).returns(false),
+ }),
+ Observed: states(bg, ina, fg, ina, bga, bg),
+ },
+ {
+ Name: "ios background launch by BGAppRefresh, then foreground",
+ Platform: IOS,
+ Steps: steps([]Step{
+ step(BackgroundSyncStart, bga).returns(true),
+ step(BackgroundSyncTimerFires, bg).flush(),
+ }, toForeground),
+ Observed: states(bg, bga, bg, ina, fg),
+ },
+ {
+ Name: "ios home and return",
+ Platform: IOS,
+ Steps: steps(toForeground, []Step{
+ step(WillResignActive, ina),
+ step(DidEnterBackground, bg).flushes(2).returns(false),
+ step(WillEnterForeground, ina),
+ step(DidBecomeActive, fg),
+ }),
+ Observed: states(bg, ina, fg, ina, bga, bg, ina, fg),
+ },
+ {
+ Name: "ios quick background and foreground cycles with duplicate events",
+ Platform: IOS,
+ Steps: steps(toForeground, []Step{
+ step(WillResignActive, ina),
+ step(WillResignActive, ina),
+ step(DidEnterBackground, bg).flushes(2).returns(false),
+ step(DidEnterBackground, bg).returns(false),
+ step(WillEnterForeground, ina),
+ step(WillEnterForeground, ina),
+ step(DidBecomeActive, fg),
+ step(DidBecomeActive, fg),
+ // Backgrounding abandoned before didEnterBackground.
+ step(WillResignActive, ina),
+ step(DidBecomeActive, fg),
+ step(WillResignActive, ina),
+ step(DidEnterBackground, bg).flushes(2).returns(false),
+ step(WillEnterForeground, ina),
+ step(DidBecomeActive, fg),
+ }),
+ Observed: states(bg, ina, fg, ina, bga, bg, ina, fg, ina, fg, ina, bga, bg, ina, fg),
+ },
+ {
+ Name: "ios control center or system alert keeps things up",
+ Platform: IOS,
+ Steps: steps(toForeground, []Step{
+ step(WillResignActive, ina), step(DidBecomeActive, fg),
+ step(WillResignActive, ina), step(DidBecomeActive, fg),
+ }),
+ Observed: states(bg, ina, fg, ina, fg, ina, fg),
+ },
+ {
+ Name: "ipad focus loss keeps things up",
+ Platform: IOS,
+ Steps: steps(toForeground, []Step{
+ step(WillResignActive, ina), step(WillResignActive, ina), step(DidBecomeActive, fg),
+ step(WillResignActive, ina), step(DidBecomeActive, fg), step(DidBecomeActive, fg),
+ }),
+ Observed: states(bg, ina, fg, ina, fg, ina, fg),
+ },
+ {
+ Name: "ios lock and unlock",
+ Platform: IOS,
+ Steps: steps(toForeground, []Step{
+ step(WillResignActive, ina),
+ step(DidEnterBackground, bg).flushes(2).returns(false),
+ step(WillEnterForeground, ina),
+ step(DidBecomeActive, fg),
+ }),
+ Observed: states(bg, ina, fg, ina, bga, bg, ina, fg),
+ },
+ {
+ // Leaving the background ends the sync's hold, so the sync returns at once.
+ Name: "ios BackgroundSync window racing willEnterForeground and didBecomeActive",
+ Platform: IOS,
+ Steps: []Step{
+ step(BackgroundSyncStart, bga).returns(true),
+ step(WillEnterForeground, ina),
+ step(DidBecomeActive, fg),
+ step(BackgroundSyncWait, fg),
+ },
+ Observed: states(bg, bga, ina, fg),
+ },
+ {
+ Name: "ios slow didBecomeActive after the BackgroundSync window ends",
+ Platform: IOS,
+ Steps: []Step{
+ step(BackgroundSyncStart, bga).returns(true),
+ step(WillEnterForeground, ina),
+ step(BackgroundSyncTimerFires, ina),
+ step(DidBecomeActive, fg),
+ },
+ Observed: states(bg, bga, ina, fg),
+ },
+ {
+ Name: "ios BackgroundSync skips outside the background",
+ Platform: IOS,
+ Steps: steps(toForeground, []Step{
+ step(BackgroundSyncStart, fg).returns(false),
+ step(WillResignActive, ina),
+ step(BackgroundSyncStart, ina).returns(false),
+ }),
+ Observed: states(bg, ina, fg, ina),
+ },
+ {
+ Name: "ios background task completes",
+ Platform: IOS,
+ Steps: steps(toForeground, iosToBackgroundTask, []Step{
+ step(BackgroundTaskDelivered, bg).flush(),
+ step(BackgroundTaskExpired, bg).flush(),
+ }, toForeground),
+ Observed: states(bg, ina, fg, ina, bga, bg, ina, fg),
+ },
+ {
+ Name: "ios background task fails",
+ Platform: IOS,
+ Steps: steps(toForeground, iosToBackgroundTask, []Step{step(BackgroundTaskFails, bg).flush().warn()}),
+ Observed: states(bg, ina, fg, ina, bga, bg),
+ },
+ {
+ Name: "ios background task runs out of time",
+ Platform: IOS,
+ Steps: steps(toForeground, iosToBackgroundTask, []Step{step(BackgroundTaskTimesUp, bg).flush().warn()}),
+ Observed: states(bg, ina, fg, ina, bga, bg),
+ },
+ {
+ Name: "ios background task expires",
+ Platform: IOS,
+ Steps: steps(toForeground, iosToBackgroundTask, []Step{
+ step(BackgroundTaskExpired, bg).flush().warn(),
+ step(BackgroundTaskWait, bg),
+ step(BackgroundTaskExpired, bg).flush(),
+ }),
+ Observed: states(bg, ina, fg, ina, bga, bg),
+ },
+ {
+ Name: "ios background task expires after return to foreground",
+ Platform: IOS,
+ Steps: steps(toForeground, iosToBackgroundTask, []Step{
+ step(WillEnterForeground, ina),
+ step(DidBecomeActive, fg),
+ step(BackgroundTaskWait, fg),
+ step(BackgroundTaskExpired, fg),
+ }),
+ Observed: states(bg, ina, fg, ina, bga, ina, fg),
+ },
+ {
+ Name: "ios background task expires between willEnterForeground and didBecomeActive",
+ Platform: IOS,
+ Steps: steps(toForeground, iosToBackgroundTask, []Step{
+ step(WillEnterForeground, ina),
+ step(BackgroundTaskExpired, ina),
+ step(BackgroundTaskDelivered, ina),
+ step(DidBecomeActive, fg),
+ }),
+ Observed: states(bg, ina, fg, ina, bga, ina, fg),
+ },
+ {
+ // Leaving the background ended the task's hold; finishing later changes nothing.
+ Name: "ios background task finishes after willEnterForeground",
+ Platform: IOS,
+ Steps: steps(toForeground, iosToBackgroundTask, []Step{
+ step(WillEnterForeground, ina),
+ step(BackgroundTaskDelivered, ina),
+ step(DidBecomeActive, fg),
+ }),
+ Observed: states(bg, ina, fg, ina, bga, ina, fg),
+ },
+ {
+ Name: "ios live location across background",
+ Platform: IOS,
+ Steps: steps(toForeground, iosToBackgroundTask, []Step{
+ step(BackgroundTaskDelivered, bg).flush(),
+ // A location update wakes the app while tracking.
+ step(LiveLocationAcquire, bga),
+ // Tracking ends.
+ step(LiveLocationRelease, bg).flush(),
+ step(LiveLocationAcquire, bga),
+ step(WillEnterForeground, ina),
+ step(DidBecomeActive, fg),
+ step(LiveLocationRelease, fg),
+ // A hold taken in the foreground keeps the app running once it backgrounds.
+ step(LiveLocationAcquire, fg),
+ step(WorkStops, fg),
+ step(WillResignActive, ina),
+ step(DidEnterBackground, bga).flushes(2).returns(false),
+ step(LiveLocationRelease, bg).flush(),
+ }),
+ Observed: states(bg, ina, fg, ina, bga, bg, bga, bg, bga, ina, fg, ina, bga, bg),
+ },
+ {
+ // A repeat didEnterBackground is defensive: a single-scene iOS app
+ // doesn't report twice. It joins the running task instead of starting
+ // another that would warn again.
+ Name: "ios duplicate didEnterBackground keeps one background task",
+ Platform: IOS,
+ Steps: steps(toForeground, iosToBackgroundTask, []Step{
+ step(DidEnterBackground, bga).returns(true),
+ step(BackgroundTaskFails, bg).flush().warn(),
+ }),
+ Observed: states(bg, ina, fg, ina, bga, bg),
+ },
+ {
+ Name: "ios background task expiration keeps live location running",
+ Platform: IOS,
+ Steps: steps(toForeground, []Step{step(LiveLocationAcquire, fg)}, iosToBackgroundTask, []Step{
+ step(BackgroundTaskExpired, bga).flush().warn(),
+ step(BackgroundTaskWait, bga),
+ step(LiveLocationRelease, bg).flush(),
+ }),
+ Observed: states(bg, ina, fg, ina, bga, bg),
+ },
+ {
+ Name: "ios termination from the background",
+ Platform: IOS,
+ Steps: steps(toForeground, []Step{
+ step(WillResignActive, ina),
+ step(DidEnterBackground, bg).flushes(2).returns(false),
+ step(WillTerminate, bg).flush().warn(),
+ }),
+ Observed: states(bg, ina, fg, ina, bga, bg),
+ },
+ {
+ Name: "ios termination from the foreground",
+ Platform: IOS,
+ Steps: steps(toForeground, []Step{step(WillTerminate, bg).flush().warn()}),
+ Observed: states(bg, ina, fg, bg),
+ },
+ {
+ Name: "ios termination during a background task",
+ Platform: IOS,
+ Steps: steps(toForeground, iosToBackgroundTask, []Step{
+ step(WillTerminate, bg).flush().warn(),
+ step(BackgroundTaskWait, bg),
+ step(BackgroundTaskExpired, bg).flush(),
+ }),
+ Observed: states(bg, ina, fg, ina, bga, bg),
+ },
+ {
+ Name: "ios termination ends live location's hold",
+ Platform: IOS,
+ Steps: steps(toForeground, []Step{
+ step(LiveLocationAcquire, fg),
+ step(WillResignActive, ina),
+ step(DidEnterBackground, bga).flushes(2).returns(false),
+ step(WillTerminate, bg).flush().warn(),
+ step(LiveLocationRelease, bg),
+ }),
+ Observed: states(bg, ina, fg, ina, bga, bg),
+ },
+ {Name: "android cold launch", Platform: Android, Steps: toForeground, Observed: states(bg, ina, fg)},
+ {
+ Name: "android process stop and start",
+ Platform: Android,
+ Steps: steps(toForeground, []Step{
+ step(DidEnterBackground, bg).flushes(2).returns(false),
+ }, toForeground, []Step{
+ step(WorkStarts, fg),
+ step(DidEnterBackground, bga).flush().returns(true),
+ step(WillEnterForeground, ina),
+ step(DidBecomeActive, fg),
+ step(BackgroundTaskWait, fg),
+ }),
+ Observed: states(bg, ina, fg, bga, bg, ina, fg, bga, ina, fg),
+ },
+ {
+ // A finishing activity reports willExit while visible; the process stop
+ // that follows reports the background again, which starts nothing.
+ Name: "android willExit then process stop",
+ Platform: Android,
+ Steps: steps(toForeground, []Step{
+ step(WillTerminate, bg).flush().warn(),
+ step(WorkStarts, bg),
+ step(DidEnterBackground, bg).returns(false),
+ }),
+ Observed: states(bg, ina, fg, bg),
+ },
+ {
+ Name: "android dialog, permission prompt or picker pause keeps the foreground",
+ Platform: Android,
+ Steps: steps(toForeground, []Step{
+ step(Nothing, fg),
+ step(PushWindowBegin, fg).returns(false),
+ step(PushWindowEnd, fg).returns(false),
+ // Back from the prompt: the process resumes without a start.
+ step(DidBecomeActive, fg),
+ }),
+ Observed: states(bg, ina, fg),
+ },
+ {
+ Name: "android push window in the background",
+ Platform: Android,
+ Steps: steps(toForeground, []Step{
+ step(DidEnterBackground, bg).flushes(2).returns(false),
+ step(PushWindowBegin, bga).returns(true),
+ step(PushWindowEnd, bg).flushes(2).returns(false),
+ }),
+ Observed: states(bg, ina, fg, bga, bg, bga, bg),
+ },
+ {
+ // A process started without UI stays in BACKGROUND until the push window opens.
+ Name: "android push at cold start",
+ Platform: Android,
+ Steps: []Step{
+ step(PushWindowBegin, bga).returns(true),
+ step(PushWindowEnd, bg).flushes(2).returns(false),
+ },
+ Observed: states(bg, bga, bg),
+ },
+ {
+ Name: "android quick reply at cold start hands the sending reply over to a background task",
+ Platform: Android,
+ Steps: []Step{
+ step(PushWindowBegin, bga).returns(true),
+ step(WorkStarts, bga),
+ step(PushWindowEnd, bga).flush().returns(true),
+ step(BackgroundTaskDelivered, bg).flush(),
+ },
+ Observed: states(bg, bga, bg),
+ },
+ {
+ // The push window's hold lasts until its own end, whatever the process does meanwhile.
+ Name: "android push window racing process start",
+ Platform: Android,
+ Steps: steps(toForeground, []Step{
+ step(DidEnterBackground, bg).flushes(2).returns(false),
+ step(PushWindowBegin, bga).returns(true),
+ step(WillEnterForeground, ina),
+ // No background task outside the background, even with work pending.
+ step(WorkStarts, ina),
+ step(PushWindowEnd, ina).returns(false),
+ step(DidBecomeActive, fg),
+ step(WorkStops, fg),
+ step(PushWindowBegin, fg).returns(false),
+ step(PushWindowEnd, fg).returns(false),
+ step(DidEnterBackground, bg).flushes(2).returns(false),
+ step(PushWindowBegin, bga).returns(true),
+ }, toForeground, []Step{
+ step(DidEnterBackground, bga).flushes(2).returns(false),
+ step(PushWindowEnd, bg).flushes(2).returns(false),
+ }),
+ Observed: states(bg, ina, fg, bga, bg, bga, ina, fg, bga, bg, bga, ina, fg, bga, bg),
+ },
+ {
+ Name: "android push window hands over to a background task",
+ Platform: Android,
+ Steps: steps(toForeground, []Step{
+ step(DidEnterBackground, bg).flushes(2).returns(false),
+ step(PushWindowBegin, bga).returns(true),
+ step(WorkStarts, bga),
+ step(PushWindowEnd, bga).flush().returns(true),
+ step(BackgroundTaskDelivered, bg).flush(),
+ }),
+ Observed: states(bg, ina, fg, bga, bg, bga, bg),
+ },
+ {
+ Name: "android push window ending during a background task joins it",
+ Platform: Android,
+ Steps: steps(toForeground, []Step{
+ step(WorkStarts, fg),
+ step(DidEnterBackground, bga).flush().returns(true),
+ step(PushWindowBegin, bga).returns(true),
+ step(PushWindowEnd, bga).flush().returns(true),
+ step(BackgroundTaskFails, bg).flush().warn(),
+ }),
+ Observed: states(bg, ina, fg, bga, bg),
+ },
+ {
+ Name: "android overlapping push windows",
+ Platform: Android,
+ Steps: steps(toForeground, []Step{
+ step(DidEnterBackground, bg).flushes(2).returns(false),
+ step(PushWindowBegin, bga).slot(0).returns(true),
+ step(PushWindowBegin, bga).slot(1).returns(true),
+ step(PushWindowEnd, bga).slot(0).flushes(2).returns(false),
+ step(PushWindowEnd, bg).slot(1).flushes(2).returns(false),
+ }),
+ Observed: states(bg, ina, fg, bga, bg, bga, bg),
+ },
+ {
+ // BackgroundSyncWorker doesn't init Go, so it only syncs in a process where
+ // something else did, such as a push at cold start.
+ Name: "android WorkManager BackgroundSync after a push cold start",
+ Platform: Android,
+ Steps: []Step{
+ step(PushWindowBegin, bga).returns(true),
+ step(PushWindowEnd, bg).flushes(2).returns(false),
+ step(BackgroundSyncStart, bga).returns(true),
+ step(BackgroundSyncTimerFires, bg).flush(),
+ },
+ Observed: states(bg, bga, bg, bga, bg),
+ },
+ {
+ Name: "android UI starts during a WorkManager sync after a push cold start",
+ Platform: Android,
+ Steps: []Step{
+ step(PushWindowBegin, bga).returns(true),
+ step(PushWindowEnd, bg).flushes(2).returns(false),
+ step(BackgroundSyncStart, bga).returns(true),
+ step(WillEnterForeground, ina),
+ step(DidBecomeActive, fg),
+ step(BackgroundSyncWait, fg),
+ },
+ Observed: states(bg, bga, bg, bga, ina, fg),
+ },
+ {
+ // The sync keeps its hold after the push window ends.
+ Name: "android WorkManager BackgroundSync racing a push window",
+ Platform: Android,
+ Steps: steps(toForeground, []Step{
+ step(DidEnterBackground, bg).flushes(2).returns(false),
+ step(BackgroundSyncStart, bga).returns(true),
+ step(PushWindowBegin, bga).returns(true),
+ step(PushWindowEnd, bga).flushes(2).returns(false),
+ step(BackgroundSyncTimerFires, bg).flush(),
+ }),
+ Observed: states(bg, ina, fg, bga, bg, bga, bg),
+ },
+ {
+ // A finishing activity reports willExit while the process lives on, so a
+ // push can still open a window. The next exit ends its hold, and the
+ // window's end then starts no task.
+ Name: "android termination",
+ Platform: Android,
+ Steps: steps(toForeground, []Step{
+ step(WillTerminate, bg).flush().warn(),
+ step(PushWindowBegin, bga).returns(true),
+ step(WillTerminate, bg).flush().warn(),
+ step(WorkStarts, bg),
+ step(PushWindowEnd, bg).returns(false),
+ }),
+ Observed: states(bg, ina, fg, bg, bga, bg),
+ },
+}
diff --git a/go/libkb/lifecycle/scenario_test.go b/go/libkb/lifecycle/scenario_test.go
new file mode 100644
index 000000000000..e885949acbf1
--- /dev/null
+++ b/go/libkb/lifecycle/scenario_test.go
@@ -0,0 +1,103 @@
+// Copyright 2026 Keybase, Inc. All rights reserved. Use of
+// this source code is governed by the included BSD license.
+
+package lifecycle_test
+
+import (
+ "context"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/keybase/client/go/libkb"
+ "github.com/keybase/client/go/libkb/lifecycle/lifecycletest"
+ "github.com/keybase/client/go/protocol/keybase1"
+ "github.com/stretchr/testify/require"
+)
+
+func newAppState(t *testing.T) (*libkb.MobileAppState, *libkb.GlobalContext) {
+ tc := libkb.SetupTest(t, strings.ReplaceAll(t.Name(), "/", "_"), 0)
+ t.Cleanup(tc.Cleanup)
+ return libkb.NewMobileAppState(tc.G), tc.G
+}
+
+// Each scenario runs against a real MobileAppState. Besides the harness's
+// per-step checks, live RPCs must be canceled exactly on a real change into
+// BACKGROUND, the one state that tears down network and servers.
+func TestScenarios(t *testing.T) {
+ for _, sc := range lifecycletest.Scenarios {
+ t.Run(sc.Name, func(t *testing.T) {
+ appState, g := newAppState(t)
+ h := lifecycletest.NewHarness(t, appState, sc.Platform)
+ defer h.Close()
+ for _, step := range sc.Steps {
+ seen := len(h.Recorder.States())
+ ctx, key := g.RPCCanceler.RegisterContext(context.Background(), libkb.RPCCancelerReasonBackground)
+ h.Do(step)
+ canceled := ctx.Err() != nil
+ g.RPCCanceler.UnregisterContext(key)
+ wantCancel := slices.Contains(h.Recorder.States()[seen:], keybase1.MobileAppState_BACKGROUND)
+ require.Equal(t, wantCancel, canceled, "%v: RPC cancel", step.Do)
+ }
+ h.CheckObserved(sc.Observed)
+ teardowns := 0
+ for _, s := range sc.Observed[1:] {
+ if s == keybase1.MobileAppState_BACKGROUND {
+ teardowns++
+ }
+ }
+ require.Equal(t, teardowns, h.Recorder.Teardowns())
+ })
+ }
+}
+
+// Play is what consumer tests use; make sure it runs the same checks.
+func TestPlay(t *testing.T) {
+ for _, sc := range lifecycletest.Scenarios {
+ t.Run(sc.Name, func(t *testing.T) {
+ appState, _ := newAppState(t)
+ steps := 0
+ lifecycletest.Play(t, appState, sc, func(h *lifecycletest.Harness, i int, step lifecycletest.Step) {
+ require.Equal(t, step.Want, h.Recorder.Last())
+ steps++
+ })
+ require.Equal(t, len(sc.Steps), steps)
+ })
+ }
+}
+
+// A scenario that fails midway must not hang in Close on work still waiting
+// on the fake clock or on deliveries.
+func TestHarnessCloseEndsRunningWork(t *testing.T) {
+ const bga = keybase1.MobileAppState_BACKGROUNDACTIVE
+ cases := map[string][]lifecycletest.Step{
+ "background sync": {
+ {Do: lifecycletest.BackgroundSyncStart, Want: bga, Returns: lifecycletest.ReturnTrue},
+ },
+ "background task": {
+ {Do: lifecycletest.DidBecomeActive, Want: keybase1.MobileAppState_FOREGROUND},
+ {Do: lifecycletest.WorkStarts, Want: keybase1.MobileAppState_FOREGROUND},
+ {Do: lifecycletest.DidEnterBackground, Want: bga, Flushes: 1, Returns: lifecycletest.ReturnTrue},
+ },
+ }
+ for name, steps := range cases {
+ t.Run(name, func(t *testing.T) {
+ appState, _ := newAppState(t)
+ h := lifecycletest.NewHarness(t, appState, lifecycletest.IOS)
+ for _, step := range steps {
+ h.Do(step)
+ }
+ closed := make(chan struct{})
+ go func() {
+ h.Close()
+ close(closed)
+ }()
+ select {
+ case <-closed:
+ case <-time.After(5 * time.Second):
+ require.Fail(t, "Close hung")
+ }
+ })
+ }
+}
diff --git a/go/libkb/notify_router.go b/go/libkb/notify_router.go
index fcf789e563b2..8847a4f04457 100644
--- a/go/libkb/notify_router.go
+++ b/go/libkb/notify_router.go
@@ -2838,6 +2838,33 @@ func (n *NotifyRouter) HandleHTTPSrvInfoUpdate(ctx context.Context, info keybase
})
}
+// HandleMobileAppState announces the app lifecycle state the service derived
+// from native's UI reports. It is the client's only source for it: deriving it
+// a second time from the OS would mean two answers -- on iOS from two different
+// notification streams -- with nothing ordering them against each other.
+//
+// No runListeners, unlike the announces above it: there is no in-process
+// listener for this. The in-process consumers (kbhttp/manager, kbfs) watch
+// MobileAppState.NextUpdate directly, which is the earlier and cheaper signal.
+//
+// Called with MobileAppState's lock held, so nothing below may read app state.
+func (n *NotifyRouter) HandleMobileAppState(ctx context.Context, state keybase1.MobileAppState) {
+ if n == nil {
+ return
+ }
+ ctx = CopyTagsToBackground(ctx)
+ n.cm.ApplyAll(func(id ConnectionID, xp rpc.Transporter) bool {
+ if n.getNotificationChannels(id).App {
+ go func() {
+ _ = (keybase1.NotifyAppClient{
+ Cli: rpc.NewClient(xp, NewContextifiedErrorUnwrapper(n.G()), nil),
+ }).MobileAppStateChanged(ctx, state)
+ }()
+ }
+ return true
+ })
+}
+
func (n *NotifyRouter) HandleHandleKeybaseLink(ctx context.Context, link string, deferred bool) {
if n == nil {
return
diff --git a/go/protocol/keybase1/notify_app.go b/go/protocol/keybase1/notify_app.go
index 19120a54d2d3..e3fae3e1d110 100644
--- a/go/protocol/keybase1/notify_app.go
+++ b/go/protocol/keybase1/notify_app.go
@@ -13,8 +13,13 @@ import (
type ExitArg struct {
}
+type MobileAppStateChangedArg struct {
+ State MobileAppState `codec:"state" json:"state"`
+}
+
type NotifyAppInterface interface {
Exit(context.Context) error
+ MobileAppStateChanged(context.Context, MobileAppState) error
}
func NotifyAppProtocol(i NotifyAppInterface) rpc.Protocol {
@@ -31,6 +36,21 @@ func NotifyAppProtocol(i NotifyAppInterface) rpc.Protocol {
return
},
},
+ "mobileAppStateChanged": {
+ MakeArg: func() any {
+ var ret [1]MobileAppStateChangedArg
+ return &ret
+ },
+ Handler: func(ctx context.Context, args any) (ret any, err error) {
+ typedArgs, ok := args.(*[1]MobileAppStateChangedArg)
+ if !ok {
+ err = rpc.NewTypeError((*[1]MobileAppStateChangedArg)(nil), args)
+ return
+ }
+ err = i.MobileAppStateChanged(ctx, typedArgs[0].State)
+ return
+ },
+ },
},
}
}
@@ -43,3 +63,9 @@ func (c NotifyAppClient) Exit(ctx context.Context) (err error) {
err = c.Cli.Notify(ctx, "keybase.1.NotifyApp.exit", []any{ExitArg{}}, 0*time.Millisecond)
return
}
+
+func (c NotifyAppClient) MobileAppStateChanged(ctx context.Context, state MobileAppState) (err error) {
+ __arg := MobileAppStateChangedArg{State: state}
+ err = c.Cli.Notify(ctx, "keybase.1.NotifyApp.mobileAppStateChanged", []any{__arg}, 0*time.Millisecond)
+ return
+}
diff --git a/go/service/appstate.go b/go/service/appstate.go
index c6e0e44b2b94..97d05a86a66c 100644
--- a/go/service/appstate.go
+++ b/go/service/appstate.go
@@ -5,7 +5,6 @@ package service
import (
"context"
- "fmt"
"strings"
"github.com/keybase/client/go/libkb"
@@ -25,14 +24,6 @@ func newAppStateHandler(xp rpc.Transporter, g *libkb.GlobalContext) *appStateHan
}
}
-func (a *appStateHandler) UpdateAppState(ctx context.Context, state keybase1.MobileAppState) (err error) {
- a.G().Trace(fmt.Sprintf("UpdateAppState(%v)", state), &err)()
-
- // Update app state
- a.G().MobileAppState.Update(state)
- return nil
-}
-
func (a *appStateHandler) UpdateMobileNetState(ctx context.Context, stateStr string) (err error) {
a.G().Log.CDebugf(ctx, "UpdateMobileNetState(%v)", stateStr)
diff --git a/go/service/gregor.go b/go/service/gregor.go
index 5fe2ba3edbc9..bc6ec1c572fd 100644
--- a/go/service/gregor.go
+++ b/go/service/gregor.go
@@ -269,6 +269,22 @@ const (
monitorNoop
)
+// mobileMonitorAction keeps gregor connected in every state but BACKGROUND.
+// INACTIVE is also the scene coming to the foreground before it is active, so
+// connecting there has gregor up before React Native resumes painting, and
+// leaves a connection a background hold kept open in place.
+func mobileMonitorAction(state keybase1.MobileAppState) int {
+ switch state {
+ case keybase1.MobileAppState_FOREGROUND, keybase1.MobileAppState_BACKGROUNDACTIVE,
+ keybase1.MobileAppState_INACTIVE:
+ return monitorConnect
+ case keybase1.MobileAppState_BACKGROUND:
+ return monitorDisconnect
+ default:
+ return monitorNoop
+ }
+}
+
func (g *gregorHandler) monitorAppState() {
ctx := libkb.WithLogTag(context.Background(), "GRGRMON")
// Wait for state updates and react accordingly
@@ -279,15 +295,10 @@ func (g *gregorHandler) monitorAppState() {
select {
case <-g.G().MobileAppState.NextUpdate(state):
state = g.G().MobileAppState.State()
- switch state {
- case keybase1.MobileAppState_FOREGROUND:
+ if state == keybase1.MobileAppState_FOREGROUND {
g.forcePing(ctx)
- monitorAction = monitorConnect
- case keybase1.MobileAppState_BACKGROUNDACTIVE:
- monitorAction = monitorConnect
- case keybase1.MobileAppState_BACKGROUND, keybase1.MobileAppState_INACTIVE:
- monitorAction = monitorDisconnect
}
+ monitorAction = mobileMonitorAction(state)
case <-g.G().DesktopAppState.NextSuspendUpdate(suspended):
suspended = g.G().DesktopAppState.Suspended()
if !suspended {
diff --git a/go/service/gregor_test.go b/go/service/gregor_test.go
index f90d63e17956..c2452f3bedf5 100644
--- a/go/service/gregor_test.go
+++ b/go/service/gregor_test.go
@@ -1139,3 +1139,14 @@ func badgerResync(ctx context.Context, t testing.TB, b *badges.Badger, chatRemot
b.PushChatFullUpdate(ctx, update)
b.PushState(ctx, state)
}
+
+func TestMobileMonitorAction(t *testing.T) {
+ for state, want := range map[keybase1.MobileAppState]int{
+ keybase1.MobileAppState_FOREGROUND: monitorConnect,
+ keybase1.MobileAppState_INACTIVE: monitorConnect,
+ keybase1.MobileAppState_BACKGROUNDACTIVE: monitorConnect,
+ keybase1.MobileAppState_BACKGROUND: monitorDisconnect,
+ } {
+ require.Equal(t, want, mobileMonitorAction(state), "state %v", state)
+ }
+}
diff --git a/protocol/avdl/keybase1/notify_app.avdl b/protocol/avdl/keybase1/notify_app.avdl
index 31dfac72e695..ca9ea15b87b7 100644
--- a/protocol/avdl/keybase1/notify_app.avdl
+++ b/protocol/avdl/keybase1/notify_app.avdl
@@ -1,7 +1,14 @@
@namespace("keybase.1")
protocol NotifyApp {
+ import idl "appstate.avdl";
void exit() oneway;
+ // The app's lifecycle state changed. The service derives it from the UI
+ // reports native makes, so this is the only place a client learns it --
+ // deriving it a second time from the OS would mean two answers with no
+ // ordering between them.
+ void mobileAppStateChanged(MobileAppState state) oneway;
+
}
diff --git a/protocol/bin/enabled-calls.json b/protocol/bin/enabled-calls.json
index 35ebd092896e..658ceacc02e6 100644
--- a/protocol/bin/enabled-calls.json
+++ b/protocol/bin/enabled-calls.json
@@ -153,6 +153,7 @@
"chat.1.local.updateUnsentText": {"promise":true},
"chat.1.local.userEmojis": {"promise":true},
"keybase.1.NotifyApp.exit": {"custom":true},
+ "keybase.1.NotifyApp.mobileAppStateChanged": {"incoming":true},
"keybase.1.NotifyAudit.boxAuditError": {"incoming":true},
"keybase.1.NotifyAudit.rootAuditError": {"incoming":true},
"keybase.1.NotifyBadges.badgeState": {"incoming":true},
diff --git a/protocol/json/keybase1/notify_app.json b/protocol/json/keybase1/notify_app.json
index c1a6ab87bd48..bfe65971fc58 100644
--- a/protocol/json/keybase1/notify_app.json
+++ b/protocol/json/keybase1/notify_app.json
@@ -1,12 +1,27 @@
{
"protocol": "NotifyApp",
- "imports": [],
+ "imports": [
+ {
+ "path": "appstate.avdl",
+ "type": "idl"
+ }
+ ],
"types": [],
"messages": {
"exit": {
"request": [],
"response": null,
"oneway": true
+ },
+ "mobileAppStateChanged": {
+ "request": [
+ {
+ "name": "state",
+ "type": "MobileAppState"
+ }
+ ],
+ "response": null,
+ "oneway": true
}
},
"namespace": "keybase.1"
diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/KeybasePushNotificationListenerService.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/KeybasePushNotificationListenerService.kt
index 396b0aabe2e9..6bdce186b21d 100644
--- a/shared/android/app/src/main/java/io/keybase/ossifrage/KeybasePushNotificationListenerService.kt
+++ b/shared/android/app/src/main/java/io/keybase/ossifrage/KeybasePushNotificationListenerService.kt
@@ -395,14 +395,18 @@ internal class NotificationData(type: String, bundle: Bundle) {
}
}
-// Interface to run some task while in backgroundActive.
-// If already foreground, ignore
+// Runs some task inside a push window, which keeps a backgrounded app running
+// while the task does. If already foreground, ignore.
+//
+// Transitional: it exists only because Android still opens the push window from
+// here. It goes away, with Keybase.appPushWindow*, once the bind layer wraps
+// push handling in the window itself.
internal interface WithBackgroundActive {
@Throws(Exception::class)
fun task()
@Throws(Exception::class)
- fun whileActive(context: Context?) {
+ fun whileActive(context: Context) {
try {
// We are foreground don't show anything
val isForeground = Keybase.isAppStateForeground()
@@ -410,29 +414,18 @@ internal interface WithBackgroundActive {
if (isForeground) {
NativeLogger.info("WithBackgroundActive.whileActive app is foreground, returning early")
return
- } else {
- NativeLogger.info("WithBackgroundActive.whileActive setting background active and calling task")
- Keybase.setAppStateBackgroundActive()
+ }
+ // 0 when the app is active and nothing needs holding up.
+ val token = Keybase.appPushWindowBegin()
+ NativeLogger.info("WithBackgroundActive.whileActive push window $token, calling task")
+ try {
task()
NativeLogger.info("WithBackgroundActive.whileActive task completed")
-
- // Check if we are foreground now for some reason. In that case we don't want to go background again
- val isForegroundNow = Keybase.isAppStateForeground()
- NativeLogger.info("WithBackgroundActive.whileActive isForegroundNow: $isForegroundNow")
- if (isForegroundNow) {
- NativeLogger.info("WithBackgroundActive.whileActive app became foreground, returning")
- return
- }
- val didEnterBackground = Keybase.appDidEnterBackground()
- NativeLogger.info("WithBackgroundActive.whileActive didEnterBackground: $didEnterBackground")
- if (didEnterBackground) {
- if (context != null) {
- NativeLogger.info("WithBackgroundActive.whileActive beginning background task")
- Keybase.appBeginBackgroundTaskNonblock(KBPushNotifier(context, Bundle()))
- }
- } else {
- NativeLogger.info("WithBackgroundActive.whileActive setting app state to background")
- Keybase.setAppStateBackground()
+ } finally {
+ if (token > 0) {
+ // Hands over to a background task if the UI is still in the
+ // background and work must keep going.
+ Keybase.appPushWindowEnd(token, KBPushNotifier(context, Bundle()))
}
}
} catch (ex: Exception) {
diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt
index f4369a931039..c93a7982e1e0 100644
--- a/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt
+++ b/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt
@@ -93,11 +93,6 @@ class MainActivity : ReactActivity() {
override fun onPause() {
NativeLogger.info("Activity onPause")
super.onPause()
- if (Keybase.appDidEnterBackground()) {
- Keybase.appBeginBackgroundTaskNonblock(KBPushNotifier(this, Bundle()))
- } else {
- Keybase.setAppStateBackground()
- }
}
private fun getFileNameFromResolver(resolver: ContentResolver, uri: Uri, extension: String?): String {
@@ -154,17 +149,27 @@ class MainActivity : ReactActivity() {
return filePath
}
+ // Native reports only what the UI is doing; Go derives the app state from
+ // these reports and the holds background work opens (go/libkb/lifecycle).
override fun onResume() {
NativeLogger.info("Activity onResume")
super.onResume()
- Keybase.setAppStateForeground()
+ Keybase.appUIActive()
handleIntent()
}
override fun onStart() {
NativeLogger.info("Activity onStart")
super.onStart()
- Keybase.setAppStateForeground()
+ Keybase.appUIInactive()
+ }
+
+ override fun onStop() {
+ NativeLogger.info("Activity onStop")
+ super.onStop()
+ // The token is for iOS, which has to wait out Go's background task
+ // before the OS suspends it; Android keeps the process running.
+ Keybase.appUIBackground(KBPushNotifier(this, Bundle()))
}
override fun onDestroy() {
diff --git a/shared/app/index.native.tsx b/shared/app/index.native.tsx
index dfc15a3d789a..a1bd3ad7614d 100644
--- a/shared/app/index.native.tsx
+++ b/shared/app/index.native.tsx
@@ -5,7 +5,7 @@ import * as React from 'react'
import Main from './main'
import {KeyboardProvider} from 'react-native-keyboard-controller'
import {ReducedMotionConfig, ReduceMotion} from 'react-native-reanimated'
-import {AppRegistry, AppState, Appearance, Platform} from 'react-native'
+import {AppRegistry, Appearance, Platform} from 'react-native'
import {PortalProvider} from '@/common-adapters/portal.native'
import {SafeAreaProvider, initialWindowMetrics} from 'react-native-safe-area-context'
import {makeEngine} from '../engine'
@@ -57,18 +57,18 @@ const initDarkMode = () => {
}
const useDarkHookup = () => {
+ // The store starts at 'unknown' and only the service can move it off that, which is later than
+ // this mounts, so assume on screen until told otherwise rather than dropping an early theme
+ // change. Being wrong costs at most one system theme change applied off screen -- which is what
+ // the gate exists to avoid, and which the next 'active' re-reads anyway.
const appStateRef = React.useRef('active')
const setSystemDarkMode = DarkMode.useDarkModeState(s => s.dispatch.setSystemDarkMode)
- const setMobileAppState = useShellState(s => s.dispatch.setMobileAppState)
React.useEffect(() => {
- const appStateChangeSub = AppState.addEventListener('change', nextAppState => {
- appStateRef.current = nextAppState
- if (nextAppState !== 'unknown' && nextAppState !== 'extension') {
- setMobileAppState(nextAppState)
- }
-
- if (nextAppState === 'active') {
+ const stopWatchingAppState = useShellState.subscribe((s, old) => {
+ if (s.mobileAppState === old.mobileAppState) return
+ appStateRef.current = s.mobileAppState
+ if (s.mobileAppState === 'active') {
setSystemDarkMode(Appearance.getColorScheme() === 'dark')
}
})
@@ -81,10 +81,10 @@ const useDarkHookup = () => {
})
return () => {
- appStateChangeSub.remove()
+ stopWatchingAppState()
darkSub.remove()
}
- }, [setSystemDarkMode, setMobileAppState])
+ }, [setSystemDarkMode])
}
const StoreHelper = (p: {children: React.ReactNode}): React.ReactNode => {
diff --git a/shared/constants/init/app-state.test.ts b/shared/constants/init/app-state.test.ts
new file mode 100644
index 000000000000..c8b7f373e485
--- /dev/null
+++ b/shared/constants/init/app-state.test.ts
@@ -0,0 +1,57 @@
+///
+import * as T from '@/constants/types'
+import {resetAllStores} from '@/util/zustand'
+import {useShellState} from '@/stores/shell'
+import {applyMobileAppState, _onEngineIncoming} from './shared'
+
+const g = globalThis as unknown as {isMobile: boolean}
+
+beforeEach(() => {
+ g.isMobile = true
+ resetAllStores()
+ // the shell store keeps its state across an account-level reset on purpose
+ useShellState.setState({mobileAppState: 'unknown'})
+})
+
+afterEach(() => {
+ g.isMobile = false
+})
+
+describe('the app state the service derives', () => {
+ test.each([
+ [T.RPCGen.MobileAppState.foreground, 'active'],
+ [T.RPCGen.MobileAppState.inactive, 'inactive'],
+ [T.RPCGen.MobileAppState.background, 'background'],
+ // nothing in the UI distinguishes "backgrounded with work still running" from "backgrounded"
+ [T.RPCGen.MobileAppState.backgroundactive, 'background'],
+ ])('%s becomes %s', (state, expected) => {
+ applyMobileAppState(state)
+ expect(useShellState.getState().mobileAppState).toBe(expected)
+ })
+
+ test('arrives through the notification', () => {
+ _onEngineIncoming({
+ payload: {params: {state: T.RPCGen.MobileAppState.background}},
+ type: 'keybase.1.NotifyApp.mobileAppStateChanged',
+ } as never)
+ expect(useShellState.getState().mobileAppState).toBe('background')
+ })
+
+ test('is applied in arrival order: the service sends it on one ordered stream', () => {
+ applyMobileAppState(T.RPCGen.MobileAppState.background)
+ applyMobileAppState(T.RPCGen.MobileAppState.foreground)
+ expect(useShellState.getState().mobileAppState).toBe('active')
+ })
+
+ test('a state we do not map leaves the app state alone rather than guessing', () => {
+ applyMobileAppState(T.RPCGen.MobileAppState.background)
+ applyMobileAppState(99 as T.RPCGen.MobileAppState)
+ expect(useShellState.getState().mobileAppState).toBe('background')
+ })
+
+ test('desktop has no lifecycle to learn, so its constant FOREGROUND is ignored', () => {
+ g.isMobile = false
+ applyMobileAppState(T.RPCGen.MobileAppState.foreground)
+ expect(useShellState.getState().mobileAppState).toBe('unknown')
+ })
+})
diff --git a/shared/constants/init/index.tsx b/shared/constants/init/index.tsx
index 88b76432ee8e..985cf814046c 100644
--- a/shared/constants/init/index.tsx
+++ b/shared/constants/init/index.tsx
@@ -342,25 +342,19 @@ export const initPlatformListener = () => {
const _initNativePlatformListener = () => {
useShellState.subscribe((s, old) => {
if (s.mobileAppState === old.mobileAppState) return
- let appFocused: boolean
- switch (s.mobileAppState) {
- case 'active':
- appFocused = true
- break
- case 'background':
- appFocused = false
- persistRoute(false, true, () => useConfigState.getState().startup.loaded)
- break
- case 'inactive':
- appFocused = false
- break
- default:
- appFocused = false
+ if (s.mobileAppState === 'background') {
+ persistRoute(false, true, () => useConfigState.getState().startup.loaded)
}
- // Native KeybaseSetAppState* is the only writer of Go MobileAppState.
+ // mobileAppState is the service's derived state, applied in constants/init/shared.tsx;
+ // nothing in JS derives it, so this only translates it into focus.
logger.info(`app focus changed: ${s.mobileAppState}`)
- s.dispatch.changedFocus(appFocused)
+ s.dispatch.changedFocus(s.mobileAppState === 'active')
+
+ if (s.mobileAppState === 'active') {
+ // only reload on foreground
+ useSettingsContactsState.getState().dispatch.loadContactPermissions()
+ }
})
const configureAndroidCacheDir = () => {
@@ -412,14 +406,6 @@ const _initNativePlatformListener = () => {
ignorePromise(f())
})
- useShellState.subscribe((s, old) => {
- if (s.mobileAppState === old.mobileAppState) return
- if (s.mobileAppState === 'active') {
- // only reload on foreground
- useSettingsContactsState.getState().dispatch.loadContactPermissions()
- }
- })
-
if (isAndroid) {
useDarkModeState.subscribe((s, old) => {
if (s.darkModePreference === old.darkModePreference) return
diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx
index c0266f11ad80..f754f7421dfc 100644
--- a/shared/constants/init/shared.tsx
+++ b/shared/constants/init/shared.tsx
@@ -237,6 +237,36 @@ const onBootstrapStatusChanged = (bootstrap: DaemonState['bootstrapStatus']) =>
}
}
+// The service derives the app's lifecycle state from the UI reports native makes and is the only
+// party that derives it; this is the whole of JS's model of it. Go's two background states are one
+// state here: nothing in the UI distinguishes "backgrounded with work still running" from
+// "backgrounded".
+//
+// Applied only on mobile. Desktop has no lifecycle to report, so the service's value there is a
+// constant FOREGROUND that describes nothing -- desktop's window focus is a separate fact, written
+// straight to `appFocused` by the window listeners.
+export const applyMobileAppState = (state: T.RPCGen.MobileAppState) => {
+ if (!isMobile) {
+ return
+ }
+ switch (state) {
+ case T.RPCGen.MobileAppState.foreground:
+ useShellState.getState().dispatch.setMobileAppState('active')
+ break
+ case T.RPCGen.MobileAppState.inactive:
+ useShellState.getState().dispatch.setMobileAppState('inactive')
+ break
+ case T.RPCGen.MobileAppState.background:
+ case T.RPCGen.MobileAppState.backgroundactive:
+ useShellState.getState().dispatch.setMobileAppState('background')
+ break
+ default:
+ // a fifth state the service grew and we have not mapped: say so rather than leaving the store
+ // silently stuck on the one before it
+ logger.warn(`[AppState] unmapped state ${String(state)}, leaving the app state as it was`)
+ }
+}
+
const onNavStateChanged =(nextNavState: RouterState['navState'], previousNavState: RouterState['navState']) => {
const next = nextNavState as Util.NavState
const prev = previousNavState as Util.NavState
@@ -357,6 +387,9 @@ export const _onEngineIncoming = (action: EngineGen.Actions) => {
}
switch (action.type) {
+ case 'keybase.1.NotifyApp.mobileAppStateChanged':
+ applyMobileAppState(action.payload.params.state)
+ break
case 'keybase.1.NotifyBadges.badgeState':
{
const {badgeState} = action.payload.params
diff --git a/shared/constants/rpc/index.tsx b/shared/constants/rpc/index.tsx
index 639df9e16838..14806b2581c8 100644
--- a/shared/constants/rpc/index.tsx
+++ b/shared/constants/rpc/index.tsx
@@ -71,6 +71,7 @@ type Chat1ResponseActionMap = {
}
type Keybase1IncomingAction =
+ 'keybase.1.NotifyApp.mobileAppStateChanged' |
'keybase.1.NotifyAudit.boxAuditError' |
'keybase.1.NotifyAudit.rootAuditError' |
'keybase.1.NotifyBadges.badgeState' |
diff --git a/shared/constants/rpc/rpc-gen.tsx b/shared/constants/rpc/rpc-gen.tsx
index c1792d8d0bad..e49a4dc17ece 100644
--- a/shared/constants/rpc/rpc-gen.tsx
+++ b/shared/constants/rpc/rpc-gen.tsx
@@ -15,6 +15,10 @@ export type MessageTypes = {
inParam: undefined,
outParam: void,
},
+ 'keybase.1.NotifyApp.mobileAppStateChanged': {
+ inParam: {readonly state: MobileAppState},
+ outParam: void,
+ },
'keybase.1.NotifyAudit.boxAuditError': {
inParam: {readonly message: string},
outParam: void,
@@ -3125,7 +3129,7 @@ export type WalletAccountInfo = {readonly accountID: string,readonly numUnread:
export type WebProof = {readonly hostname: string,readonly protocols?: ReadonlyArray | null,}
export type WriteArgs = {readonly opID: OpID,readonly path: Path,readonly offset: number,}
-type IncomingMethod = 'keybase.1.NotifyAudit.boxAuditError' | 'keybase.1.NotifyAudit.rootAuditError' | 'keybase.1.NotifyBadges.badgeState' | 'keybase.1.NotifyDeviceHistory.deviceHistoryChanged' | 'keybase.1.NotifyFS.FSActivity' | 'keybase.1.NotifySession.loggedOut' | 'keybase.1.NotifyTracking.trackingChanged' | 'keybase.1.NotifyUsers.userChanged' | 'keybase.1.loginUi.displayPaperKeyPhrase' | 'keybase.1.loginUi.displayPrimaryPaperKey' | 'keybase.1.loginUi.displayResetProgress' | 'keybase.1.loginUi.explainDeviceRecovery' | 'keybase.1.pgpUi.finished' | 'keybase.1.proveUi.displayRecheckWarning' | 'keybase.1.proveUi.outputPrechecks' | 'keybase.1.provisionUi.DisplaySecretExchanged' | 'keybase.1.provisionUi.ProvisioneeSuccess' | 'keybase.1.provisionUi.ProvisionerSuccess' | 'keybase.1.reachability.reachabilityChanged' | 'keybase.1.rekeyUI.refresh' | 'keybase.1.rekeyUI.rekeySendEvent'
+type IncomingMethod = 'keybase.1.NotifyApp.mobileAppStateChanged' | 'keybase.1.NotifyAudit.boxAuditError' | 'keybase.1.NotifyAudit.rootAuditError' | 'keybase.1.NotifyBadges.badgeState' | 'keybase.1.NotifyDeviceHistory.deviceHistoryChanged' | 'keybase.1.NotifyFS.FSActivity' | 'keybase.1.NotifySession.loggedOut' | 'keybase.1.NotifyTracking.trackingChanged' | 'keybase.1.NotifyUsers.userChanged' | 'keybase.1.loginUi.displayPaperKeyPhrase' | 'keybase.1.loginUi.displayPrimaryPaperKey' | 'keybase.1.loginUi.displayResetProgress' | 'keybase.1.loginUi.explainDeviceRecovery' | 'keybase.1.pgpUi.finished' | 'keybase.1.proveUi.displayRecheckWarning' | 'keybase.1.proveUi.outputPrechecks' | 'keybase.1.provisionUi.DisplaySecretExchanged' | 'keybase.1.provisionUi.ProvisioneeSuccess' | 'keybase.1.provisionUi.ProvisionerSuccess' | 'keybase.1.reachability.reachabilityChanged' | 'keybase.1.rekeyUI.refresh' | 'keybase.1.rekeyUI.rekeySendEvent'
export type IncomingCallMapType = Partial<{[M in IncomingMethod]: (params: RpcIn) => void}>
type CustomIncomingMethod = 'keybase.1.NotifyApp.exit' | 'keybase.1.NotifyEmailAddress.emailAddressVerified' | 'keybase.1.NotifyEmailAddress.emailsChanged' | 'keybase.1.NotifyFS.FSOverallSyncStatusChanged' | 'keybase.1.NotifyFS.FSSubscriptionNotify' | 'keybase.1.NotifyFS.FSSubscriptionNotifyPath' | 'keybase.1.NotifyFeaturedBots.featuredBotsUpdate' | 'keybase.1.NotifyPGP.pgpKeyInSecretStoreFile' | 'keybase.1.NotifyPhoneNumber.phoneNumbersChanged' | 'keybase.1.NotifyRuntimeStats.runtimeStatsUpdate' | 'keybase.1.NotifyService.HTTPSrvInfoUpdate' | 'keybase.1.NotifyService.handleKeybaseLink' | 'keybase.1.NotifyService.shutdown' | 'keybase.1.NotifySession.clientOutOfDate' | 'keybase.1.NotifySession.loggedIn' | 'keybase.1.NotifySimpleFS.simpleFSArchiveStatusChanged' | 'keybase.1.NotifyTeam.avatarUpdated' | 'keybase.1.NotifyTeam.teamChangedByID' | 'keybase.1.NotifyTeam.teamDeleted' | 'keybase.1.NotifyTeam.teamExit' | 'keybase.1.NotifyTeam.teamMetadataUpdate' | 'keybase.1.NotifyTeam.teamRoleMapChanged' | 'keybase.1.NotifyTeam.teamTreeMembershipsDone' | 'keybase.1.NotifyTeam.teamTreeMembershipsPartial' | 'keybase.1.NotifyTracking.notifyUserBlocked' | 'keybase.1.NotifyTracking.trackingInfo' | 'keybase.1.NotifyUsers.identifyUpdate' | 'keybase.1.NotifyUsers.passwordChanged' | 'keybase.1.gpgUi.selectKey' | 'keybase.1.gpgUi.wantToAddGPGKey' | 'keybase.1.gregorUI.pushState' | 'keybase.1.homeUI.homeUIRefresh' | 'keybase.1.identify3Ui.identify3Result' | 'keybase.1.identify3Ui.identify3ShowTracker' | 'keybase.1.identify3Ui.identify3Summary' | 'keybase.1.identify3Ui.identify3UpdateRow' | 'keybase.1.identify3Ui.identify3UpdateUserCard' | 'keybase.1.identify3Ui.identify3UserReset' | 'keybase.1.logUi.log' | 'keybase.1.loginUi.chooseDeviceToRecoverWith' | 'keybase.1.loginUi.displayPaperKeyPhrase' | 'keybase.1.loginUi.displayPrimaryPaperKey' | 'keybase.1.loginUi.displayResetProgress' | 'keybase.1.loginUi.explainDeviceRecovery' | 'keybase.1.loginUi.getEmailOrUsername' | 'keybase.1.loginUi.promptPassphraseRecovery' | 'keybase.1.loginUi.promptResetAccount' | 'keybase.1.loginUi.promptRevokePaperKeys' | 'keybase.1.logsend.prepareLogsend' | 'keybase.1.pgpUi.finished' | 'keybase.1.pgpUi.keyGenerated' | 'keybase.1.pgpUi.shouldPushPrivate' | 'keybase.1.proveUi.checking' | 'keybase.1.proveUi.continueChecking' | 'keybase.1.proveUi.displayRecheckWarning' | 'keybase.1.proveUi.okToCheck' | 'keybase.1.proveUi.outputInstructions' | 'keybase.1.proveUi.outputPrechecks' | 'keybase.1.proveUi.preProofWarning' | 'keybase.1.proveUi.promptOverwrite' | 'keybase.1.proveUi.promptUsername' | 'keybase.1.provisionUi.DisplayAndPromptSecret' | 'keybase.1.provisionUi.DisplaySecretExchanged' | 'keybase.1.provisionUi.PromptNewDeviceName' | 'keybase.1.provisionUi.ProvisioneeSuccess' | 'keybase.1.provisionUi.ProvisionerSuccess' | 'keybase.1.provisionUi.chooseDevice' | 'keybase.1.provisionUi.chooseDeviceType' | 'keybase.1.provisionUi.chooseGPGMethod' | 'keybase.1.provisionUi.switchToGPGSignOK' | 'keybase.1.rekeyUI.delegateRekeyUI' | 'keybase.1.rekeyUI.refresh' | 'keybase.1.rekeyUI.rekeySendEvent' | 'keybase.1.secretUi.getPassphrase' | 'keybase.1.teamsUi.confirmInviteLinkAccept' | 'keybase.1.teamsUi.confirmRootTeamDelete' | 'keybase.1.teamsUi.confirmSubteamDelete'
diff --git a/shared/ios/Keybase/AppDelegate.swift b/shared/ios/Keybase/AppDelegate.swift
index 04648fc15bdd..2dd6f45c3b72 100644
--- a/shared/ios/Keybase/AppDelegate.swift
+++ b/shared/ios/Keybase/AppDelegate.swift
@@ -21,7 +21,7 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi
var resignImageView: UIImageView?
var fsPaths: [String: String] = [:]
- var shutdownTask: UIBackgroundTaskIdentifier = .invalid
+ private let lifecycle = AppLifecycleForwarder()
var iph: ItemProviderHelper?
private var startupLogFileHandle: FileHandle?
private let logQueue = DispatchQueue(label: "kb.startup.log", qos: .utility)
@@ -37,12 +37,6 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi
self.didLaunchSetupBefore()
- // Tell Go the real app state right after init. Go defaults to foreground,
- // so a background launch (silent push, background fetch) would otherwise
- // look foregrounded until didLaunchSetupAfter runs — long enough to join
- // a coin flip it can't finish.
- self.notifyAppState(application)
-
if let remoteNotification = launchOptions?[.remoteNotification] as? [AnyHashable: Any] {
let notificationDict = Dictionary(uniqueKeysWithValues: remoteNotification.map { (String(describing: $0.key), $0.value) })
KbSetInitialNotification(notificationDict)
@@ -74,7 +68,7 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi
// Start FPS monitoring if launched with -PERF_FPS_MONITOR
PerfFPSMonitor.startIfEnabled()
- self.didLaunchSetupAfter(application: application)
+ self.didLaunchSetupAfter()
return true
}
@@ -202,17 +196,6 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi
self.writeStartupTimingLog("After Go init")
}
- func notifyAppState(_ application: UIApplication) {
- let state = application.applicationState
- log.info("notifyAppState: notifying service with new appState: \(state.rawValue)")
- switch state {
- case .active: Keybasego.KeybaseSetAppStateForeground()
- case .background: Keybasego.KeybaseSetAppStateBackground()
- case .inactive: Keybasego.KeybaseSetAppStateInactive()
- default: Keybasego.KeybaseSetAppStateForeground()
- }
- }
-
func didLaunchSetupBefore() {
setupGo()
try? AVAudioSession.sharedInstance().setCategory(.ambient)
@@ -221,9 +204,7 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi
// BGTaskScheduler.register must run before didFinishLaunching returns, so this
// can't wait for the scene to connect.
- func didLaunchSetupAfter(application: UIApplication) {
- notifyAppState(application)
-
+ func didLaunchSetupAfter() {
BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.keybase.app.refresh", using: nil) { task in
self.handleAppRefresh(task: task as! BGAppRefreshTask)
}
@@ -244,6 +225,7 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi
dim = screenBounds.height
}
let square = CGRect(origin: screenBounds.origin, size: CGSize(width: dim, height: dim))
+ self.resignImageView?.removeFromSuperview()
self.resignImageView = UIImageView(frame: square)
self.resignImageView?.contentMode = .center
self.resignImageView?.alpha = 0
@@ -252,6 +234,14 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi
if let view = self.resignImageView { window.addSubview(view) }
}
+ // Called by SceneDelegate when the scene goes away; didStartReactNative
+ // rebuilds both if a new scene connects.
+ func didDisconnectScene() {
+ self.window = nil
+ self.resignImageView?.removeFromSuperview()
+ self.resignImageView = nil
+ }
+
func addDrop(_ rootView: UIView) {
let dropInteraction = UIDropInteraction(delegate: self)
dropInteraction.allowsSimultaneousDropSessions = true
@@ -384,7 +374,7 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi
override func applicationWillTerminate(_ application: UIApplication) {
self.window?.rootViewController?.view.isHidden = true
- Keybasego.KeybaseAppWillExit(PushNotifier())
+ lifecycle.willTerminate()
}
func hideCover() {
@@ -403,7 +393,7 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi
} completion: { finished in
log.info("applicationWillResignActive: rendered keyz screen. Finished: \(finished)")
}
- Keybasego.KeybaseSetAppStateInactive()
+ lifecycle.uiInactive()
}
override func applicationDidEnterBackground(_ application: UIApplication) {
@@ -414,43 +404,13 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi
log.info("applicationDidEnterBackground: setting keyz screen alpha to 1.")
self.resignImageView?.alpha = 1
- log.info("applicationDidEnterBackground: notifying go.")
- let requestTime = Keybasego.KeybaseAppDidEnterBackground()
- log.info("applicationDidEnterBackground: after notifying go.")
-
- if requestTime && (self.shutdownTask == UIBackgroundTaskIdentifier.invalid) {
- self.shutdownTask = UIApplication.shared.beginBackgroundTask {
- // Expiration handler runs on the main thread.
- log.info("applicationDidEnterBackground: shutdown task run.")
- Keybasego.KeybaseAppWillExit(PushNotifier())
- self.endShutdownTask()
- }
-
- DispatchQueue.global(qos: .default).async {
- Keybasego.KeybaseAppBeginBackgroundTask(PushNotifier())
- DispatchQueue.main.async {
- self.endShutdownTask()
- }
- }
- }
- }
-
- // Main thread only: serializes the expiration handler and the background
- // work both trying to end the same task.
- private func endShutdownTask() {
- let task = self.shutdownTask
- guard task != .invalid else { return }
- self.shutdownTask = .invalid
- UIApplication.shared.endBackgroundTask(task)
+ lifecycle.didEnterBackground(application)
}
override func applicationDidBecomeActive(_ application: UIApplication) {
log.info("applicationDidBecomeActive: hiding keyz screen.")
hideCover()
- log.info("applicationDidBecomeActive: notifying service.")
- // Forwarded from sceneDidBecomeActive, where applicationState still reads
- // .inactive; notifyAppState would stop the http server.
- Keybasego.KeybaseSetAppStateForeground()
+ lifecycle.didBecomeActive()
// Re-emit a notification the user tapped while React Native wasn't ready yet.
KbEmitStoredNotificationOnBecomeActive()
@@ -460,12 +420,7 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi
log.info("applicationWillEnterForeground: hiding keyz screen.")
PerfFPSMonitor.appWillEnterForeground()
hideCover()
- // HTTP and gregor should come up before React Native resumes painting (image
- // loads race a stopped http server). BACKGROUNDACTIVE starts those without
- // claiming the user is on-screen — FOREGROUND waits for didBecomeActive.
- // Can't use notifyAppState here: applicationState is still .background.
- Keybasego.KeybaseSetAppStateBackgroundActive()
- NSLog("applicationWillEnterForeground: done")
+ lifecycle.uiInactive()
}
func applicationProtectedDataDidBecomeAvailable(_ application: UIApplication) {
@@ -474,6 +429,69 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi
}
+// Hands lifecycle events to Go on the main thread, in callback order: every Go
+// lifecycle call returns at once, except the exit work, which runBounded caps.
+// Also owns the UIKit background tasks that keep the app alive while Go does
+// its background work. Main thread only.
+//
+// Native reports only UI state; Go derives the app state (go/libkb/lifecycle).
+// Nothing here may derive state, and UIApplication.applicationState lags inside
+// the scene-forwarded callbacks anyway.
+final class AppLifecycleForwarder {
+ // Upper bound on how long the expiration handler and willTerminate hold the
+ // main thread for Go's last work (flush, a pending-message warning).
+ private static let exitWorkTimeout: TimeInterval = 1
+
+ // willEnterForeground and willResignActive.
+ func uiInactive() { Keybasego.KeybaseAppUIInactive() }
+ func didBecomeActive() { Keybasego.KeybaseAppUIActive() }
+
+ func willTerminate() {
+ runBounded { Keybasego.KeybaseAppWillExit(PushNotifier()) }
+ }
+
+ // Every background entry starts its own task, which lasts until Go's
+ // background task has ended. Background time is per app, so every task still
+ // open expires together, and Go ends all of its background tasks at once.
+ func didEnterBackground(_ application: UIApplication) {
+ // The task's id, or .invalid once it has ended.
+ var task = UIBackgroundTaskIdentifier.invalid
+ func end() {
+ guard task != .invalid else { return }
+ application.endBackgroundTask(task)
+ task = .invalid
+ }
+ task = application.beginBackgroundTask(withName: "kb.didEnterBackground") {
+ guard task != .invalid else { return }
+ log.info("background task expired")
+ self.runBounded { Keybasego.KeybaseAppBackgroundTaskExpired(PushNotifier()) }
+ end()
+ }
+ // 0 while Go isn't running (before Init, after shutdown), and when the UI
+ // was already in the background with no Go background task running.
+ let token = Keybasego.KeybaseAppUIBackground(PushNotifier())
+ guard token > 0 else {
+ end()
+ return
+ }
+ DispatchQueue.global(qos: .default).async {
+ Keybasego.KeybaseAppWaitBackgroundTask(token)
+ DispatchQueue.main.async { end() }
+ }
+ }
+
+ // Every earlier event has already reached Go, so this keeps the order; the
+ // wait only bounds how long the app stays alive for the work.
+ private func runBounded(_ work: @escaping () -> Void) {
+ let done = DispatchSemaphore(value: 0)
+ DispatchQueue.global(qos: .userInitiated).async {
+ work()
+ done.signal()
+ }
+ _ = done.wait(timeout: .now() + Self.exitWorkTimeout)
+ }
+}
+
class ReactNativeDelegate: ExpoReactNativeFactoryDelegate {
// Extension point for config-plugins
diff --git a/shared/ios/Keybase/SceneDelegate.swift b/shared/ios/Keybase/SceneDelegate.swift
index 17be345c2006..59b34b40f2f1 100644
--- a/shared/ios/Keybase/SceneDelegate.swift
+++ b/shared/ios/Keybase/SceneDelegate.swift
@@ -10,9 +10,13 @@ class SceneDelegate: ExpoAppSceneDelegate {
) {
super.scene(scene, willConnectTo: session, options: connectionOptions)
- guard let window = self.window,
- let appDelegate = UIApplication.shared.delegate as? AppDelegate
- else { return }
+ guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { return }
+ guard let window = self.window else { return }
appDelegate.didStartReactNative(in: window)
}
+
+ override func sceneDidDisconnect(_ scene: UIScene) {
+ super.sceneDidDisconnect(scene)
+ (UIApplication.shared.delegate as? AppDelegate)?.didDisconnectScene()
+ }
}