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() + } }