feat(amethyst): register one location provider with a paired listening hook

feat(amethyst): add location provider ladder
feat(amethyst): add RefCountedSession for overlapping ledger holders
This commit is contained in:
davotoula
2026-07-31 10:24:39 +02:00
parent 3fe936bedb
commit a742cec495
6 changed files with 551 additions and 24 deletions
@@ -21,56 +21,131 @@
package com.vitorpamplona.amethyst.service.location
import android.annotation.SuppressLint
import android.content.Context
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.Looper
import com.vitorpamplona.amethyst.service.location.LocationState.Companion.MIN_DISTANCE
import com.vitorpamplona.amethyst.service.location.LocationState.Companion.MIN_TIME
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.launch
/**
* Wraps [LocationManager] update registration as a cold [Flow].
*
* Registers on **one** provider, chosen by [LocationProviderLadder], rather than
* on every provider the device reports. The previous shotgun cost four
* simultaneous registrations — passive, network, fused and gps, the last at
* HIGH_ACCURACY — to produce a 5 km geohash.
*
* Takes a [LocationManager] rather than a `Context` so the registration
* behaviour is unit-testable; the caller does the `getSystemService` lookup.
*
* [onListening] is fired from inside the flow, after a registration succeeds and
* again from `awaitClose`, never as an `onStart`/`onCompletion` pair on the
* returned flow. The distinction matters: an `onStart` fires on collection even
* when nothing registered, so a device with no usable provider would accrue
* location time with no location running, and — because the ledger refcounts the
* two [LocationState] flows together — the unpaired close would steal the other
* flow's holder.
*
* The pair is kept honest from both ends. The acquire cannot fire without a
* registration, because a failure to register throws before reaching it. The
* release cannot be skipped, because everything after the acquire runs inside a
* `try`/`finally` rather than inside `awaitClose` — `send` suspends, so a
* collector that cancels mid-seed would otherwise unwind past an `awaitClose`
* that never ran.
*/
class LocationFlow(
private val context: Context,
private val locationManager: LocationManager,
private val sdkInt: Int = Build.VERSION.SDK_INT,
private val hasFine: Boolean = false,
) {
@SuppressLint("MissingPermission")
fun get(
minTimeMs: Long = MIN_TIME,
minDistanceM: Float = MIN_DISTANCE,
minTimeMs: Long,
minDistanceM: Float,
onListening: ((Boolean) -> Unit)? = null,
): Flow<Location> =
callbackFlow {
Log.i("LocationFlow", "Start")
val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
val locationCallback =
LocationListener { location ->
Log.d("LocationFlow") { "onLocationChanged $location" }
launch { send(location) }
}
locationManager.allProviders.forEach {
val location = locationManager.getLastKnownLocation(it)
Log.d("LocationFlow") { "Last Known location is $location" }
if (location != null) {
send(location)
// One binder call, reused for both the ladder filter and the seed.
val providers = locationManager.allProviders
val candidates = LocationProviderLadder.chooseProviders(sdkInt, hasFine) { it in providers }
var registered: String? = null
for (provider in candidates) {
try {
locationManager.requestLocationUpdates(
provider,
minTimeMs,
minDistanceM,
locationCallback,
Looper.getMainLooper(),
)
registered = provider
break
} catch (e: SecurityException) {
Log.w("LocationFlow", "Provider $provider refused the update request", e)
}
Log.d("LocationFlow", "Requesting Updates")
locationManager.requestLocationUpdates(
it,
minTimeMs,
minDistanceM,
locationCallback,
Looper.getMainLooper(),
)
}
awaitClose {
Log.i("LocationFlow", "Stop")
if (registered == null) {
throw SecurityException("No usable location provider. Candidates: $candidates")
}
Log.i("LocationFlow") { "Listening on $registered every ${minTimeMs}ms / ${minDistanceM}m" }
onListening?.invoke(true)
// Everything after the acquire runs under try/finally, not under
// awaitClose. `send` below suspends, so it is a cancellation point:
// if the collector cancels while the seed is mid-flight, the
// producer throws there and `awaitClose` is never entered. Cleanup
// parked inside awaitClose would then never run — the registration
// would leak and the refcount would stick at >= 1 for the life of
// the process, so location.ms would accrue forever with nothing
// listening. The finally covers normal close and
// cancellation-during-send alike.
try {
// Seeded after registration so the no-provider path throws
// without having emitted anything; seeding first would show the
// consumer Success -> LackPermission on a device with no
// compatible provider.
freshestLastKnownLocation(providers)?.let {
Log.d("LocationFlow") { "Last known location is $it" }
send(it)
}
awaitClose { }
} finally {
Log.i("LocationFlow") { "Stopped listening on $registered" }
locationManager.removeUpdates(locationCallback)
onListening?.invoke(false)
}
}
/**
* The freshest cached fix across every provider. Permission-checked per
* provider like the update request is, so each lookup is guarded — on a
* device where a provider refuses us, the others should still seed.
*/
@SuppressLint("MissingPermission")
private fun freshestLastKnownLocation(providers: List<String>): Location? =
providers
.mapNotNull { provider ->
try {
locationManager.getLastKnownLocation(provider)
} catch (e: SecurityException) {
Log.w("LocationFlow", "No permission to read the last known location of $provider", e)
null
}
}.maxByOrNull { it.time }
}
@@ -0,0 +1,75 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.location
import android.location.LocationManager
import android.os.Build
/**
* Picks which location providers to try, in order.
*
* Deliberately selects on **provider existence**, never on
* [LocationManager.isProviderEnabled]. A registration on a disabled provider
* goes live by itself when the user enables location — including from the
* quick-settings shade without leaving the app, which is exactly what someone
* does after seeing an empty "Around Me" feed. An enabled-state guard evaluated
* once at subscription start would lose that.
*
* Below API 31, `gps`, `passive` and `fused` required `ACCESS_FINE_LOCATION`;
* only `network` accepted `ACCESS_COARSE_LOCATION`. Approximate location, which
* lets a coarse-only app request any provider and receive a fuzzed result, is an
* Android 12 change. Amethyst declares coarse only, so [hasFine] is always false
* in production — it is a parameter so the function is total over the permission
* axis and both sides of the API branch are testable, not because fine access is
* anticipated.
*
* Returns the ordered candidate list rather than a single choice so the caller
* can fall through to the next rung if a registration is refused. An empty list
* means no compatible provider exists.
*/
object LocationProviderLadder {
// Compile-time String constants, inlined by the compiler, so naming
// FUSED_PROVIDER (added in API 31) is safe on older runtimes.
private val FULL_LADDER =
listOf(
LocationManager.FUSED_PROVIDER,
LocationManager.NETWORK_PROVIDER,
LocationManager.GPS_PROVIDER,
LocationManager.PASSIVE_PROVIDER,
)
private val COARSE_ONLY_LEGACY_LADDER = listOf(LocationManager.NETWORK_PROVIDER)
fun chooseProviders(
sdkInt: Int,
hasFine: Boolean,
exists: (String) -> Boolean,
): List<String> {
val ladder =
if (sdkInt >= Build.VERSION_CODES.S || hasFine) {
FULL_LADDER
} else {
COARSE_ONLY_LEGACY_LADDER
}
return ladder.filter(exists)
}
}
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.resourceusage
/**
* Refcounts a boolean session so overlapping holders don't close each other's
* segment. [LocationState][com.vitorpamplona.amethyst.service.location.LocationState]
* exposes two independent location flows that can both be listening at once —
* the "Around Me" feed plus an open geohash chat — and a bare
* [SessionTimeIntegrator] would close the segment when either one stops.
*
* The count and the transition it drives are taken under one lock. An
* [java.util.concurrent.atomic.AtomicInteger] beside an unsynchronised call is
* not enough: two threads can leave the counter at 1 while the last
* `setActive(false)` lands after the `setActive(true)`, latching the session
* off with a holder still active.
*
* Takes the setter as a lambda rather than a [SessionTimeIntegrator] because
* that is all it needs — and because constructing a real integrator drags in a
* [ResourceUsageAccountant] and a store file to observe one boolean.
*
* Reports **transitions only**, not every call. A 1 -> 2 acquire would otherwise
* re-enter [SessionTimeIntegrator.setActive] with the session already open,
* splitting one segment into two. That happens to be arithmetically harmless
* (`account()` adds each piece, and the pieces are contiguous), and it does not
* inflate a `*.starts` counter either, because [SessionTimeIntegrator] already
* guards its starts increment on `prev == null`. Transition-only is simply the
* contract the name implies, and it keeps the class honest for a future caller
* that reacts to the callback rather than integrating it.
*
* Releases must be paired with acquires. This class cannot tell an unpaired
* release from a real one, so callers guarantee the pairing; see `LocationFlow`,
* which throws rather than reaching `awaitClose` when nothing registered.
*/
class RefCountedSession(
private val setSessionActive: (Boolean) -> Unit,
) {
private val lock = Any()
private var holders = 0
fun setActive(active: Boolean) {
synchronized(lock) {
val wasActive = holders > 0
holders = if (active) holders + 1 else (holders - 1).coerceAtLeast(0)
val isActive = holders > 0
if (isActive != wasActive) setSessionActive(isActive)
}
}
}
@@ -0,0 +1,158 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.location
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class LocationFlowTest {
/**
* A LocationManager that reports [providers] and refuses [denied] with a
* SecurityException, mimicking the pre-API-31 fine-location requirement.
*/
private fun manager(
providers: List<String>,
denied: Set<String> = emptySet(),
): LocationManager {
val lm = mockk<LocationManager>(relaxed = true)
every { lm.allProviders } returns providers
every { lm.getLastKnownLocation(any()) } returns null
every {
lm.requestLocationUpdates(any<String>(), any<Long>(), any<Float>(), any<LocationListener>(), any())
} answers {
val provider = firstArg<String>()
if (provider in denied) throw SecurityException("denied: $provider")
}
return lm
}
@Test
fun firesNeitherEdgeWhenNoProviderExists() =
runTest {
val edges = mutableListOf<Boolean>()
val flow = LocationFlow(manager(providers = emptyList()), sdkInt = 37).get(60_000L, 500f) { edges.add(it) }
val failure = runCatching { flow.collect { } }.exceptionOrNull()
assertTrue("expected SecurityException, got $failure", failure is SecurityException)
assertEquals(emptyList<Boolean>(), edges)
}
@Test
fun firesNeitherEdgeWhenEveryRungIsDenied() =
runTest {
val edges = mutableListOf<Boolean>()
val lm = manager(providers = listOf("fused", "network"), denied = setOf("fused", "network"))
val flow = LocationFlow(lm, sdkInt = 37).get(60_000L, 500f) { edges.add(it) }
val failure = runCatching { flow.collect { } }.exceptionOrNull()
assertTrue("expected SecurityException, got $failure", failure is SecurityException)
assertEquals(emptyList<Boolean>(), edges)
}
@Test
fun fallsThroughToTheNextRungWhenOneIsDenied() =
runTest {
val edges = mutableListOf<Boolean>()
val lm = manager(providers = listOf("fused", "network"), denied = setOf("fused"))
val job = launch { LocationFlow(lm, sdkInt = 37).get(60_000L, 500f) { edges.add(it) }.collect { } }
runCurrent()
assertEquals(listOf(true), edges)
verify { lm.requestLocationUpdates("network", 60_000L, 500f, any<LocationListener>(), any()) }
job.cancelAndJoin()
}
@Test
fun pairsTheListeningEdgesAroundASuccessfulRegistration() =
runTest {
val edges = mutableListOf<Boolean>()
val lm = manager(providers = listOf("network"))
val job = launch { LocationFlow(lm, sdkInt = 30).get(60_000L, 500f) { edges.add(it) }.collect { } }
runCurrent()
assertEquals(listOf(true), edges)
job.cancelAndJoin()
assertEquals(listOf(true, false), edges)
verify { lm.removeUpdates(any<LocationListener>()) }
}
@Test
fun releasesTheRegistrationWhenCancelledDuringTheSeed() =
runTest {
// `send` in the seed suspends, so a collector cancelling while the
// getLastKnownLocation sweep is in flight unwinds the producer
// there. Cleanup must still run, or the refcount sticks at >= 1
// forever and the OS registration leaks.
val edges = mutableListOf<Boolean>()
val lm = mockk<LocationManager>(relaxed = true)
every { lm.allProviders } returns listOf("network")
lateinit var job: Job
every { lm.getLastKnownLocation(any()) } answers {
// Cancel from inside the sweep, so the subsequent send() throws.
job.cancel()
mockk<Location> { every { time } returns 1L }
}
job = launch { LocationFlow(lm, sdkInt = 30).get(60_000L, 500f) { edges.add(it) }.collect { } }
runCurrent()
job.join()
assertEquals("the acquire must be released even on cancellation", listOf(true, false), edges)
verify { lm.removeUpdates(any<LocationListener>()) }
}
@Test
fun registersOnExactlyOneProvider() =
runTest {
val lm = manager(providers = listOf("fused", "network", "gps", "passive"))
val job = launch { LocationFlow(lm, sdkInt = 37).get(60_000L, 500f).collect { } }
runCurrent()
verify(exactly = 1) {
lm.requestLocationUpdates(any<String>(), any<Long>(), any<Float>(), any<LocationListener>(), any())
}
job.cancelAndJoin()
}
}
@@ -0,0 +1,82 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.location
import org.junit.Assert.assertEquals
import org.junit.Test
class LocationProviderLadderTest {
private val all = setOf("fused", "network", "gps", "passive")
@Test
fun modernDevicePrefersFusedThenFallsBackInOrder() {
assertEquals(
listOf("fused", "network", "gps", "passive"),
LocationProviderLadder.chooseProviders(sdkInt = 31, hasFine = false) { it in all },
)
}
@Test
fun missingProvidersAreFilteredOutButOrderIsKept() {
val present = setOf("network", "passive")
assertEquals(
listOf("network", "passive"),
LocationProviderLadder.chooseProviders(sdkInt = 37, hasFine = false) { it in present },
)
}
@Test
fun coarseOnlyBelowApi31GetsNetworkOnly() {
// gps, passive and fused all required ACCESS_FINE_LOCATION before
// Android 12 (see Hypothesis H1 in the design spec).
assertEquals(
listOf("network"),
LocationProviderLadder.chooseProviders(sdkInt = 30, hasFine = false) { it in all },
)
}
@Test
fun fineBelowApi31GetsTheFullLadder() {
assertEquals(
listOf("fused", "network", "gps", "passive"),
LocationProviderLadder.chooseProviders(sdkInt = 26, hasFine = true) { it in all },
)
}
@Test
fun coarseOnlyBelowApi31WithNoNetworkProviderGetsNothing() {
val present = setOf("gps", "passive")
assertEquals(
emptyList<String>(),
LocationProviderLadder.chooseProviders(sdkInt = 28, hasFine = false) { it in present },
)
}
@Test
fun noProvidersAtAllGetsNothing() {
assertEquals(
emptyList<String>(),
LocationProviderLadder.chooseProviders(sdkInt = 37, hasFine = false) { false },
)
}
}
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.resourceusage
import org.junit.Assert.assertEquals
import org.junit.Test
class RefCountedSessionTest {
@Test
fun overlappingHoldersKeepTheSessionOpenAndReportOnlyTransitions() {
val calls = mutableListOf<Boolean>()
val session = RefCountedSession { calls.add(it) }
session.setActive(true) // holders 1 — inactive -> active
session.setActive(true) // holders 2 — a second listener joins, no transition
session.setActive(false) // holders 1 — the first one leaves, still active
assertEquals("only the 0 -> 1 edge is a transition", listOf(true), calls)
session.setActive(false) // holders 0 — the last one leaves
assertEquals(listOf(true, false), calls)
}
@Test
fun unmatchedReleaseDoesNotDriveTheCountNegative() {
val calls = mutableListOf<Boolean>()
val session = RefCountedSession { calls.add(it) }
session.setActive(false)
session.setActive(false)
assertEquals("releasing an idle session is a no-op", emptyList<Boolean>(), calls)
// If the count had gone to -2, one acquire would leave it at -1 and
// report inactive. It must open the session instead.
session.setActive(true)
assertEquals(listOf(true), calls)
}
@Test
fun aSingleHolderOpensAndClosesTheSession() {
val calls = mutableListOf<Boolean>()
val session = RefCountedSession { calls.add(it) }
session.setActive(true)
session.setActive(false)
assertEquals(listOf(true, false), calls)
}
}