From a742cec495303af26f314b4e8d6bdc3c591cb452 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 30 Jul 2026 09:50:52 +0200 Subject: [PATCH] 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 --- .../amethyst/service/location/LocationFlow.kt | 123 +++++++++++--- .../location/LocationProviderLadder.kt | 75 +++++++++ .../resourceusage/RefCountedSession.kt | 67 ++++++++ .../service/location/LocationFlowTest.kt | 158 ++++++++++++++++++ .../location/LocationProviderLadderTest.kt | 82 +++++++++ .../resourceusage/RefCountedSessionTest.kt | 70 ++++++++ 6 files changed, 551 insertions(+), 24 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadder.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSession.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationFlowTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadderTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSessionTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt index 87ab766cee..ad82448597 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationFlow.kt @@ -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 = 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): 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 } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadder.kt new file mode 100644 index 0000000000..278d57f866 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadder.kt @@ -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 { + val ladder = + if (sdkInt >= Build.VERSION_CODES.S || hasFine) { + FULL_LADDER + } else { + COARSE_ONLY_LEGACY_LADDER + } + + return ladder.filter(exists) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSession.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSession.kt new file mode 100644 index 0000000000..f73e4996a4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSession.kt @@ -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) + } + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationFlowTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationFlowTest.kt new file mode 100644 index 0000000000..0bfb1bc9c7 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationFlowTest.kt @@ -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, + denied: Set = emptySet(), + ): LocationManager { + val lm = mockk(relaxed = true) + every { lm.allProviders } returns providers + every { lm.getLastKnownLocation(any()) } returns null + every { + lm.requestLocationUpdates(any(), any(), any(), any(), any()) + } answers { + val provider = firstArg() + if (provider in denied) throw SecurityException("denied: $provider") + } + return lm + } + + @Test + fun firesNeitherEdgeWhenNoProviderExists() = + runTest { + val edges = mutableListOf() + 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(), edges) + } + + @Test + fun firesNeitherEdgeWhenEveryRungIsDenied() = + runTest { + val edges = mutableListOf() + 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(), edges) + } + + @Test + fun fallsThroughToTheNextRungWhenOneIsDenied() = + runTest { + val edges = mutableListOf() + 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(), any()) } + + job.cancelAndJoin() + } + + @Test + fun pairsTheListeningEdgesAroundASuccessfulRegistration() = + runTest { + val edges = mutableListOf() + 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()) } + } + + @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() + val lm = mockk(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 { 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()) } + } + + @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(), any(), any(), any(), any()) + } + + job.cancelAndJoin() + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadderTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadderTest.kt new file mode 100644 index 0000000000..5928e9f5bd --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/location/LocationProviderLadderTest.kt @@ -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(), + LocationProviderLadder.chooseProviders(sdkInt = 28, hasFine = false) { it in present }, + ) + } + + @Test + fun noProvidersAtAllGetsNothing() { + assertEquals( + emptyList(), + LocationProviderLadder.chooseProviders(sdkInt = 37, hasFine = false) { false }, + ) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSessionTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSessionTest.kt new file mode 100644 index 0000000000..78a9d35680 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/RefCountedSessionTest.kt @@ -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() + 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() + val session = RefCountedSession { calls.add(it) } + + session.setActive(false) + session.setActive(false) + + assertEquals("releasing an idle session is a no-op", emptyList(), 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() + val session = RefCountedSession { calls.add(it) } + + session.setActive(true) + session.setActive(false) + + assertEquals(listOf(true, false), calls) + } +}