fix: [SDK-4672] start location updates when permission is granted#2663
Conversation
Enabling isShared before location permission was granted left the GMS listener subscribed via a requestLocationUpdates call that silently failed (SecurityException). A later start() short-circuited and only fired a one-shot last location, so live updates resumed only on the next app focus change. start() now re-arms the listener in the already-initialized branch, and the wrapper reports subscribe success so a failed request is retried rather than treated as active. Adds regression tests. Co-Authored-By: Cursor <cursoragent@cursor.com>
|
@claude review |
📊 Diff Coverage ReportDiff Coverage Report (Changed Lines Only)Gate: aggregate coverage on changed executable lines must be ≥ 80% (JaCoCo line data for lines touched in the diff). Changed Files Coverage
Overall (aggregate gate)0/18 touched executable lines covered (0.0% — requires ≥ 80%) Per-file detail (informational; gate is aggregate above):
❌ Coverage Check FailedAggregate coverage on touched lines is 0.0% (minimum 80%). |
There was a problem hiding this comment.
LGTM — small, well-tested fix that re-arms the live location subscription when start() is called again after a permission grant.
Extended reasoning...
Overview
This PR fixes SDK-4672 by making location updates start immediately after the user grants permission, rather than waiting for the next app focus change. It modifies FusedLocationApiWrapperImpl.requestLocationUpdates to return a Boolean indicating success/failure, threads that result into LocationUpdateListener.hasExistingRequest so failed requests are not later cancelled as if they were active, and adds a refreshRequest() call in the start() re-entrance branch so a second start() (triggered by the permission grant) re-arms the subscription instead of short-circuiting. Visibility of refreshRequest was widened from private to internal, the mock was extended with call counters and a configurable return value, and two regression tests were added.
Security risks
None meaningful. The PR does not change how permissions are checked or requested — it only changes how the SDK reacts to an existing failed request once permission becomes available. No public API change, no data exposure, no auth surface.
Level of scrutiny
Low-to-moderate. The diff is confined to the GMS location subsystem (Huawei path untouched), the change is mechanical (propagate a boolean, retry on re-entrance), and existing tests plus two new regression tests cover both the happy path and the failed-request-not-treated-as-active edge case. The new refreshRequest() call sits inside the existing startStopMutex, so it inherits the same synchronization as the rest of start().
Other factors
The bug-hunting pass found no issues. The author reported manual reproduction and verification on an Android 14 emulator. The fix aligns cleanly with the prior commit 032afbe on the same ticket. No outstanding reviewer comments to address.
|
Is this similar to the other location pr? |
No, that one catches throwables in LocationServices API only. This one calls an API |
abdulraqeeb33
left a comment
There was a problem hiding this comment.
Hey Nan, took a pass through this with a couple of reviewers and overall it's a solid fix — really like that requestLocationUpdates now reports real success instead of us optimistically assuming it worked. The re-arm-on-grant flow makes a lot of sense and the manual repro in the description is super clear. Nice work tracking this one down.
A few things I'd love your thoughts on before we ship:
Re-arm churn on every start() — since the early-path now calls refreshRequest() unconditionally, and refreshRequest() cancels + re-registers (and resets the interval timer) even when the subscription is already healthy, repeated start() calls (toggling isShared, permission callbacks) end up churning the subscription. Could we guard it so we only re-arm when hasExistingRequest == false?
hasExistingRequest threading — it's an unsynchronized var that's now touched from the IO start() path (under startStopMutex) and from onFocus/onUnfocused on the main thread (no lock). This race technically predates your change, but this PR widens it a bit. Worth at least a @volatile, or serializing the refresh through the same lock?
The new tests — the mocked applicationService no-ops addApplicationLifecycleHandler, but the real one fires onFocus(true) synchronously, so the callCount shouldBe 2 assertion doesn't quite mirror production's re-entrant path. Might be worth a test that exercises that flow + asserts the failed-first / success-second transition directly.
None of these are blockers from my side
abdulraqeeb33
left a comment
There was a problem hiding this comment.
Hey Nan, took a pass through this with a couple of reviewers and overall it's a solid fix — really like that requestLocationUpdates now reports real success instead of us optimistically assuming it worked. The re-arm-on-grant flow makes a lot of sense and the manual repro in the description is super clear. Nice work tracking this one down.
A few things I'd love your thoughts on before we ship:
Re-arm churn on every start() — since the early-path now calls refreshRequest() unconditionally, and refreshRequest() cancels + re-registers (and resets the interval timer) even when the subscription is already healthy, repeated start() calls (toggling isShared, permission callbacks) end up churning the subscription. Could we guard it so we only re-arm when hasExistingRequest == false?
hasExistingRequest threading — it's an unsynchronized var that's now touched from the IO start() path (under startStopMutex) and from onFocus/onUnfocused on the main thread (no lock). This race technically predates your change, but this PR widens it a bit. Worth at least a @volatile, or serializing the refresh through the same lock?
The new tests — the mocked applicationService no-ops addApplicationLifecycleHandler, but the real one fires onFocus(true) synchronously, so the callCount shouldBe 2 assertion doesn't quite mirror production's re-entrant path. Might be worth a test that exercises that flow + asserts the failed-first / success-second transition directly.
None of these are blockers from my side
|
@nan-li merge? |
Drive the real repro in one test: the first requestLocationUpdates fails (permission missing) and must not be recorded as active, then a later start() re-registers successfully and the now-active request is cancelled on stop. Asserts the state transition the call-count test only approximated. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
LGTM — small, well-tested fix; the re-registration on subsequent start() and the success-aware hasExistingRequest are both covered by the new regression tests.
Extended reasoning...
Overview
This PR fixes a bug where the SDK records location-updates registration as active even when the underlying requestLocationUpdates call fails (e.g. a swallowed SecurityException when permission is missing). The fix makes the wrapper return a Boolean, assigns hasExistingRequest from that result rather than unconditionally setting it to true, and calls refreshRequest() on a subsequent start() when the GoogleApiClient already exists so the live subscription re-arms as soon as permission is granted. Touches 3 source files (wrapper impl, interface, controller) and 2 test files, no public API change.
Security risks
None meaningful. The change is in the GMS location path and does not alter permission checks, crypto, auth, or any external interface. If anything, it makes the SDK's handling of missing-permission failures more accurate (a previously silent failure now correctly reflects as not-registered, preventing a phantom cancel later).
Level of scrutiny
Low–medium. Internal Kotlin interface signature change, small scope, well-isolated to the GMS location controller. The behavior is exercised by three new regression tests that cover the re-arm path, the failed-request-not-recorded invariant, and the full grant-after-failure repro. Manual verification logs in the PR description show the live subscription engaging immediately after permission grant with no background/foreground cycle.
Other factors
The diff coverage gate reports 0% on touched executable lines, but this is explained: the wrapper impl lines can't be exercised without real GMS APIs, and the touched lines in GmsLocationController are reached via the mock in the new tests (the gate appears to be using JaCoCo line data that doesn't credit indirect coverage here). The reviewer (fadi-george) has already asked the author about merging. Companion PR #2662 (which I previously reviewed) handled a related but distinct path (catching throwables); the author clarified the distinction in the thread.
…race refreshRequest() and close() run on both the start()/stop() coroutine path and the main-thread onFocus()/onUnfocused() lifecycle callbacks. Guard the cancel/register sequence and the hasExistingRequest flag with a dedicated lock so the check-then-act is atomic and visible across threads, instead of relying on an unsynchronized var. Co-authored-by: Cursor <cursoragent@cursor.com>
The early start() path now re-registers for location updates only when there is no active request, so a successful registration is left in place on repeated start() calls (e.g. toggling isShared or permission callbacks) instead of being cancelled and re-registered (which also reset the interval timer). Recovery after a failed first attempt is preserved because the flag is false in that case, and the focus-driven refresh path still re-registers unconditionally to pick up the new interval. Co-Authored-By: Cursor <cursoragent@cursor.com>
|
Thanks for the careful review, all three were fair, and I've pushed a change for each: 1. Re-arm churn on every start() — done. 2. hasExistingRequest threading — done. 3. Tests — done. Added a regression test that drives the real transition — a first |
There was a problem hiding this comment.
Focused fix for SDK-4672 with good test coverage; the new Boolean return + re-arm on start() correctly addresses the silent-failure case, and the synchronization around hasExistingRequest is straightforward.
Extended reasoning...
Overview
This PR fixes SDK-4672: when requestLocationUpdates failed (e.g. swallowed SecurityException because permission wasn't granted yet), the SDK still recorded the listener as active, so once permission was granted no further updates were registered until a background/foreground cycle. The fix changes IFusedLocationApiWrapper.requestLocationUpdates to return a Boolean indicating actual success, propagates that into hasExistingRequest, and re-arms the subscription on the next start() via refreshRequest(onlyIfInactive = true). Scope is limited to the GMS location path; the Huawei path and public API are unchanged.
Security risks
None material. The change is purely about correctly tracking whether a location-updates subscription is active — no permission, auth, crypto, or data-exposure surface is touched. The underlying permission check in Android is unchanged; the SDK simply no longer pretends a permission-denied request succeeded.
Level of scrutiny
Moderate. The change touches subscription state management and introduces a synchronized(requestLock) block, so concurrency correctness matters, but the lock pattern is conservative (single lock guarding hasExistingRequest and the cancel/register sequence) and easy to verify. The interface change is internal-only.
Other factors
- Three new unit tests directly exercise the regression path (failed, then granted, then stop), plus a no-churn test for repeated
start(). - Manual verification logs in the PR body show the fix working on a real device.
- The Diff Coverage gate failed at 0%, but inspection shows the new tests clearly exercise the touched lines — this looks like a JaCoCo/Kotlin instrumentation artifact rather than missing coverage, and the comment doesn't appear to be blocking the maintainers.
- Recent commits (
only re-arm location updates when none is active to avoid churn,serialize location-updates refresh to remove hasExistingRequest race) show the author iterated on concurrency concerns already. - Two maintainers are aligned on merging.
There was a problem hiding this comment.
LGTM — all three prior comments are addressed and the new tests cover the failed-then-granted re-registration path.
Extended reasoning...
Overview
This revision addresses the three concerns I raised earlier on the Google Play Services location path:
refreshRequest()now takesonlyIfInactiveand thestart()early-return path passestrue, so a healthy active request is no longer torn down and re-registered (which would reset the interval) on repeatedstart()s, while grant-recovery still works because a failed request is not flagged active.hasExistingRequestand the cancel/register/assign sequence are now serialized via a dedicatedrequestLockshared byrefreshRequest()andclose(), correctly handling the main-threadonFocus()/onUnfocused()callbacks that the existing coroutine mutex cannot cover.- A new regression test drives the actual transition (first
requestLocationUpdatesfails, secondstart()succeeds, thenstop()cancels), and the old "re-arms on subsequent start" test was flipped to assert the no-churn behavior.
Security risks
None. The change tightens an existing failure path: requestLocationUpdates already catches throwables (including the SecurityException thrown when permission is missing), and the only new behavior is that the swallowed failure is now reported via a Boolean instead of being silently treated as success. No permission checks, crypto, or auth code is touched.
Level of scrutiny
Moderate-low. The blast radius is the GMS location subsystem only (the Huawei path is unchanged) and the IFusedLocationApiWrapper interface change is internal. The locking introduced for hasExistingRequest is a small, well-scoped synchronized block on a private Any() monitor, with no nested locks that could deadlock against startStopMutex.
Other factors
The author personally reproduced the bug on a Pixel 7 / Android 14 emulator and posted before/after logs, the new tests directly exercise both the no-churn and failed-then-granted paths, and prior feedback from the bug-hunting system has all been addressed in subsequent commits. The 0% diff-coverage report is a Jacoco artifact (the new tests are robolectric/kotest tests that don't feed Jacoco), not a real coverage gap.
Description
One Line Summary
When registering for ongoing location updates fails, the SDK now records the failure and registers again the next time location starts, instead of assuming the first attempt worked and never retrying.
Details
Motivation
If an app turns on location sharing (
isShared = true) before the user has granted location permission, the SDK tries to register for ongoing location updates, but that call fails because the permission is missing. The failure was caught but not reported back, so the SDK recorded the location-updates listener as registered when it actually was not. After the user granted permission, the SDK sent a single saved location and did not register for ongoing updates again, so no further location changes were reported until the next time the app was sent to the background and brought back to the foreground.This change makes the registration call report whether it actually succeeded, so the SDK no longer treats a failed attempt as registered, and it registers for ongoing updates again as soon as permission is granted. Ongoing updates now start right away.
Scope
Testing
Unit testing
Added two tests in
GmsLocationControllerTests: one checks that callingstart()again registers for location updates a second time, and one checks that a failed registration is not recorded as active. All location unit tests and detekt pass.Manual testing
Reproduced on the demo app (
com.onesignal.example, built from this branch) on a Pixel 7 / Android 14 emulator: turned onisSharedbefore granting permission, then chose "While using the app". Before the change, ongoing updates started only after the app was sent to the background and brought back; after the change, the SDK registers for ongoing updates right after the grant with no error, and the latitude/longitude reaches OneSignal in the same foreground session.Verification logs
Turn on
isSharedbefore permission — the registration call fails, so no ongoing updates are started:Grant "While using the app" — the SDK registers for ongoing updates immediately, with no background/foreground cycle in between:
The
requestLocationUpdates!at the grant has noSecurityExceptionafter it, and there is noLocationUpdateListener.onFocus()/onUnfocused()between the grant and the re-registration — so ongoing updates start from the permission grant itself, not from a background/foreground cycle.Affected code checklist
Checklist
Overview
Testing
Final pass
Made with Cursor