fix(core): application-scoped runtimes, strict multi-host singletons, and replay=0 for transient events
This commit is contained in:
parent
7e2512e390
commit
57677b7770
13 changed files with 244 additions and 31 deletions
|
|
@ -106,6 +106,7 @@ dependencies {
|
|||
|
||||
// Testing
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("io.mockk:mockk:1.13.12")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.1")
|
||||
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
|
||||
testImplementation("app.cash.turbine:turbine:1.2.0")
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:name=".HermesApplication"
|
||||
android:allowBackup="false"
|
||||
android:icon="@android:drawable/sym_def_app_icon"
|
||||
android:label="@string/app_name"
|
||||
|
|
|
|||
59
app/src/main/java/app/hermes/mobile/HermesAppContainer.kt
Normal file
59
app/src/main/java/app/hermes/mobile/HermesAppContainer.kt
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package app.hermes.mobile
|
||||
|
||||
import android.content.Context
|
||||
import app.hermes.mobile.core.auth.PkceLoopbackAuthManager
|
||||
import app.hermes.mobile.core.network.HermesRestClient
|
||||
import app.hermes.mobile.core.repository.UnifiedSessionRepository
|
||||
import app.hermes.mobile.core.runtime.HermesConnectionManager
|
||||
import app.hermes.mobile.core.security.EncryptedTokenVault
|
||||
import app.hermes.mobile.core.storage.HermesDatabase
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
|
||||
interface AppContainer {
|
||||
val db: HermesDatabase
|
||||
val tokenVault: EncryptedTokenVault
|
||||
val restClient: HermesRestClient
|
||||
val pkceAuthManager: PkceLoopbackAuthManager
|
||||
val connectionManager: HermesConnectionManager
|
||||
val unifiedSessionRepo: UnifiedSessionRepository
|
||||
val applicationScope: CoroutineScope
|
||||
}
|
||||
|
||||
class HermesAppContainer(private val context: Context) : AppContainer {
|
||||
override val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
override val db: HermesDatabase by lazy {
|
||||
HermesDatabase.getInstance(context)
|
||||
}
|
||||
|
||||
override val tokenVault: EncryptedTokenVault by lazy {
|
||||
EncryptedTokenVault(context)
|
||||
}
|
||||
|
||||
override val restClient: HermesRestClient by lazy {
|
||||
HermesRestClient()
|
||||
}
|
||||
|
||||
override val pkceAuthManager: PkceLoopbackAuthManager by lazy {
|
||||
PkceLoopbackAuthManager(restClient, tokenVault)
|
||||
}
|
||||
|
||||
override val connectionManager: HermesConnectionManager by lazy {
|
||||
HermesConnectionManager(
|
||||
hostDao = db.hostDao(),
|
||||
tokenVault = tokenVault,
|
||||
restClient = restClient,
|
||||
scope = applicationScope
|
||||
)
|
||||
}
|
||||
|
||||
override val unifiedSessionRepo: UnifiedSessionRepository by lazy {
|
||||
UnifiedSessionRepository(
|
||||
connectionManager = connectionManager,
|
||||
sessionDao = db.unifiedSessionDao(),
|
||||
scope = applicationScope
|
||||
)
|
||||
}
|
||||
}
|
||||
18
app/src/main/java/app/hermes/mobile/HermesApplication.kt
Normal file
18
app/src/main/java/app/hermes/mobile/HermesApplication.kt
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
package app.hermes.mobile
|
||||
|
||||
import android.app.Application
|
||||
import kotlinx.coroutines.cancel
|
||||
|
||||
class HermesApplication : Application() {
|
||||
lateinit var container: AppContainer
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
container = HermesAppContainer(this)
|
||||
}
|
||||
|
||||
override fun onTerminate() {
|
||||
super.onTerminate()
|
||||
container.applicationScope.cancel()
|
||||
}
|
||||
}
|
||||
|
|
@ -43,28 +43,19 @@ class MainActivity : ComponentActivity() {
|
|||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
val db = HermesDatabase.getInstance(applicationContext)
|
||||
val container = (applicationContext as HermesApplication).container
|
||||
|
||||
val db = container.db
|
||||
val hostDao = db.hostDao()
|
||||
val sessionDao = db.unifiedSessionDao()
|
||||
val tokenVault = EncryptedTokenVault(applicationContext)
|
||||
val restClient = HermesRestClient()
|
||||
val pkceAuthManager = PkceLoopbackAuthManager(restClient, tokenVault)
|
||||
|
||||
|
||||
// Migrate legacy connections from DataStore if present
|
||||
lifecycleScope.launch {
|
||||
MigrationHelper.migrateLegacyConnections(applicationContext, hostDao)
|
||||
}
|
||||
|
||||
val connectionManager = HermesConnectionManager(
|
||||
hostDao = hostDao,
|
||||
tokenVault = tokenVault,
|
||||
restClient = restClient
|
||||
)
|
||||
|
||||
val unifiedSessionRepo = UnifiedSessionRepository(
|
||||
connectionManager = connectionManager,
|
||||
sessionDao = sessionDao
|
||||
)
|
||||
val tokenVault = container.tokenVault
|
||||
val pkceAuthManager = container.pkceAuthManager
|
||||
val connectionManager = container.connectionManager
|
||||
val unifiedSessionRepo = container.unifiedSessionRepo
|
||||
|
||||
setContent {
|
||||
HermesAndroidTheme {
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ class JsonRpcGatewayClient(
|
|||
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||
|
||||
private val _events = MutableSharedFlow<GatewayEvent>(replay = 1, extraBufferCapacity = 64)
|
||||
private val _events = MutableSharedFlow<GatewayEvent>(extraBufferCapacity = 64)
|
||||
val events: SharedFlow<GatewayEvent> = _events.asSharedFlow()
|
||||
|
||||
private fun nextId(): String = "a${reqCounter.incrementAndGet()}"
|
||||
|
|
|
|||
|
|
@ -26,13 +26,14 @@ class HermesConnectionManager(
|
|||
val tokenVault: TokenVault,
|
||||
val restClient: HermesRestClient = HermesRestClient(),
|
||||
val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||
val runtimeFactory: (HermesHost) -> HermesHostRuntime = { host ->
|
||||
val runtimeFactory: (CoroutineScope, HermesHost) -> HermesHostRuntime = { parentScope, host ->
|
||||
val childScope = CoroutineScope(SupervisorJob(parentScope.coroutineContext[kotlinx.coroutines.Job]) + Dispatchers.Default)
|
||||
HermesHostRuntime(
|
||||
initialHost = host,
|
||||
restClient = restClient,
|
||||
gatewayClient = JsonRpcGatewayClient(scope = scope),
|
||||
gatewayClient = JsonRpcGatewayClient(scope = childScope),
|
||||
tokenVault = tokenVault,
|
||||
scope = scope
|
||||
scope = childScope
|
||||
)
|
||||
}
|
||||
) {
|
||||
|
|
@ -44,7 +45,7 @@ class HermesConnectionManager(
|
|||
private val _activeHostId = MutableStateFlow<HermesHostId?>(null)
|
||||
val activeHostId: StateFlow<HermesHostId?> = _activeHostId.asStateFlow()
|
||||
|
||||
private val _allEvents = MutableSharedFlow<HostGatewayEvent>(replay = 1, extraBufferCapacity = 128)
|
||||
private val _allEvents = MutableSharedFlow<HostGatewayEvent>(extraBufferCapacity = 128)
|
||||
val allEvents: SharedFlow<HostGatewayEvent> = _allEvents.asSharedFlow()
|
||||
|
||||
init {
|
||||
|
|
@ -89,7 +90,7 @@ class HermesConnectionManager(
|
|||
|
||||
fun getOrCreateRuntime(host: HermesHost): HermesHostRuntime {
|
||||
return runtimes.computeIfAbsent(host.id) {
|
||||
val rt = runtimeFactory(host)
|
||||
val rt = runtimeFactory(scope, host)
|
||||
// Forward events
|
||||
scope.launch {
|
||||
rt.events.collect { event ->
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
package app.hermes.mobile.core.network
|
||||
|
||||
import app.hermes.mobile.core.model.GatewayEvent
|
||||
import app.hermes.mobile.core.model.GatewayEvent.MessageDeltaEvent
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class EventStreamReplayTest {
|
||||
|
||||
@Test
|
||||
fun testNoReplay() = runTest {
|
||||
val client = JsonRpcGatewayClient(scope = this)
|
||||
|
||||
// Emit first event BEFORE subscribing
|
||||
client.handleIncomingMessage("{\"jsonrpc\":\"2.0\",\"method\":\"event\",\"params\":{\"type\":\"message.delta\",\"session_id\":\"s1\",\"payload\":{\"message_id\":\"m1\",\"delta\":\"Hello\"}}}")
|
||||
runCurrent()
|
||||
|
||||
val events = mutableListOf<GatewayEvent>()
|
||||
val job = launch {
|
||||
client.events.collect { events.add(it) }
|
||||
}
|
||||
runCurrent()
|
||||
|
||||
// Should not have received the first event
|
||||
assertTrue("Events should be empty since we subscribed after the first emission", events.isEmpty())
|
||||
|
||||
// Emit second event AFTER subscribing
|
||||
client.handleIncomingMessage("{\"jsonrpc\":\"2.0\",\"method\":\"event\",\"params\":{\"type\":\"message.delta\",\"session_id\":\"s1\",\"payload\":{\"message_id\":\"m1\",\"delta\":\" World\"}}}")
|
||||
runCurrent()
|
||||
|
||||
assertEquals("Should have exactly 1 event", 1, events.size)
|
||||
val event = events[0] as GatewayEvent.MessageDeltaEvent
|
||||
assertEquals(" World", event.delta)
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,9 @@ import app.hermes.mobile.core.model.RuntimeSessionId
|
|||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.async
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
|
|
@ -123,12 +126,18 @@ class JsonRpcGatewayClientTest {
|
|||
)
|
||||
|
||||
val wsUrl = "ws://${server.hostName}:${server.port}/api/ws"
|
||||
|
||||
// Start subscription BEFORE connecting, so we don't miss the event
|
||||
val eventDeferred = async(start = CoroutineStart.UNDISPATCHED) {
|
||||
withTimeout(5000) {
|
||||
client.events.first { it is GatewayEvent.MessageDeltaEvent }
|
||||
}
|
||||
}
|
||||
|
||||
client.connect(wsUrl, allowCleartext = true)
|
||||
client.awaitGatewayReady(5000)
|
||||
|
||||
val event = withTimeout(5000) {
|
||||
client.events.first { it is GatewayEvent.MessageDeltaEvent }
|
||||
}
|
||||
val event = eventDeferred.await()
|
||||
|
||||
assertTrue(event is GatewayEvent.MessageDeltaEvent)
|
||||
val deltaEvent = event as GatewayEvent.MessageDeltaEvent
|
||||
|
|
|
|||
|
|
@ -59,7 +59,17 @@ class ApprovalRoutingTest {
|
|||
connectionManager = HermesConnectionManager(
|
||||
hostDao = hostDao,
|
||||
tokenVault = tokenVault,
|
||||
scope = CoroutineScope(testDispatcher)
|
||||
scope = CoroutineScope(testDispatcher),
|
||||
runtimeFactory = { parentScope, host ->
|
||||
val childScope = CoroutineScope(kotlinx.coroutines.SupervisorJob(parentScope.coroutineContext[kotlinx.coroutines.Job]) + testDispatcher)
|
||||
HermesHostRuntime(
|
||||
initialHost = host,
|
||||
restClient = app.hermes.mobile.core.network.HermesRestClient(),
|
||||
gatewayClient = JsonRpcGatewayClient(scope = childScope),
|
||||
tokenVault = tokenVault,
|
||||
scope = childScope
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
sessionRepo = UnifiedSessionRepository(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package app.hermes.mobile.core.repository
|
|||
import app.hermes.mobile.core.model.*
|
||||
import app.hermes.mobile.core.network.ConnectionState
|
||||
import app.hermes.mobile.core.network.JsonRpcGatewayClient
|
||||
import app.hermes.mobile.core.network.HermesRestClient
|
||||
import app.hermes.mobile.core.runtime.HermesConnectionManager
|
||||
import app.hermes.mobile.core.runtime.HermesHostRuntime
|
||||
import app.hermes.mobile.core.security.InMemoryTokenVault
|
||||
|
|
@ -53,7 +54,17 @@ class MultiHostConcurrencyExecutionTest {
|
|||
connectionManager = HermesConnectionManager(
|
||||
hostDao = hostDao,
|
||||
tokenVault = tokenVault,
|
||||
scope = CoroutineScope(testDispatcher)
|
||||
scope = CoroutineScope(testDispatcher),
|
||||
runtimeFactory = { parentScope, host ->
|
||||
val childScope = CoroutineScope(kotlinx.coroutines.SupervisorJob(parentScope.coroutineContext[kotlinx.coroutines.Job]) + testDispatcher)
|
||||
app.hermes.mobile.core.runtime.HermesHostRuntime(
|
||||
initialHost = host,
|
||||
restClient = app.hermes.mobile.core.network.HermesRestClient(),
|
||||
gatewayClient = app.hermes.mobile.core.network.JsonRpcGatewayClient(scope = childScope),
|
||||
tokenVault = tokenVault,
|
||||
scope = childScope
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
sessionRepo = UnifiedSessionRepository(
|
||||
|
|
@ -664,8 +675,10 @@ class MultiHostConcurrencyExecutionTest {
|
|||
scope = CoroutineScope(Dispatchers.Default)
|
||||
)
|
||||
|
||||
val session = testRepo.createUnifiedSession(title = "Reconnect Test", initialHostId = host1Id)
|
||||
val runtime = testConnectionManager.getRuntime(host1Id)!!
|
||||
val host1 = HermesHost(id = HermesHostId("host-windows"), displayName = "Server", baseUrl = wsUrl, allowCleartext = true, lastKnownStatus = HostStatus.ONLINE)
|
||||
testConnectionManager.addHost(host1)
|
||||
val session = testRepo.createUnifiedSession(title = "Reconnect Test", initialHostId = HermesHostId("host-windows"))
|
||||
val runtime = testConnectionManager.getRuntime(HermesHostId("host-windows"))!!
|
||||
|
||||
// Initial connect
|
||||
runtime.connect()
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import app.hermes.mobile.core.network.ConnectionState
|
|||
import app.hermes.mobile.core.network.HermesRestClient
|
||||
import app.hermes.mobile.core.network.JsonRpcGatewayClient
|
||||
import app.hermes.mobile.core.runtime.HermesConnectionManager
|
||||
import app.hermes.mobile.core.runtime.HermesHostRuntime
|
||||
import app.hermes.mobile.core.security.InMemoryTokenVault
|
||||
import app.hermes.mobile.core.storage.FakeHostDao
|
||||
import app.hermes.mobile.core.storage.FakeUnifiedSessionDao
|
||||
|
|
@ -49,7 +50,17 @@ class UnifiedSessionRepositoryTest {
|
|||
connectionManager = HermesConnectionManager(
|
||||
hostDao = hostDao,
|
||||
tokenVault = tokenVault,
|
||||
scope = CoroutineScope(testDispatcher)
|
||||
scope = CoroutineScope(testDispatcher),
|
||||
runtimeFactory = { parentScope, host ->
|
||||
val childScope = CoroutineScope(kotlinx.coroutines.SupervisorJob(parentScope.coroutineContext[kotlinx.coroutines.Job]) + testDispatcher)
|
||||
app.hermes.mobile.core.runtime.HermesHostRuntime(
|
||||
initialHost = host,
|
||||
restClient = app.hermes.mobile.core.network.HermesRestClient(),
|
||||
gatewayClient = app.hermes.mobile.core.network.JsonRpcGatewayClient(scope = childScope),
|
||||
tokenVault = tokenVault,
|
||||
scope = childScope
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
repository = UnifiedSessionRepository(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
package app.hermes.mobile.core.runtime
|
||||
|
||||
import app.hermes.mobile.core.model.HermesHost
|
||||
import app.hermes.mobile.core.model.HermesHostId
|
||||
import app.hermes.mobile.core.model.HostStatus
|
||||
import app.hermes.mobile.core.network.HermesRestClient
|
||||
import app.hermes.mobile.core.security.InMemoryTokenVault
|
||||
import app.hermes.mobile.core.storage.FakeHostDao
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
|
||||
class AppLifecycleTest {
|
||||
|
||||
@Test
|
||||
fun testSingleHostIdCorrespondsToAtMostOneLiveRuntime() = runTest {
|
||||
val hostDao = FakeHostDao()
|
||||
val tokenVault = InMemoryTokenVault()
|
||||
val restClient = HermesRestClient()
|
||||
|
||||
val connectionManager = HermesConnectionManager(
|
||||
hostDao = hostDao,
|
||||
tokenVault = tokenVault,
|
||||
restClient = restClient,
|
||||
scope = backgroundScope,
|
||||
runtimeFactory = { parentScope, host ->
|
||||
val childScope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.SupervisorJob(parentScope.coroutineContext[kotlinx.coroutines.Job]) + kotlinx.coroutines.test.StandardTestDispatcher(testScheduler))
|
||||
app.hermes.mobile.core.runtime.HermesHostRuntime(
|
||||
initialHost = host,
|
||||
restClient = restClient,
|
||||
gatewayClient = app.hermes.mobile.core.network.JsonRpcGatewayClient(scope = childScope),
|
||||
tokenVault = tokenVault,
|
||||
scope = childScope
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
val host = HermesHost(
|
||||
id = HermesHostId("h1"),
|
||||
displayName = "Host 1",
|
||||
baseUrl = "http://host1.com",
|
||||
allowCleartext = true,
|
||||
enabled = true,
|
||||
lastSeenAt = 0L,
|
||||
lastKnownStatus = HostStatus.OFFLINE
|
||||
)
|
||||
|
||||
val runtime1 = connectionManager.getOrCreateRuntime(host)
|
||||
val runtime2 = connectionManager.getOrCreateRuntime(host)
|
||||
val runtime3 = connectionManager.getRuntime(host.id)
|
||||
|
||||
assertSame(runtime1, runtime2)
|
||||
assertSame(runtime1, runtime3)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue