mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 16:33:27 +00:00
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:
+99
-24
@@ -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 }
|
||||
}
|
||||
|
||||
+75
@@ -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)
|
||||
}
|
||||
}
|
||||
+67
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+158
@@ -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()
|
||||
}
|
||||
}
|
||||
+82
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
+70
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user