diff --git a/go/bind/keybase.go b/go/bind/keybase.go index 3c8aece0fa7c..3c4c16e010d3 100644 --- a/go/bind/keybase.go +++ b/go/bind/keybase.go @@ -186,6 +186,15 @@ type ShareIntentDonator interface { DeleteDonation(conversationID string) } +// NativeLocationWatcher is implemented by the native iOS layer. It runs the OS +// location service while live location is on and reports each fix through +// LocationUpdate, so live location works without JS. When nil (Android, +// desktop), the chat UI watches position instead. +type NativeLocationWatcher interface { + StartWatching() + StopWatching() +} + // shareIntentDonatorAdapter adapts keybase.ShareIntentDonator to types.ShareIntentDonator. type shareIntentDonatorAdapter struct { wrapped ShareIntentDonator @@ -325,10 +334,10 @@ func setInited() { func InitOnce(homeDir, mobileSharedHome, logFile, runModeStr string, accessGroupOverride bool, dnsNSFetcher ExternalDNSNSFetcher, nvh NativeVideoHelper, mobileOsVersion string, isIPad bool, installReferrerListener NativeInstallReferrerListener, isIOS bool, - shareIntentDonator ShareIntentDonator, + shareIntentDonator ShareIntentDonator, locationWatcher NativeLocationWatcher, ) { startOnce.Do(func() { - if err := Init(homeDir, mobileSharedHome, logFile, runModeStr, accessGroupOverride, dnsNSFetcher, nvh, mobileOsVersion, isIPad, installReferrerListener, isIOS, shareIntentDonator); err != nil { + if err := Init(homeDir, mobileSharedHome, logFile, runModeStr, accessGroupOverride, dnsNSFetcher, nvh, mobileOsVersion, isIPad, installReferrerListener, isIOS, shareIntentDonator, locationWatcher); err != nil { log("Init error: %s", err) } }) @@ -338,7 +347,7 @@ func InitOnce(homeDir, mobileSharedHome, logFile, runModeStr string, func Init(homeDir, mobileSharedHome, logFile, runModeStr string, accessGroupOverride bool, externalDNSNSFetcher ExternalDNSNSFetcher, nvh NativeVideoHelper, mobileOsVersion string, isIPad bool, installReferrerListener NativeInstallReferrerListener, isIOS bool, - shareIntentDonator ShareIntentDonator, + shareIntentDonator ShareIntentDonator, locationWatcher NativeLocationWatcher, ) (err error) { // Dump all goroutines on a fatal error; the GOTRACEBACK env var can't be // used here since the runtime reads it before Init runs. @@ -462,6 +471,7 @@ func Init(homeDir, mobileSharedHome, logFile, runModeStr string, if shareIntentDonator != nil { kbChatCtx.ShareIntentDonator = shareIntentDonatorAdapter{wrapped: shareIntentDonator} } + kbChatCtx.LocationWatcher = locationWatcher // Runs the startup login attempt and then the long-lived background // tasks. Off the Init thread so a slow login can't hold up app launch; // must start after the chat context fields above are set since chat @@ -896,6 +906,19 @@ func AppUIInactive() { kbCtx.MobileLifecycle.UIInactive() } +// LocationUpdate reports every location fix from the native location service; +// the tracker decides which ones to record. +func LocationUpdate(lat, lon float64, accuracy int) { + if !isInited() || !kbCtx.ActiveDevice.HaveKeys() { + return + } + locationUpdate(kbChatCtx.LiveLocationTracker, lat, lon, accuracy) +} + +func locationUpdate(tracker types.LiveLocationTracker, lat, lon float64, accuracy int) { + tracker.NativeLocationUpdate(context.Background(), chat1.Coordinate{Lat: lat, Lon: lon, Accuracy: float64(accuracy)}) +} + func waitForInit(maxDur time.Duration) error { if isInited() { return nil diff --git a/go/bind/location_test.go b/go/bind/location_test.go new file mode 100644 index 000000000000..a1c1f600b2f8 --- /dev/null +++ b/go/bind/location_test.go @@ -0,0 +1,162 @@ +package keybase + +import ( + "context" + "encoding/base64" + "fmt" + "sync" + "testing" + "time" + + "github.com/keybase/client/go/chat/globals" + "github.com/keybase/client/go/chat/maps" + "github.com/keybase/client/go/chat/types" + "github.com/keybase/client/go/kbtest" + "github.com/keybase/client/go/libkb" + "github.com/keybase/client/go/libkb/lifecycle/lifecycletest" + "github.com/keybase/client/go/protocol/chat1" + "github.com/keybase/client/go/protocol/keybase1" + "github.com/keybase/clockwork" + "github.com/stretchr/testify/require" +) + +type countingLocationWatcher struct{ starts, stops chan struct{} } + +func (w countingLocationWatcher) StartWatching() { w.starts <- struct{}{} } +func (w countingLocationWatcher) StopWatching() { w.stops <- struct{}{} } + +type nilCtxFactory struct{} + +func (nilCtxFactory) NewKeyFinder() types.KeyFinder { return nil } +func (nilCtxFactory) NewUPAKFinder() types.UPAKFinder { return nil } + +func TestLocationUpdateReachesTrackers(t *testing.T) { + t.Setenv("KEYBASE_APP_TYPE", string(libkb.MobileAppType)) + tc := libkb.SetupTest(t, "LocationUpdateReachesTrackers", 0) + defer tc.Cleanup() + tc.G.ChatHelper = kbtest.NewMockChatHelper() + tc.G.SetUIRouter(kbtest.NewMockUIRouter(nil)) + watcher := countingLocationWatcher{starts: make(chan struct{}, 10), stops: make(chan struct{}, 10)} + var nativeWatcher NativeLocationWatcher = watcher + g := globals.NewContext(tc.G, &globals.ChatContext{CtxFactory: nilCtxFactory{}, LocationWatcher: nativeWatcher}) + tracker := maps.NewLiveLocationTracker(g) + clock := clockwork.NewFakeClock() + tracker.SetClock(clock) + ctx := context.Background() + lifecycletest.ToBackground(tc.G.MobileLifecycle) + + startTracking := func(msgID chat1.MessageID) types.LiveLocationKey { + tracker.StartTracking(ctx, chat1.ConversationID("conv"), msgID, clock.Now().Add(time.Hour)) + select { + case <-watcher.starts: + case <-time.After(10 * time.Second): + require.Fail(t, "native watch never started") + } + return types.LiveLocationKey(base64.StdEncoding.EncodeToString( + fmt.Appendf(nil, "%s:%d", chat1.ConversationID("conv"), msgID))) + } + stopTracking := func() { + tracker.StopAllTracking(ctx) + select { + case <-tracker.Stop(ctx): + case <-time.After(10 * time.Second): + require.Fail(t, "tracker did not stop") + } + select { + case <-watcher.stops: + default: + require.Fail(t, "native watch never stopped") + } + require.Equal(t, keybase1.MobileAppState_BACKGROUND, tc.G.MobileAppState.State()) + } + fix := func(lat float64) chat1.Coordinate { return chat1.Coordinate{Lat: lat, Lon: -73.25, Accuracy: 12} } + // waitRecorded waits for the tracker at key to take the fix at lat, and + // returns its coordinates. The tracker also takes the last coordinate it + // has when it starts, which can repeat the first fix; repeats are dropped. + waitRecorded := func(key types.LiveLocationKey, lat float64) (res []chat1.Coordinate) { + require.Eventually(t, func() bool { + coords := tracker.GetCoordinates(ctx, key) + return coords[len(coords)-1] == fix(lat) + }, 10*time.Second, time.Millisecond, "coordinate never reached the tracker") + for _, c := range tracker.GetCoordinates(ctx, key) { + if len(res) == 0 || res[len(res)-1] != c { + res = append(res, c) + } + } + return res + } + + key := startTracking(1) + // The first fix is recorded even in the background. + locationUpdate(tracker, 40.5, -73.25, 12) + require.Equal(t, []chat1.Coordinate{fix(40.5)}, waitRecorded(key, 40.5)) + require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, tc.G.MobileAppState.State()) + // About 11m north, too short a move to record in the background, then + // about 111m further. The coordinates arrive in order, so once the last one + // is in, the short move would be too. + locationUpdate(tracker, 40.5001, -73.25, 12) + locationUpdate(tracker, 40.5011, -73.25, 12) + require.Equal(t, []chat1.Coordinate{fix(40.5), fix(40.5011)}, waitRecorded(key, 40.5011)) + stopTracking() + + // A new watch records its first fix however short the move. + key = startTracking(2) + locationUpdate(tracker, 40.5012, -73.25, 12) + waitRecorded(key, 40.5012) + stopTracking() +} + +type recordingLiveLocationTracker struct { + types.LiveLocationTracker + sync.Mutex + coords []chat1.Coordinate +} + +func (r *recordingLiveLocationTracker) NativeLocationUpdate(_ context.Context, coord chat1.Coordinate) { + r.Lock() + defer r.Unlock() + r.coords = append(r.coords, coord) +} + +func (r *recordingLiveLocationTracker) Coords() []chat1.Coordinate { + r.Lock() + defer r.Unlock() + return append([]chat1.Coordinate(nil), r.coords...) +} + +func TestLocationUpdateGuards(t *testing.T) { + resetConnStateForTest(t) + savedChatCtx := kbChatCtx + t.Cleanup(func() { kbChatCtx = savedChatCtx }) + setInitComplete := func(v bool) { + initMutex.Lock() + defer initMutex.Unlock() + initComplete = v + } + + tc := libkb.SetupTest(t, "LocationUpdateGuards", 0) + defer tc.Cleanup() + tracker := &recordingLiveLocationTracker{} + kbCtx = tc.G + kbChatCtx = &globals.ChatContext{LiveLocationTracker: tracker} + + setInitComplete(true) + LocationUpdate(1, 2, 3) + require.Empty(t, tracker.Coords(), "dropped while logged out") + + sigKey, err := libkb.GenerateNaclSigningKeyPair() + require.NoError(t, err) + encKey, err := libkb.GenerateNaclDHKeyPair() + require.NoError(t, err) + uv := keybase1.UserVersion{Uid: keybase1.MakeTestUID(1), EldestSeqno: 1} + require.NoError(t, tc.G.ActiveDevice.Set(libkb.NewMetaContextForTest(tc), uv, keybase1.DeviceID("dev"), + sigKey, encKey, "testuser-device", 0, libkb.KeychainModeNone)) + + setInitComplete(false) + LocationUpdate(1, 2, 3) + require.Empty(t, tracker.Coords(), "dropped before Init completes") + + setInitComplete(true) + LocationUpdate(1, 2, 3) + require.Equal(t, []chat1.Coordinate{{Lat: 1, Lon: 2, Accuracy: 3}}, tracker.Coords()) +} diff --git a/go/chat/globals/globals.go b/go/chat/globals/globals.go index 911f042cd739..a5736f4babaf 100644 --- a/go/chat/globals/globals.go +++ b/go/chat/globals/globals.go @@ -34,6 +34,7 @@ type ChatContext struct { AttachmentUploader types.AttachmentUploader // upload attachments NativeVideoHelper types.NativeVideoHelper // connection to native for doing things with video ShareIntentDonator types.ShareIntentDonator // donate share sheet suggestions (iOS only) + LocationWatcher types.LocationWatcher // native location service for live location (iOS only) StellarLoader types.StellarLoader // stellar payment/request loader StellarSender types.StellarSender // stellar in-chat payment sender StellarPushHandler types.OobmHandler diff --git a/go/chat/maps/livelocation.go b/go/chat/maps/livelocation.go index 6b8f6fe24a38..6e2ed5f209b3 100644 --- a/go/chat/maps/livelocation.go +++ b/go/chat/maps/livelocation.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "math" "sync" "time" @@ -12,6 +13,7 @@ import ( "github.com/keybase/client/go/chat/types" "github.com/keybase/client/go/chat/utils" "github.com/keybase/client/go/libkb" + "github.com/keybase/client/go/libkb/lifecycle" "github.com/keybase/client/go/protocol/chat1" "github.com/keybase/client/go/protocol/gregor1" "github.com/keybase/client/go/protocol/keybase1" @@ -28,10 +30,21 @@ type LiveLocationTracker struct { storage *trackStorage updateInterval time.Duration uid gregor1.UID - eg errgroup.Group trackers map[types.LiveLocationKey]*locationTrack lastCoord chat1.Coordinate maxCoords int + // eg runs the trackers started since the last Stop, which replaces it and + // waits on the old one: a tracker can start at any time, even before Start, + // and must not join a group that a Stop is already waiting on. + eg *errgroup.Group + // bgHold keeps the app running while tracking; guarded by the tracker's + // mutex and changed only by releaseHoldIfIdleLocked and + // ensureHoldOnFixLocked. + bgHold *lifecycle.Hold + + nativeWatchMu sync.Mutex + nativeWatchRefs int + fixThrottle fixThrottle // testing only TestingCoordsAddedCh chan struct{} @@ -47,6 +60,7 @@ func NewLiveLocationTracker(g *globals.Context) *LiveLocationTracker { updateInterval: 30 * time.Second, maxCoords: 500, clock: clockwork.NewRealClock(), + eg: new(errgroup.Group), } } @@ -69,8 +83,10 @@ func (l *LiveLocationTracker) Stop(ctx context.Context) chan struct{} { for _, t := range l.trackers { t.Stop() } + eg := l.eg + l.eg = new(errgroup.Group) go func() { - _ = l.eg.Wait() + _ = eg.Wait() close(ch) }() return ch @@ -92,6 +108,34 @@ func (l *LiveLocationTracker) saveLocked(ctx context.Context) { } } +func (l *LiveLocationTracker) removeTrackerLocked(ctx context.Context, t *locationTrack) { + delete(l.trackers, t.Key()) + l.saveLocked(ctx) + l.releaseHoldIfIdleLocked() +} + +// releaseHoldIfIdleLocked ends the hold once nothing is tracked. Every removal +// from the trackers map calls it. +func (l *LiveLocationTracker) releaseHoldIfIdleLocked() { + if len(l.trackers) == 0 && l.bgHold != nil { + l.bgHold.Release() + l.bgHold = nil + } +} + +// ensureHoldOnFixLocked opens a hold for a location fix, since the fix can +// wake a backgrounded app and the hold keeps it up until the update gets out. +// A hold the controller ended -- WillTerminate does, and nothing else -- is +// replaced, so a fix after one still gets the app held up. +func (l *LiveLocationTracker) ensureHoldOnFixLocked() { + if len(l.trackers) == 0 || !l.G().IsMobileAppType() { + return + } + if l.bgHold == nil || l.bgHold.Released() { + l.bgHold = l.G().MobileLifecycle.AcquireBackgroundWork() + } +} + func (l *LiveLocationTracker) restoreLocked(ctx context.Context) { trackers, err := l.storage.Restore(ctx) if err != nil { @@ -102,6 +146,10 @@ func (l *LiveLocationTracker) restoreLocked(ctx context.Context) { return } l.Debug(ctx, "restoreLocked: restored %d trackers", len(trackers)) + l.runRestoredLocked(trackers) +} + +func (l *LiveLocationTracker) runRestoredLocked(trackers []*locationTrack) { l.trackers = make(map[types.LiveLocationKey]*locationTrack) for _, t := range trackers { if t.IsStopped() { @@ -113,6 +161,16 @@ func (l *LiveLocationTracker) restoreLocked(ctx context.Context) { return l.tracker(myT) }) } + // The replacement above can drop a hold's only tracker without ever + // running removeTrackerLocked for it, so release directly here rather + // than leaving the app held until some later fix notices. + l.releaseHoldIfIdleLocked() +} + +func (l *LiveLocationTracker) getLastCoord() chat1.Coordinate { + l.Lock() + defer l.Unlock() + return l.lastCoord } func (l *LiveLocationTracker) getChatUI(ctx context.Context) libkb.ChatUI { @@ -185,8 +243,8 @@ func (l *LiveLocationTracker) updateMapUnfurl(ctx context.Context, t *locationTr var coords []chat1.Coordinate trackerCoords := t.GetCoords() if len(trackerCoords) == 0 { - if !l.lastCoord.IsZero() { - coords = []chat1.Coordinate{l.lastCoord} + if lastCoord := l.getLastCoord(); !lastCoord.IsZero() { + coords = []chat1.Coordinate{lastCoord} } else { return errors.New("no coordinates") } @@ -235,58 +293,99 @@ func (l *LiveLocationTracker) updateMapUnfurl(ctx context.Context, t *locationTr return nil } -func (l *LiveLocationTracker) startWatch(ctx context.Context, t *locationTrack) (watchID chat1.LocationWatchID, err error) { +// startWatch starts OS location updates for t and returns the function that +// ends them. +func (l *LiveLocationTracker) startWatch(ctx context.Context, t *locationTrack) (watchID chat1.LocationWatchID, stop func(), err error) { + if w := l.G().LocationWatcher; w != nil { + l.acquireNativeWatch(w) + // The native watcher only checks authorization, it can't prompt. The chat + // UI, when there is one, asks for permission and reports a failure in the + // conversation; with no UI this does nothing. + if _, err := l.getChatUI(ctx).ChatWatchPosition(ctx, t.convID, t.perm); err != nil { + l.Debug(ctx, "startWatch: unable to request location permission: %s", err) + } + return 0, func() { l.releaseNativeWatch(w) }, nil + } + watchID, err = l.startChatUIWatch(ctx, t) + if err != nil { + return 0, nil, err + } + return watchID, func() { + if err := l.getChatUI(ctx).ChatClearWatch(ctx, watchID); err != nil { + l.Debug(ctx, "tracker[%v]: error clearing watch: %+v", watchID, err) + } + }, nil +} + +// acquireNativeWatch and releaseNativeWatch share one native watch among all +// trackers. The watcher is called under the lock so it sees starts and stops +// in order. +func (l *LiveLocationTracker) acquireNativeWatch(w types.LocationWatcher) { + l.nativeWatchMu.Lock() + defer l.nativeWatchMu.Unlock() + l.nativeWatchRefs++ + if l.nativeWatchRefs == 1 { + l.fixThrottle = fixThrottle{} + w.StartWatching() + } +} + +func (l *LiveLocationTracker) releaseNativeWatch(w types.LocationWatcher) { + l.nativeWatchMu.Lock() + defer l.nativeWatchMu.Unlock() + l.nativeWatchRefs-- + if l.nativeWatchRefs == 0 { + w.StopWatching() + } +} + +func (l *LiveLocationTracker) startChatUIWatch(ctx context.Context, t *locationTrack) (watchID chat1.LocationWatchID, err error) { // try this a couple times in case we are starting fresh and the UI isn't ready yet maxWatchAttempts := 20 watchAttempts := 0 for { if watchID, err = l.getChatUI(ctx).ChatWatchPosition(ctx, t.convID, t.perm); err != nil { - l.Debug(ctx, "startWatch: unable to watch position: attempt: %d msg: %s", watchAttempts, err) + l.Debug(ctx, "startChatUIWatch: unable to watch position: attempt: %d msg: %s", watchAttempts, err) if watchAttempts > maxWatchAttempts { return 0, err } } else { break } - maxWatchAttempts++ - time.Sleep(time.Second) + watchAttempts++ + l.clock.Sleep(time.Second) } return watchID, nil } func (l *LiveLocationTracker) tracker(t *locationTrack) error { ctx := context.Background() - // check to see if we are being asked to start a tracker that is already expired - if t.endTime.Before(l.clock.Now()) { + // Every exit removes the tracker, which also ends the background-work hold + // once no tracker remains. + defer func() { l.Lock() defer l.Unlock() - delete(l.trackers, t.Key()) - l.saveLocked(ctx) + l.removeTrackerLocked(ctx, t) + }() + // check to see if we are being asked to start a tracker that is already expired + if t.endTime.Before(l.clock.Now()) { l.Debug(ctx, "tracker: old tracker, not running and clearing") return errors.New("tracker from the past") } // start up the OS watch routine - watchID, err := l.startWatch(ctx, t) + watchID, stopWatch, err := l.startWatch(ctx, t) if err != nil { + l.Debug(ctx, "tracker: unable to start watching, clearing: %s", err) return err } - defer func() { - // drop everything when our live location ends - err := l.getChatUI(ctx).ChatClearWatch(ctx, watchID) - if err != nil { - l.Debug(ctx, "tracker[%v]: error clearing watch: %+v", watchID, err) - } - l.Lock() - defer l.Unlock() - delete(l.trackers, t.Key()) - l.saveLocked(ctx) - }() + // Deferred after the removal, so it runs first: stop watching, then remove. + defer stopWatch() // if this is a live location request, just put whatever the last coord is on the screen, makes it // feel more live - if !l.lastCoord.IsZero() { + if lastCoord := l.getLastCoord(); !lastCoord.IsZero() { l.Debug(ctx, "tracker[%v]: updating with last coord", watchID) - t.updateCh <- l.lastCoord + t.updateCh <- lastCoord } firstUpdate := true shouldUpdate := false @@ -369,17 +468,81 @@ func (l *LiveLocationTracker) StartTracking(ctx context.Context, convID chat1.Co l.eg.Go(func() error { return l.tracker(t) }) } +// backgroundFixDistance is the least distance, in meters, the device must move +// before a native fix is recorded while the app is not in the foreground. +const backgroundFixDistance = 65 + +// maxBackgroundFixDistance caps how far, in meters, the device must move before +// a background fix is recorded, however uncertain the fixes are. Approximate +// Location reports kilometres of uncertainty on every fix, so without a cap a +// drive across town would record nothing. +const maxBackgroundFixDistance = 200 + +// earthRadiusMeters is the mean radius of the Earth. +const earthRadiusMeters = 6371008.8 + +// fixThrottle is what shouldRecordFix knows of the fixes since the native +// watch started. +type fixThrottle struct { + // lastRecorded is the latest recorded fix; nil until the first one. + lastRecorded *chat1.Coordinate +} + +// shouldRecordFix decides whether a native fix gets recorded, and returns the +// throttle to use for the next one. Out of the foreground a fix is recorded +// once it lies, in a straight line from the last one recorded, at least both +// fixes' accuracies added together: closer than that, the two could be the +// same spot, so jitter doesn't count as a move, even when the fix it is +// measured from was itself an outlier. That distance is kept between +// backgroundFixDistance and maxBackgroundFixDistance, so very coarse fixes +// (Approximate Location) still record a real move, at the cost of some of +// their jitter counting too. A fix less than half as uncertain as the last +// recorded one is recorded as well, so a coarse cold fix gets replaced once the +// device locks on; an accuracy of 0 means unknown and never counts as better. +// The first fix after the watch starts is recorded right away, so the move +// that relaunched the app gets posted. +func shouldRecordFix(state keybase1.MobileAppState, last fixThrottle, next chat1.Coordinate) (bool, fixThrottle) { + record := last.lastRecorded == nil || state == keybase1.MobileAppState_FOREGROUND + if !record { + anchor := *last.lastRecorded + minMove := math.Min(maxBackgroundFixDistance, + math.Max(backgroundFixDistance, anchor.Accuracy+next.Accuracy)) + record = distanceMeters(anchor, next) >= minMove || + (next.Accuracy > 0 && next.Accuracy < anchor.Accuracy/2) + } + if record { + last.lastRecorded = &next + } + return record, last +} + +// distanceMeters is the great-circle distance between a and b. +func distanceMeters(a, b chat1.Coordinate) float64 { + rad := func(deg float64) float64 { return deg * math.Pi / 180 } + dLat := rad(b.Lat - a.Lat) + dLon := rad(b.Lon - a.Lon) + h := math.Sin(dLat/2)*math.Sin(dLat/2) + + math.Cos(rad(a.Lat))*math.Cos(rad(b.Lat))*math.Sin(dLon/2)*math.Sin(dLon/2) + return 2 * earthRadiusMeters * math.Asin(math.Min(1, math.Sqrt(h))) +} + +// NativeLocationUpdate takes a fix from the native location watcher, which +// reports every fix, and records the ones shouldRecordFix lets through. +func (l *LiveLocationTracker) NativeLocationUpdate(ctx context.Context, coord chat1.Coordinate) { + l.nativeWatchMu.Lock() + record, throttle := shouldRecordFix(l.G().MobileAppState.State(), l.fixThrottle, coord) + l.fixThrottle = throttle + l.nativeWatchMu.Unlock() + if record { + l.LocationUpdate(ctx, coord) + } +} + func (l *LiveLocationTracker) LocationUpdate(ctx context.Context, coord chat1.Coordinate) { defer l.Trace(ctx, nil, "LocationUpdate")() l.Lock() defer l.Unlock() - // 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. + l.ensureHoldOnFixLocked() if l.lastCoord.Eq(coord) { l.Debug(ctx, "LocationUpdate: ignoring dup coordinate") return diff --git a/go/chat/maps/livelocation_appstate_test.go b/go/chat/maps/livelocation_appstate_test.go new file mode 100644 index 000000000000..843a5eed5e35 --- /dev/null +++ b/go/chat/maps/livelocation_appstate_test.go @@ -0,0 +1,122 @@ +package maps + +import ( + "context" + "testing" + "time" + + "github.com/keybase/client/go/chat/globals" + "github.com/keybase/client/go/protocol/chat1" + + "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 TestLiveLocationTrackerBackgroundActive(t *testing.T) { + t.Setenv("KEYBASE_APP_TYPE", string(libkb.MobileAppType)) + tc := libkb.SetupTest(t, "LiveLocationTrackerBackgroundActive", 0) + defer tc.Cleanup() + appState := tc.G.MobileAppState + l := NewLiveLocationTracker(globals.NewContext(tc.G, &globals.ChatContext{})) + ctx := context.Background() + coord := func(lat float64) chat1.Coordinate { return chat1.Coordinate{Lat: lat, Lon: 1} } + addTracker := func(msgID chat1.MessageID) *locationTrack { + track := newLocationTrack(chat1.ConversationID("conv"), msgID, time.Now().Add(time.Hour), false, 10, false) + l.Lock() + defer l.Unlock() + l.trackers[track.Key()] = track + return track + } + removeTracker := func(track *locationTrack) { + l.Lock() + defer l.Unlock() + l.removeTrackerLocked(ctx, track) + } + + lc := tc.G.MobileLifecycle + lifecycletest.ToBackground(lc) + require.Equal(t, keybase1.MobileAppState_BACKGROUND, appState.State()) + l.LocationUpdate(ctx, coord(1)) + require.Equal(t, keybase1.MobileAppState_BACKGROUND, appState.State(), "no trackers, no hold") + + first := addTracker(1) + second := addTracker(2) + l.LocationUpdate(ctx, coord(2)) + require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, appState.State()) + removeTracker(first) + require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, appState.State(), "still tracking") + removeTracker(second) + require.Equal(t, keybase1.MobileAppState_BACKGROUND, appState.State()) + + // A fix in the foreground holds too, so backgrounding keeps the work running. + lc.UIActive() + third := addTracker(3) + l.LocationUpdate(ctx, coord(3)) + require.Equal(t, keybase1.MobileAppState_FOREGROUND, appState.State()) + lifecycletest.ToBackground(lc) + require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, appState.State()) + removeTracker(third) + require.Equal(t, keybase1.MobileAppState_BACKGROUND, appState.State()) +} + +// WillTerminate is the one controller event that ends a live location hold. A +// fix after it opens a new one, rather than counting on the ended hold. +func TestLiveLocationTrackerHoldSurvivesWillTerminate(t *testing.T) { + t.Setenv("KEYBASE_APP_TYPE", string(libkb.MobileAppType)) + tc := libkb.SetupTest(t, "LiveLocationTrackerWillTerminate", 0) + defer tc.Cleanup() + appState := tc.G.MobileAppState + l := NewLiveLocationTracker(globals.NewContext(tc.G, &globals.ChatContext{})) + ctx := context.Background() + coord := func(lat float64) chat1.Coordinate { return chat1.Coordinate{Lat: lat, Lon: 1} } + + track := newLocationTrack(chat1.ConversationID("conv"), 1, time.Now().Add(time.Hour), false, 10, false) + l.Lock() + l.trackers[track.Key()] = track + l.Unlock() + + lc := tc.G.MobileLifecycle + lifecycletest.ToBackground(lc) + l.LocationUpdate(ctx, coord(1)) + require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, appState.State()) + + lc.WillTerminate(func() {}) + require.Equal(t, keybase1.MobileAppState_BACKGROUND, appState.State(), "WillTerminate left a hold open") + + l.LocationUpdate(ctx, coord(2)) + require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, appState.State(), + "a fix after WillTerminate did not open a new hold") +} + +// A Start whose restored trackers are all gone (stopped, or none at all) +// still finds an outstanding hold from before the restore -- runRestoredLocked +// replaces the tracker map wholesale, so it never runs removeTrackerLocked for +// whatever was tracked previously. +func TestRestoredTrackersReleaseHoldWhenEmpty(t *testing.T) { + t.Setenv("KEYBASE_APP_TYPE", string(libkb.MobileAppType)) + tc := libkb.SetupTest(t, "LiveLocationRestoredReleasesHold", 0) + defer tc.Cleanup() + appState := tc.G.MobileAppState + l := NewLiveLocationTracker(globals.NewContext(tc.G, &globals.ChatContext{})) + ctx := context.Background() + + track := newLocationTrack(chat1.ConversationID("conv"), 1, time.Now().Add(time.Hour), false, 10, false) + l.Lock() + l.trackers[track.Key()] = track + l.Unlock() + + lc := tc.G.MobileLifecycle + lifecycletest.ToBackground(lc) + l.LocationUpdate(ctx, chat1.Coordinate{Lat: 1, Lon: 1}) + require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, appState.State(), "the fix opened a hold") + + stopped := newLocationTrack(chat1.ConversationID("conv"), 2, time.Now().Add(time.Hour), false, 10, true) + l.Lock() + l.runRestoredLocked([]*locationTrack{stopped}) + l.Unlock() + + require.Equal(t, keybase1.MobileAppState_BACKGROUND, appState.State(), + "a restore with nothing live left the old hold open") +} diff --git a/go/chat/maps/livelocation_throttle_test.go b/go/chat/maps/livelocation_throttle_test.go new file mode 100644 index 000000000000..bb2bdd01c806 --- /dev/null +++ b/go/chat/maps/livelocation_throttle_test.go @@ -0,0 +1,216 @@ +package maps + +import ( + "math" + "testing" + + "github.com/keybase/client/go/protocol/chat1" + "github.com/keybase/client/go/protocol/keybase1" + "github.com/stretchr/testify/require" +) + +// north returns c moved due north by meters, which is exact under the +// spherical distance the throttle measures. +func north(c chat1.Coordinate, meters float64) chat1.Coordinate { + c.Lat += meters / earthRadiusMeters * 180 / math.Pi + return c +} + +func withAccuracy(c chat1.Coordinate, accuracy float64) chat1.Coordinate { + c.Accuracy = accuracy + return c +} + +func TestShouldRecordFix(t *testing.T) { + origin := chat1.Coordinate{Lat: 37.7749, Lon: -122.4194, Accuracy: 10} + at := func(c chat1.Coordinate) *chat1.Coordinate { return &c } + + cases := []struct { + name string + state keybase1.MobileAppState + last fixThrottle + next chat1.Coordinate + record bool + }{ + { + name: "first fix since the watch started, in the background", + state: keybase1.MobileAppState_BACKGROUND, + next: origin, + record: true, + }, + { + name: "any move in the foreground", + state: keybase1.MobileAppState_FOREGROUND, + last: fixThrottle{lastRecorded: at(origin)}, + next: north(origin, 1), + record: true, + }, + { + name: "short move in the background", + state: keybase1.MobileAppState_BACKGROUND, + last: fixThrottle{lastRecorded: at(origin)}, + next: north(origin, 10), + record: false, + }, + { + name: "short move while background work runs", + state: keybase1.MobileAppState_BACKGROUNDACTIVE, + last: fixThrottle{lastRecorded: at(origin)}, + next: north(origin, 10), + record: false, + }, + { + name: "short move while on screen but not active", + state: keybase1.MobileAppState_INACTIVE, + last: fixThrottle{lastRecorded: at(origin)}, + next: north(origin, 10), + record: false, + }, + { + name: "long move in the background", + state: keybase1.MobileAppState_BACKGROUND, + last: fixThrottle{lastRecorded: at(origin)}, + next: north(origin, 100), + record: true, + }, + { + name: "just past the distance", + state: keybase1.MobileAppState_BACKGROUND, + last: fixThrottle{lastRecorded: at(origin)}, + next: north(origin, 65.1), + record: true, + }, + { + name: "long move with a coarse fix", + state: keybase1.MobileAppState_BACKGROUND, + last: fixThrottle{lastRecorded: at(origin)}, + next: withAccuracy(north(origin, 100), 100), + record: false, + }, + { + name: "move past both accuracies", + state: keybase1.MobileAppState_BACKGROUND, + last: fixThrottle{lastRecorded: at(origin)}, + next: withAccuracy(north(origin, 111), 100), + record: true, + }, + { + name: "coarse fixes, just short of the cap", + state: keybase1.MobileAppState_BACKGROUND, + last: fixThrottle{lastRecorded: at(withAccuracy(origin, 3000))}, + next: withAccuracy(north(origin, 199.9), 3000), + record: false, + }, + { + name: "coarse fixes, just past the cap", + state: keybase1.MobileAppState_BACKGROUND, + last: fixThrottle{lastRecorded: at(withAccuracy(origin, 3000))}, + next: withAccuracy(north(origin, 200.1), 3000), + record: true, + }, + { + name: "accuracies summing to just under the cap", + state: keybase1.MobileAppState_BACKGROUND, + last: fixThrottle{lastRecorded: at(withAccuracy(origin, 90))}, + next: withAccuracy(north(origin, 189), 100), + record: false, + }, + { + name: "accuracies summing to just under the cap, moved past them", + state: keybase1.MobileAppState_BACKGROUND, + last: fixThrottle{lastRecorded: at(withAccuracy(origin, 90))}, + next: withAccuracy(north(origin, 190.1), 100), + record: true, + }, + { + name: "short move with unknown accuracy", + state: keybase1.MobileAppState_BACKGROUND, + last: fixThrottle{lastRecorded: at(origin)}, + next: withAccuracy(north(origin, 10), 0), + record: false, + }, + { + name: "just short of the distance", + state: keybase1.MobileAppState_BACKGROUND, + last: fixThrottle{lastRecorded: at(origin)}, + next: north(origin, 64.9), + record: false, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + record, after := shouldRecordFix(c.state, c.last, c.next) + require.Equal(t, c.record, record) + want := c.last + if c.record { + want.lastRecorded = at(c.next) + } + require.Equal(t, want, after) + }) + } +} + +// recordedAt feeds fixes to a fresh throttle in the background and returns the +// indexes of the ones it records. +func recordedAt(fixes []chat1.Coordinate) (recorded []int) { + var throttle fixThrottle + for i, fix := range fixes { + var record bool + record, throttle = shouldRecordFix(keybase1.MobileAppState_BACKGROUND, throttle, fix) + if record { + recorded = append(recorded, i) + } + } + return recorded +} + +func TestShouldRecordFixIgnoresJitter(t *testing.T) { + origin := chat1.Coordinate{Lat: 37.7749, Lon: -122.4194, Accuracy: 100} + fixes := []chat1.Coordinate{origin} + for i := 0; i < 20; i++ { + fixes = append(fixes, north(origin, 40), north(origin, -40)) + } + require.Equal(t, []int{0}, recordedAt(fixes)) +} + +func TestShouldRecordFixSlowDrift(t *testing.T) { + origin := chat1.Coordinate{Lat: 37.7749, Lon: -122.4194, Accuracy: 10} + var fixes []chat1.Coordinate + for i := 0; i <= 14; i++ { + fixes = append(fixes, north(origin, float64(10*i))) + } + // 70m from origin at fix 7, then 70m from that at fix 14. + require.Equal(t, []int{0, 7, 14}, recordedAt(fixes)) +} + +func TestShouldRecordFixIgnoresJitterAroundOutlierAnchor(t *testing.T) { + center := chat1.Coordinate{Lat: 37.7749, Lon: -122.4194} + fixes := []chat1.Coordinate{withAccuracy(north(center, 40), 100)} + for i := 0; i < 20; i++ { + fixes = append(fixes, + withAccuracy(north(center, -40), 65), + withAccuracy(north(center, 40), 100)) + } + require.Equal(t, []int{0}, recordedAt(fixes)) +} + +func TestShouldRecordFixReplacesCoarseAnchor(t *testing.T) { + center := chat1.Coordinate{Lat: 37.7749, Lon: -122.4194, Accuracy: 10} + coarse := north(center, 300) + coarse.Accuracy = 1000 + fixes := []chat1.Coordinate{coarse, center, north(center, 20), north(center, -20), north(center, 70)} + // The locked-on fix replaces the coarse one, jitter around it is ignored, + // and a real move from it is recorded. + require.Equal(t, []int{0, 1, 4}, recordedAt(fixes)) +} + +func TestShouldRecordFixCoarseFixesStillRecordMoves(t *testing.T) { + // Approximate Location reports every fix kilometres wide, so the fixes + // alone can never tell a move from jitter; a steady drive still records. + origin := chat1.Coordinate{Lat: 37.7749, Lon: -122.4194, Accuracy: 3000} + var fixes []chat1.Coordinate + for i := 0; i <= 4; i++ { + fixes = append(fixes, north(origin, float64(250*i))) + } + require.Equal(t, []int{0, 1, 2, 3, 4}, recordedAt(fixes)) +} diff --git a/go/chat/maps/livelocation_watch_test.go b/go/chat/maps/livelocation_watch_test.go new file mode 100644 index 000000000000..0b0c16aff966 --- /dev/null +++ b/go/chat/maps/livelocation_watch_test.go @@ -0,0 +1,343 @@ +package maps + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/keybase/client/go/chat/globals" + "github.com/keybase/client/go/chat/types" + "github.com/keybase/client/go/chat/utils" + "github.com/keybase/client/go/kbtest" + "github.com/keybase/client/go/libkb" + "github.com/keybase/client/go/libkb/lifecycle/lifecycletest" + "github.com/keybase/client/go/protocol/chat1" + "github.com/keybase/client/go/protocol/keybase1" + "github.com/keybase/clockwork" + "github.com/stretchr/testify/require" +) + +type fakeLocationWatcher struct { + sync.Mutex + calls []string +} + +func (w *fakeLocationWatcher) StartWatching() { w.record("start") } +func (w *fakeLocationWatcher) StopWatching() { w.record("stop") } + +func (w *fakeLocationWatcher) record(call string) { + w.Lock() + defer w.Unlock() + w.calls = append(w.calls, call) +} + +func (w *fakeLocationWatcher) Calls() []string { + w.Lock() + defer w.Unlock() + return append([]string(nil), w.calls...) +} + +type watchCall struct { + convID chat1.ConversationID + perm chat1.UIWatchPositionPerm +} + +type fakeWatchChatUI struct { + utils.NullChatUI + sync.Mutex + watches []watchCall + clears []chat1.LocationWatchID + nextID chat1.LocationWatchID +} + +func (u *fakeWatchChatUI) ChatWatchPosition(_ context.Context, convID chat1.ConversationID, + perm chat1.UIWatchPositionPerm, +) (chat1.LocationWatchID, error) { + u.Lock() + defer u.Unlock() + u.watches = append(u.watches, watchCall{convID: convID, perm: perm}) + u.nextID++ + return u.nextID, nil +} + +func (u *fakeWatchChatUI) ChatClearWatch(_ context.Context, id chat1.LocationWatchID) error { + u.Lock() + defer u.Unlock() + u.clears = append(u.clears, id) + return nil +} + +func (u *fakeWatchChatUI) Watches() []watchCall { + u.Lock() + defer u.Unlock() + return append([]watchCall(nil), u.watches...) +} + +func (u *fakeWatchChatUI) Clears() []chat1.LocationWatchID { + u.Lock() + defer u.Unlock() + return append([]chat1.LocationWatchID(nil), u.clears...) +} + +type nilCtxFactory struct{} + +func (nilCtxFactory) NewKeyFinder() types.KeyFinder { return nil } +func (nilCtxFactory) NewUPAKFinder() types.UPAKFinder { return nil } + +// newWatchTestTracker builds a tracker whose map unfurls fail right away (the +// mock chat helper returns no message), so the tracker loop runs without a +// chat server. chatUI nil means no UI is connected. +func newWatchTestTracker(t *testing.T, tc libkb.TestContext, watcher types.LocationWatcher, + chatUI libkb.ChatUI, +) *LiveLocationTracker { + tc.G.ChatHelper = kbtest.NewMockChatHelper() + tc.G.SetUIRouter(kbtest.NewMockUIRouter(chatUI)) + g := globals.NewContext(tc.G, &globals.ChatContext{ + CtxFactory: nilCtxFactory{}, + LocationWatcher: watcher, + }) + l := NewLiveLocationTracker(g) + l.SetClock(clockwork.NewFakeClock()) + t.Cleanup(func() { + l.StopAllTracking(context.Background()) + select { + case <-l.Stop(context.Background()): + case <-time.After(10 * time.Second): + t.Error("trackers did not stop") + } + }) + return l +} + +var watchTestConvID = chat1.ConversationID("conv") + +func startTestTracker(l *LiveLocationTracker, msgID chat1.MessageID) *locationTrack { + l.StartTracking(context.Background(), watchTestConvID, msgID, l.clock.Now().Add(time.Hour)) + l.Lock() + defer l.Unlock() + return l.trackers[newLocationTrack(watchTestConvID, msgID, time.Time{}, false, 0, false).Key()] +} + +func waitTrackerRemoved(t *testing.T, l *LiveLocationTracker, track *locationTrack) { + require.Eventually(t, func() bool { + l.Lock() + defer l.Unlock() + _, ok := l.trackers[track.Key()] + return !ok + }, 10*time.Second, 5*time.Millisecond) +} + +func TestLiveLocationTrackerNativeWatcher(t *testing.T) { + tc := libkb.SetupTest(t, "LiveLocationTrackerNativeWatcher", 0) + t.Cleanup(tc.Cleanup) + watcher := &fakeLocationWatcher{} + ui := &fakeWatchChatUI{} + l := newWatchTestTracker(t, tc, watcher, ui) + + first := startTestTracker(l, 1) + second := startTestTracker(l, 2) + require.Eventually(t, func() bool { return len(ui.Watches()) == 2 }, 10*time.Second, 5*time.Millisecond) + require.Equal(t, []string{"start"}, watcher.Calls(), "one native watch for both trackers") + for _, w := range ui.Watches() { + require.Equal(t, watchCall{convID: watchTestConvID, perm: chat1.UIWatchPositionPerm_ALWAYS}, w, + "the UI is still asked for permission") + } + + first.Stop() + waitTrackerRemoved(t, l, first) + require.Equal(t, []string{"start"}, watcher.Calls(), "still tracking") + + second.Stop() + waitTrackerRemoved(t, l, second) + require.Equal(t, []string{"start", "stop"}, watcher.Calls()) + require.Empty(t, ui.Clears(), "the UI never watched, so it never clears") + + third := startTestTracker(l, 3) + require.Eventually(t, func() bool { return len(watcher.Calls()) == 3 }, 10*time.Second, 5*time.Millisecond) + require.Equal(t, []string{"start", "stop", "start"}, watcher.Calls()) + third.Stop() + waitTrackerRemoved(t, l, third) + require.Equal(t, []string{"start", "stop", "start", "stop"}, watcher.Calls()) +} + +func TestLiveLocationTrackerChatUIWatch(t *testing.T) { + tc := libkb.SetupTest(t, "LiveLocationTrackerChatUIWatch", 0) + t.Cleanup(tc.Cleanup) + ui := &fakeWatchChatUI{} + l := newWatchTestTracker(t, tc, nil, ui) + + first := startTestTracker(l, 1) + second := startTestTracker(l, 2) + require.Eventually(t, func() bool { return len(ui.Watches()) == 2 }, 10*time.Second, 5*time.Millisecond) + + first.Stop() + waitTrackerRemoved(t, l, first) + require.Len(t, ui.Clears(), 1) + second.Stop() + waitTrackerRemoved(t, l, second) + require.ElementsMatch(t, []chat1.LocationWatchID{1, 2}, ui.Clears()) +} + +func TestLiveLocationTrackerRestoreStartsNativeWatch(t *testing.T) { + tc := libkb.SetupTest(t, "LiveLocationTrackerRestoreStartsNativeWatch", 0) + t.Cleanup(tc.Cleanup) + watcher := &fakeLocationWatcher{} + l := newWatchTestTracker(t, tc, watcher, nil) + + endTime := l.clock.Now().Add(time.Hour) + live := newLocationTrack(watchTestConvID, 1, endTime, false, 10, false) + other := newLocationTrack(watchTestConvID, 2, endTime, false, 10, false) + stopped := newLocationTrack(watchTestConvID, 3, endTime, false, 10, true) + l.Lock() + l.runRestoredLocked([]*locationTrack{live, other, stopped}) + l.Unlock() + + require.Eventually(t, func() bool { return len(watcher.Calls()) == 1 }, 10*time.Second, 5*time.Millisecond) + require.Equal(t, []string{"start"}, watcher.Calls()) + require.True(t, l.ActivelyTracking(context.Background())) + + live.Stop() + other.Stop() + waitTrackerRemoved(t, l, live) + waitTrackerRemoved(t, l, other) + require.Equal(t, []string{"start", "stop"}, watcher.Calls()) +} + +func TestLiveLocationTrackerNativeWatchStopsWhenTrackerEnds(t *testing.T) { + tc := libkb.SetupTest(t, "LiveLocationTrackerNativeWatchStopsWhenTrackerEnds", 0) + t.Cleanup(tc.Cleanup) + watcher := &fakeLocationWatcher{} + l := newWatchTestTracker(t, tc, watcher, nil) + clock := l.clock.(clockwork.FakeClock) + + track := startTestTracker(l, 1) + require.Eventually(t, func() bool { return len(watcher.Calls()) == 1 }, 10*time.Second, 5*time.Millisecond) + clock.BlockUntil(2) + clock.Advance(2 * time.Hour) + waitTrackerRemoved(t, l, track) + require.Equal(t, []string{"start", "stop"}, watcher.Calls()) +} + +type failingWatchChatUI struct { + utils.NullChatUI + attempts atomic.Int32 +} + +func (u *failingWatchChatUI) ChatWatchPosition(context.Context, chat1.ConversationID, + chat1.UIWatchPositionPerm, +) (chat1.LocationWatchID, error) { + u.attempts.Add(1) + return 0, errors.New("no UI yet") +} + +func TestLiveLocationTrackerChatUIWatchGivesUp(t *testing.T) { + tc := libkb.SetupTest(t, "LiveLocationTrackerChatUIWatchGivesUp", 0) + t.Cleanup(tc.Cleanup) + ui := &failingWatchChatUI{} + l := newWatchTestTracker(t, tc, nil, ui) + clock := l.clock.(clockwork.FakeClock) + + done := make(chan error, 1) + track := newLocationTrack(watchTestConvID, 1, clock.Now().Add(time.Hour), false, 10, false) + go func() { + _, err := l.startChatUIWatch(context.Background(), track) + done <- err + }() + // One try plus 21 retries, a second apart. + const maxAttempts = 22 + for n := int32(1); ; n++ { + require.Eventually(t, func() bool { return ui.attempts.Load() >= n }, 10*time.Second, time.Millisecond) + if n == maxAttempts { + break + } + clock.BlockUntil(1) + clock.Advance(time.Second) + } + select { + case err := <-done: + require.Error(t, err) + case <-time.After(10 * time.Second): + require.Fail(t, "still retrying", "after %d attempts", ui.attempts.Load()) + } + require.EqualValues(t, maxAttempts, ui.attempts.Load()) +} + +// A tracker whose watch never starts ends like any other: it leaves no tracker +// and no background-work hold, so the app can still reach BACKGROUND. +func TestLiveLocationTrackerFailedWatchLeavesNoHold(t *testing.T) { + t.Setenv("KEYBASE_APP_TYPE", string(libkb.MobileAppType)) + tc := libkb.SetupTest(t, "LiveLocationTrackerFailedWatchLeavesNoHold", 0) + t.Cleanup(tc.Cleanup) + ui := &failingWatchChatUI{} + l := newWatchTestTracker(t, tc, nil, ui) + clock := l.clock.(clockwork.FakeClock) + appState := tc.G.MobileAppState + + track := startTestTracker(l, 1) + require.NotNil(t, track) + // A fix while the watch is still retrying holds the app up. + require.Eventually(t, func() bool { return ui.attempts.Load() >= 1 }, 10*time.Second, time.Millisecond) + l.LocationUpdate(context.Background(), chat1.Coordinate{Lat: 1, Lon: 1}) + lifecycletest.ToBackground(tc.G.MobileLifecycle) + require.Equal(t, keybase1.MobileAppState_BACKGROUNDACTIVE, appState.State()) + + for ui.attempts.Load() < 22 { + // Read the count while the retry is parked on the clock: Advance + // releases it, so a count read afterward can already include it. + clock.BlockUntil(1) + n := ui.attempts.Load() + clock.Advance(time.Second) + require.Eventually(t, func() bool { return ui.attempts.Load() > n }, 10*time.Second, time.Millisecond) + } + waitTrackerRemoved(t, l, track) + require.Equal(t, keybase1.MobileAppState_BACKGROUND, appState.State()) + + // A later fix finds no tracker to hold the app up for. + tc.G.MobileLifecycle.UIActive() + l.LocationUpdate(context.Background(), chat1.Coordinate{Lat: 2, Lon: 2}) + lifecycletest.ToBackground(tc.G.MobileLifecycle) + require.Equal(t, keybase1.MobileAppState_BACKGROUND, appState.State()) +} + +// gatedClearChatUI holds the first watch's clear until gate closes. +type gatedClearChatUI struct { + fakeWatchChatUI + clearing chan struct{} + gate chan struct{} +} + +func (u *gatedClearChatUI) ChatClearWatch(ctx context.Context, id chat1.LocationWatchID) error { + if id == 1 { + close(u.clearing) + <-u.gate + } + return u.fakeWatchChatUI.ChatClearWatch(ctx, id) +} + +// Stop waits for the trackers it stops, not for one started after it. +func TestLiveLocationTrackerStopWaitsOnlyForItsTrackers(t *testing.T) { + tc := libkb.SetupTest(t, "LiveLocationTrackerStopWaitsOnlyForItsTrackers", 0) + t.Cleanup(tc.Cleanup) + ui := &gatedClearChatUI{clearing: make(chan struct{}), gate: make(chan struct{})} + l := newWatchTestTracker(t, tc, nil, ui) + + require.NotNil(t, startTestTracker(l, 1)) + require.Eventually(t, func() bool { return len(ui.Watches()) == 1 }, 10*time.Second, 5*time.Millisecond) + stopped := l.Stop(context.Background()) + select { + case <-ui.clearing: + case <-time.After(10 * time.Second): + require.FailNow(t, "the stopped tracker did not exit") + } + // The stopped tracker is still exiting when the next one starts. + require.NotNil(t, startTestTracker(l, 2)) + close(ui.gate) + select { + case <-stopped: + case <-time.After(10 * time.Second): + require.FailNow(t, "Stop waited for a tracker started after it") + } +} diff --git a/go/chat/types/interfaces.go b/go/chat/types/interfaces.go index 3fc7a7da8d6e..2ff29723e0ee 100644 --- a/go/chat/types/interfaces.go +++ b/go/chat/types/interfaces.go @@ -464,6 +464,15 @@ type ShareIntentDonator interface { DeleteDonation(conversationID string) } +// LocationWatcher runs the OS location service natively (iOS), so live +// location keeps working without the UI. Fixes come back through +// LiveLocationTracker.NativeLocationUpdate. When nil, the chat UI watches +// position. +type LocationWatcher interface { + StartWatching() + StopWatching() +} + type StellarLoader interface { LoadPayment(ctx context.Context, convID chat1.ConversationID, msgID chat1.MessageID, senderUsername string, paymentID stellar1.PaymentID) *chat1.UIPaymentInfo LoadRequest(ctx context.Context, convID chat1.ConversationID, msgID chat1.MessageID, senderUsername string, requestID stellar1.KeybaseRequestID) *chat1.UIRequestInfo @@ -577,6 +586,7 @@ type LiveLocationTracker interface { GetCurrentPosition(ctx context.Context, convID chat1.ConversationID, msgID chat1.MessageID) StartTracking(ctx context.Context, convID chat1.ConversationID, msgID chat1.MessageID, endTime time.Time) LocationUpdate(ctx context.Context, coord chat1.Coordinate) + NativeLocationUpdate(ctx context.Context, coord chat1.Coordinate) GetCoordinates(ctx context.Context, key LiveLocationKey) []chat1.Coordinate GetEndTime(ctx context.Context, key LiveLocationKey) *time.Time ActivelyTracking(ctx context.Context) bool diff --git a/go/chat/unfurl/scraper_test.go b/go/chat/unfurl/scraper_test.go index 6a077e9b2700..f97b8e823fd2 100644 --- a/go/chat/unfurl/scraper_test.go +++ b/go/chat/unfurl/scraper_test.go @@ -387,6 +387,10 @@ func (t *testingLiveLocationTracker) LocationUpdate(ctx context.Context, coord c t.coords = append(t.coords, coord) } +func (t *testingLiveLocationTracker) NativeLocationUpdate(ctx context.Context, coord chat1.Coordinate) { + t.LocationUpdate(ctx, coord) +} + func (t *testingLiveLocationTracker) GetCoordinates(ctx context.Context, key types.LiveLocationKey) []chat1.Coordinate { return t.coords } 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 f1aaabaf42b4..96e85fae4b72 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 @@ -422,7 +422,7 @@ class MainActivity : ReactActivity() { val isIPad = false val isIOS = false Keybase.initOnce(context.filesDir.path, "", context.getFileStreamPath("service.log").absolutePath, "prod", false, - DNSNSFetcher(), VideoHelper(), mobileOsVersion, isIPad, KBInstallReferrerListener(context), isIOS, null) + DNSNSFetcher(), VideoHelper(), mobileOsVersion, isIPad, KBInstallReferrerListener(context), isIOS, null, null) } } } diff --git a/shared/constants/init/index.tsx b/shared/constants/init/index.tsx index 985cf814046c..505e7c24b795 100644 --- a/shared/constants/init/index.tsx +++ b/shared/constants/init/index.tsx @@ -66,6 +66,23 @@ const ensureBackgroundTask = (ExpoTaskManager: ExpoTaskManagerModule) => { }) } +// Builds from before native iOS location left this expo task registered, and expo restores it +// into a second CLLocationManager on every launch. JS is early enough to remove it: with no +// UMAppLoader registered, expo can never start JS for a restored task on a background launch +// (expo-task-manager EXTaskService.m `_loadAppWithId:appUrl:`). +export const unregisterLegacyIOSLocationTask = async () => { + if (!isIOS) return + const {ExpoTaskManager} = _getNative() + try { + if (await ExpoTaskManager.isTaskRegisteredAsync(locationTaskName)) { + await ExpoTaskManager.unregisterTaskAsync(locationTaskName) + logger.info('[location] removed the legacy iOS background location task') + } + } catch (error) { + logger.info('[location] failed to remove the legacy iOS background location task: ' + String(error)) + } +} + const setPermissionDeniedCommandStatus = (conversationIDKey: T.Chat.ConversationIDKey, text: string) => { setThreadInputCommandStatus(conversationIDKey, { actions: [T.RPCChat.UICommandStatusActionTyp.appsettings], @@ -92,6 +109,9 @@ const onChatWatchPosition = async ( ) } + // iOS watches location natively (ios/Keybase/LocationWatcher.swift), so JS only asks for permission. + if (isIOS) return + locationRefs++ if (locationRefs === 1) { @@ -112,6 +132,7 @@ const onChatWatchPosition = async ( } const onChatClearWatch = async () => { + if (isIOS) return const {ExpoLocation, ExpoTaskManager} = _getNative() locationRefs-- if (locationRefs <= 0) { @@ -452,6 +473,8 @@ const _initNativePlatformListener = () => { initPushListener() + ignorePromise(unregisterLegacyIOSLocationTask()) + const {NetInfo} = _getNative() NetInfo.addEventListener(({type}) => { useShellState.getState().dispatch.osNetworkStatusChanged(type !== NetInfo.NetInfoStateType.none, type) diff --git a/shared/constants/init/location-watch.test.ts b/shared/constants/init/location-watch.test.ts new file mode 100644 index 000000000000..55868d1fd195 --- /dev/null +++ b/shared/constants/init/location-watch.test.ts @@ -0,0 +1,129 @@ +/// +import type * as Init from './index' +import type * as EngineGen from '@/constants/rpc' + +// The init module picks its mobile behavior from the platform globals, so each test loads it +// fresh with them set and the native modules mocked. + +const calls = new Array() +const originalGlobals = {isAndroid: global.isAndroid, isIOS: global.isIOS, isMobile: global.isMobile} + +const load = (platform: 'ios' | 'android'): typeof Init => { + global.isMobile = true + global.isIOS = platform === 'ios' + global.isAndroid = platform === 'android' + jest.resetModules() + jest.doMock('./platform', () => ({ + getNative: () => ({ + ExpoLocation: { + startLocationUpdatesAsync: async () => { + calls.push('startLocationUpdates') + return Promise.resolve() + }, + stopLocationUpdatesAsync: async () => { + calls.push('stopLocationUpdates') + return Promise.resolve() + }, + }, + ExpoTaskManager: { + defineTask: () => { + calls.push('defineTask') + }, + // Registered until it is unregistered, like the real task store. + isTaskRegisteredAsync: async () => { + calls.push('isTaskRegistered') + return Promise.resolve(!calls.includes('unregisterTask')) + }, + unregisterTaskAsync: async () => { + calls.push('unregisterTask') + return Promise.resolve() + }, + }, + requestLocationPermission: async (perm: unknown) => { + calls.push(`requestPermission:${String(perm)}`) + return Promise.resolve() + }, + }), + })) + jest.doMock('./shared', () => ({ + _onEngineIncoming: () => {}, + })) + // pulls in the mobile theme, which needs more of react-native than the test mock has + jest.doMock('@/fs/common/lifecycle', () => ({})) + return require('./index') as typeof Init +} + +const watchPosition = () => + ({ + payload: { + params: {convID: new Uint8Array([0xaa, 0xbb]), perm: 1}, + response: { + result: () => { + calls.push('result') + }, + }, + }, + type: 'chat.1.chatUi.chatWatchPosition', + }) as unknown as EngineGen.Actions + +const clearWatch = () => + ({ + payload: {params: {id: 1}, response: {result: () => {}}}, + type: 'chat.1.chatUi.chatClearWatch', + }) as unknown as EngineGen.Actions + +const flush = async () => new Promise(resolve => setTimeout(resolve, 0)) + +afterEach(() => { + calls.length = 0 + jest.dontMock('./platform') + jest.dontMock('./shared') + jest.dontMock('@/fs/common/lifecycle') + jest.resetModules() + global.isMobile = originalGlobals.isMobile + global.isIOS = originalGlobals.isIOS + global.isAndroid = originalGlobals.isAndroid +}) + +test('iOS only asks for permission; the native watcher runs location', async () => { + const init = load('ios') + init.onEngineIncoming(watchPosition()) + await flush() + init.onEngineIncoming(clearWatch()) + await flush() + + expect(calls).toEqual(['result', 'requestPermission:1']) +}) + +test('Android asks for permission and runs the expo location task', async () => { + const init = load('android') + init.onEngineIncoming(watchPosition()) + await flush() + init.onEngineIncoming(clearWatch()) + await flush() + + expect(calls).toEqual([ + 'result', + 'requestPermission:1', + 'defineTask', + 'startLocationUpdates', + 'stopLocationUpdates', + ]) +}) + +// The second run stands for every launch after the cleanup: unregistering a task that is gone +// throws E_TASK_NOT_FOUND, so it must not be asked for again. +test('iOS removes the legacy expo background location task once', async () => { + const init = load('ios') + await init.unregisterLegacyIOSLocationTask() + await init.unregisterLegacyIOSLocationTask() + + expect(calls).toEqual(['isTaskRegistered', 'unregisterTask', 'isTaskRegistered']) +}) + +test('Android keeps its expo background location task', async () => { + const init = load('android') + await init.unregisterLegacyIOSLocationTask() + + expect(calls).toEqual([]) +}) diff --git a/shared/constants/init/platform-types.ts b/shared/constants/init/platform-types.ts index 8b44511da42a..8831418f3305 100644 --- a/shared/constants/init/platform-types.ts +++ b/shared/constants/init/platform-types.ts @@ -14,6 +14,8 @@ export type NetInfoModule = { } export type ExpoTaskManagerModule = { defineTask: (taskName: string, cb: (params: {data: unknown; error: unknown}) => Promise) => void + isTaskRegisteredAsync: (taskName: string) => Promise + unregisterTaskAsync: (taskName: string) => Promise } export type DesktopModules = { diff --git a/shared/ios/Keybase.xcodeproj/project.pbxproj b/shared/ios/Keybase.xcodeproj/project.pbxproj index 04efe02c9f9d..5530d8122d57 100644 --- a/shared/ios/Keybase.xcodeproj/project.pbxproj +++ b/shared/ios/Keybase.xcodeproj/project.pbxproj @@ -41,6 +41,7 @@ DBDCF30E1B8D03DD00BA95D8 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DBDCF3081B8D03DD00BA95D8 /* Images.xcassets */; }; DBDF89F62DF7779900EA18C2 /* Pusher.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDF89F52DF7779900EA18C2 /* Pusher.swift */; }; DBF123462DF1234500A12345 /* ShareIntentDonatorImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF123452DF1234500A12345 /* ShareIntentDonatorImpl.swift */; }; + DBLOCWATCH00270000000002 /* LocationWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBLOCWATCH00270000000001 /* LocationWatcher.swift */; }; DBPERF022600000002 /* PerfFPSMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBPERF022600000001 /* PerfFPSMonitor.swift */; }; DBSCENE00270000000000002 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBSCENE00270000000000001 /* SceneDelegate.swift */; }; /* End PBXBuildFile section */ @@ -120,6 +121,7 @@ DBDCF3441B8D04FC00BA95D8 /* Keybase-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Keybase-Bridging-Header.h"; sourceTree = ""; }; DBDF89F52DF7779900EA18C2 /* Pusher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Pusher.swift; sourceTree = ""; }; DBF123452DF1234500A12345 /* ShareIntentDonatorImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareIntentDonatorImpl.swift; sourceTree = ""; }; + DBLOCWATCH00270000000001 /* LocationWatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationWatcher.swift; sourceTree = ""; }; DBPERF022600000001 /* PerfFPSMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerfFPSMonitor.swift; sourceTree = ""; }; DBSCENE00270000000000001 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; F68DC40B579A1F9AC0F34950 /* Pods_KeybaseShare.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_KeybaseShare.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -281,6 +283,7 @@ DBD252972DF32D5C008A43FF /* Fs.swift */, DBDF89F52DF7779900EA18C2 /* Pusher.swift */, DBF123452DF1234500A12345 /* ShareIntentDonatorImpl.swift */, + DBLOCWATCH00270000000001 /* LocationWatcher.swift */, DBPERF022600000001 /* PerfFPSMonitor.swift */, 832341AE1AAA6A7D00B99B32 /* Libraries */, DAA243C38335068656BC36B1 /* PrivacyInfo.xcprivacy */, @@ -599,6 +602,7 @@ DB07050522E21B8B002F273D /* KeepThisFile.swift in Sources */, DBDF89F62DF7779900EA18C2 /* Pusher.swift in Sources */, DBF123462DF1234500A12345 /* ShareIntentDonatorImpl.swift in Sources */, + DBLOCWATCH00270000000002 /* LocationWatcher.swift in Sources */, DBD252982DF32D5C008A43FF /* Fs.swift in Sources */, DBPERF022600000002 /* PerfFPSMonitor.swift in Sources */, 9E1460E4A90E8D73A7347FED /* ExpoModulesProvider.swift in Sources */, diff --git a/shared/ios/Keybase/AppDelegate.swift b/shared/ios/Keybase/AppDelegate.swift index 42cac6f2eeae..08603d0d7a33 100644 --- a/shared/ios/Keybase/AppDelegate.swift +++ b/shared/ios/Keybase/AppDelegate.swift @@ -22,6 +22,7 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi var resignImageView: UIImageView? var fsPaths: [String: String] = [:] private let lifecycle = AppLifecycleForwarder() + private var locationWatcher: LocationWatcher? var iph: ItemProviderHelper? private var startupLogFileHandle: FileHandle? private let logQueue = DispatchQueue(label: "kb.startup.log", qos: .utility) @@ -183,7 +184,9 @@ class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider, UNUserNotifi log.info("Starting KeybaseInit (synchronous)...") var err: NSError? let shareIntentDonator = ShareIntentDonatorImpl() - Keybasego.KeybaseInit(self.fsPaths["homedir"], self.fsPaths["sharedHome"], self.fsPaths["logFile"], "prod", securityAccessGroupOverride, nil, nil, systemVer, isIPad, nil, isIOS, shareIntentDonator, &err) + let locationWatcher = LocationWatcher() + self.locationWatcher = locationWatcher + Keybasego.KeybaseInit(self.fsPaths["homedir"], self.fsPaths["sharedHome"], self.fsPaths["logFile"], "prod", securityAccessGroupOverride, nil, nil, systemVer, isIPad, nil, isIOS, shareIntentDonator, locationWatcher, &err) if let err { let initResult = "FAILED: \(err.localizedDescription) (code=\(err.code) domain=\(err.domain))" log.error("KeybaseInit FAILED: \(err.localizedDescription, privacy: .public)") diff --git a/shared/ios/Keybase/LocationWatcher.swift b/shared/ios/Keybase/LocationWatcher.swift new file mode 100644 index 000000000000..6db76692af97 --- /dev/null +++ b/shared/ios/Keybase/LocationWatcher.swift @@ -0,0 +1,88 @@ +import CoreLocation +import Keybasego +import os + +private let log = Logger(subsystem: "com.keybase.app", category: "location") + +// Runs the OS location service for live location (go/chat/maps) without JS. Go +// starts and stops watching; every fix goes back to Go, which decides which to +// record. Created in didFinishLaunching, before Go restores its trackers, so an +// app relaunched by significant-change monitoring starts watching again. Its +// CLLocationManager options match expo-location's background task, which +// Android still uses. +final class LocationWatcher: NSObject, Keybasego.KeybaseNativeLocationWatcherProtocol, CLLocationManagerDelegate { + // Everything below is main thread only. + private let manager = CLLocationManager() + private var wanted = false + private var running = false + + // Go records a fix to disk, so fixes go to it off the main thread, in order. + private let goQueue = DispatchQueue(label: "com.keybase.app.location", qos: .utility) + + override init() { + super.init() + manager.delegate = self + } + + // Called by Go on a Go thread. + func startWatching() { + DispatchQueue.main.async { + self.wanted = true + self.apply() + } + } + + func stopWatching() { + DispatchQueue.main.async { + self.wanted = false + self.apply() + } + } + + // The prompt is asked for in JS when sharing starts; once the user answers, + // this starts watching if Go still wants it. + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + apply() + } + + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard running else { return } + let fixes = locations.filter { $0.horizontalAccuracy >= 0 }.map { + (coordinate: $0.coordinate, accuracy: Int($0.horizontalAccuracy)) + } + goQueue.async { + for fix in fixes { + Keybasego.KeybaseLocationUpdate(fix.coordinate.latitude, fix.coordinate.longitude, fix.accuracy) + } + } + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + log.error("location update failed: \(error.localizedDescription, privacy: .public)") + } + + private func apply() { + let status = manager.authorizationStatus + let authorized = status == .authorizedAlways || status == .authorizedWhenInUse + if wanted && !authorized { + log.warning("not watching location: not authorized (status \(status.rawValue))") + } + if wanted && authorized && !running { + log.info("starting location updates") + running = true + manager.allowsBackgroundLocationUpdates = true + manager.desiredAccuracy = kCLLocationAccuracyHundredMeters + manager.distanceFilter = kCLDistanceFilterNone + manager.activityType = .other + manager.pausesLocationUpdatesAutomatically = true + manager.showsBackgroundLocationIndicator = true + manager.startUpdatingLocation() + manager.startMonitoringSignificantLocationChanges() + } else if running && !(wanted && authorized) { + log.info("stopping location updates") + running = false + manager.stopUpdatingLocation() + manager.stopMonitoringSignificantLocationChanges() + } + } +}