Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
bfef5dd
feat(lifecycle): add a controller that derives the mobile app state
chrisnojima Sep 21, 2026
97b3d78
feat(protocol): add NotifyApp.mobileAppStateChanged
chrisnojima Sep 21, 2026
945124a
fix(chat): live location stops writing the app state
chrisnojima Sep 21, 2026
ab522b2
feat(lifecycle): derive the app state from a lifecycle controller
chrisnojima Sep 21, 2026
7efb026
refactor(ios): report UI lifecycle events to Go instead of app states
chrisnojima Sep 21, 2026
b66a547
refactor(android): report UI lifecycle events to Go instead of app st…
chrisnojima Sep 21, 2026
ac3debc
feat(js): take the mobile app state from the service
chrisnojima Sep 21, 2026
4acd794
fix(kbhttp): keep the http server up while the UI is inactive
chrisnojima Sep 21, 2026
82b45d4
fix(gregor): stay connected while the UI is inactive
chrisnojima Sep 22, 2026
ab82b81
fix(lifecycle): hold a background task while work must keep running
chrisnojima Sep 22, 2026
f56e54c
fix(lifecycle): flush the local DBs once per backgrounding
chrisnojima Sep 22, 2026
068ba44
fix(lifecycle): flush whenever the state reaches the background
chrisnojima Sep 22, 2026
b0e7ea7
refactor(lifecycle): drop the unused changed result from AppState.Update
chrisnojima Sep 22, 2026
9708328
fix(lifecycle): flush on leaving the UI, and coalesce the flush at a …
chrisnojima Sep 22, 2026
61cb122
fix(lifecycle): flush on every move into the background and on exit
chrisnojima Sep 22, 2026
4b7dc6f
fix(lifecycle): flush when any hold ends in the background
chrisnojima Sep 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
271 changes: 84 additions & 187 deletions go/bind/keybase.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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) },
}
}

Expand Down
16 changes: 7 additions & 9 deletions go/chat/maps/livelocation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 0 additions & 5 deletions go/chat/types/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 5 additions & 2 deletions go/kbhttp/manager/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Expand Down
Loading