* fix(#472): refactor ChatViewModelTest to use Fake* + Turbine Replace Mockk-heavy test setup with FakeChatPersistenceRepository (+ in-memory FakeChatMessageDao) and add Turbine for Flow assertions. Changes: - Add Turbine v1.2.0 dependency (libs.versions.toml + build.gradle.kts) - Create FakeChatMessageDao – in-memory DAO with MutableStateFlow - Create FakeChatPersistenceRepository – delegates to FakeDao - ChatViewModel: make repo injectable (@VisibleForTesting lateinit var) - Rewrite ChatViewModelTest (751 lines vs 1,212): clearer helpers, Turbine test for streaming Flow sequence, same 29 tests with identical assertions, ~38% line reduction Test: ktlint --format passes clean * fix(#472): address review — constructor injection, ConcurrentHashMap, MockWebServer tests Addresses all feedback from Sourcery review on PR #474: Constructor injection (was lateinit var): - Move repo to ChatViewModel primary constructor with default - Remove @VisibleForTesting lateinit var + init block assignment - Tests pass repo directly via constructor Simplify FakeChatMessageDao: - Replace manual synchronized(lock) with ConcurrentHashMap - Same thread safety, 48% less code, no boilerplate lock object New MockWebServer tests: - HermesApiServiceMockWebServerTest: 6 tests covering getStatus, getSessions, getSessionMessages + edge cases (null fields, empty lists, slash-encoded session IDs) * fix: CI compilation errors — open class, TestScope receiver, runBlocking Three compilation errors blocking CI: - ChatPersistenceRepository was final, but FakeChatPersistenceRepository extends it → make it open class - createViewModelWithSession() called advanceUntilIdle (TestScope ext) and emit() (suspend) without a TestScope receiver or coroutine ctx → make it fun TestScope.xxx() and use tryEmit() for non-suspend emission - HermesApiServiceMockWebServerTest called suspend API methods from regular @Test methods → wrap each test in runBlocking * fix: test runtime failures — suspend helper + fix JSON escaping - Use so emit() (suspend) is valid and events are delivered synchronously instead of buffered via tryEmit() which was causing 17 test failures - Fix MalformedJsonException in MockWebServer test by using plain string content instead of unescaped JSON-within-JSON * fix: correct req-id values in ChatViewModelTest — SESSION_CREATE is req-id-3 * fix(test): correct ChatViewModelTest assertions for Fakes+Turbine refactor - testMessageStreamingFlow: collect streamingState via Turbine (ThinkingDelta/MessageToken mutate streamingState, not uiState) - testToolExecution_finalizesPreviousStreamingMessage: account for messages[0] = "Session created" system message - testMessageStart_finalizesPreviousStreamingMessage: same index offset (messages[1] is the finalized stream) - testClarifyRequestCustomResponse: options is emptyList(), not null; message count is 3 (session-create + user clarify reply) Fixes CI failures introduced in PR #474. --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
d5fa70b34b
commit
1c3a5386f4
8 files changed
+645
-715
No files matched your search
@@ -165,6 +165,7 @@ dependencies {
|
|||||||
testImplementation(libs.okhttp.mockwebserver)
|
testImplementation(libs.okhttp.mockwebserver)
|
||||||
testImplementation(libs.kotlinx.coroutines.test)
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
testImplementation(libs.mockk)
|
testImplementation(libs.mockk)
|
||||||
|
testImplementation(libs.turbine)
|
||||||
|
|
||||||
// Instrumented tests: jUnit rules and runners
|
// Instrumented tests: jUnit rules and runners
|
||||||
androidTestImplementation(libs.androidx.test.core)
|
androidTestImplementation(libs.androidx.test.core)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import com.m57.hermescontrol.data.local.toUiModel
|
|||||||
* Extracted from ChatViewModel to separate persistence concerns from
|
* Extracted from ChatViewModel to separate persistence concerns from
|
||||||
* UI state management and WebSocket event handling.
|
* UI state management and WebSocket event handling.
|
||||||
*/
|
*/
|
||||||
class ChatPersistenceRepository(
|
open class ChatPersistenceRepository(
|
||||||
private val dao: ChatMessageDao,
|
private val dao: ChatMessageDao,
|
||||||
) {
|
) {
|
||||||
/** Persist a single message for the given session. */
|
/** Persist a single message for the given session. */
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ data class ClarifyUi(
|
|||||||
class ChatViewModel(
|
class ChatViewModel(
|
||||||
application: Application,
|
application: Application,
|
||||||
private val startCleanup: Boolean,
|
private val startCleanup: Boolean,
|
||||||
|
private val repo: ChatPersistenceRepository =
|
||||||
|
ChatPersistenceRepository(
|
||||||
|
HermesDatabase.get(application).chatMessageDao(),
|
||||||
|
),
|
||||||
) : AndroidViewModel(application) {
|
) : AndroidViewModel(application) {
|
||||||
constructor(application: Application) : this(application, startCleanup = true)
|
constructor(application: Application) : this(application, startCleanup = true)
|
||||||
|
|
||||||
@@ -105,10 +109,7 @@ class ChatViewModel(
|
|||||||
|
|
||||||
private val wsClient = HermesWsClient
|
private val wsClient = HermesWsClient
|
||||||
private val pendingRequests = ConcurrentHashMap<String, PendingRequest>()
|
private val pendingRequests = ConcurrentHashMap<String, PendingRequest>()
|
||||||
private val repo =
|
|
||||||
ChatPersistenceRepository(
|
|
||||||
HermesDatabase.get(application).chatMessageDao(),
|
|
||||||
)
|
|
||||||
private val slashDispatcher = SlashCommandDispatcher()
|
private val slashDispatcher = SlashCommandDispatcher()
|
||||||
private val searchController = ChatSearchController()
|
private val searchController = ChatSearchController()
|
||||||
|
|
||||||
|
|||||||
+320
@@ -0,0 +1,320 @@
|
|||||||
|
package com.m57.hermescontrol.data.remote
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.SessionMessage
|
||||||
|
import com.m57.hermescontrol.data.model.StatusResponse
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MockWebServer-based tests for [HermesApiService].
|
||||||
|
*
|
||||||
|
* Tests the Retrofit serialization + HTTP layer independently of the
|
||||||
|
* real Hermes Gateway. Each test starts a local mock server, enqueues
|
||||||
|
* canned JSON responses, and asserts the parsed response objects.
|
||||||
|
*/
|
||||||
|
class HermesApiServiceMockWebServerTest {
|
||||||
|
private lateinit var mockServer: MockWebServer
|
||||||
|
private lateinit var api: HermesApiService
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
mockServer = MockWebServer()
|
||||||
|
mockServer.start()
|
||||||
|
|
||||||
|
api =
|
||||||
|
Retrofit
|
||||||
|
.Builder()
|
||||||
|
.baseUrl(mockServer.url("/"))
|
||||||
|
.addConverterFactory(GsonConverterFactory.create(OkHttpProvider.gson))
|
||||||
|
.build()
|
||||||
|
.create(HermesApiService::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
mockServer.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getStatus_parsesResponse() =
|
||||||
|
runBlocking {
|
||||||
|
mockServer.enqueue(
|
||||||
|
MockResponse()
|
||||||
|
.setResponseCode(200)
|
||||||
|
.setBody(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"version": "1.2.3",
|
||||||
|
"gateway_running": true,
|
||||||
|
"active_sessions": 4,
|
||||||
|
"auth_required": true,
|
||||||
|
"gateway_platforms": {
|
||||||
|
"telegram": { "state": "connected" },
|
||||||
|
"discord": { "state": "error", "error_code": "AUTH_FAILED" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = api.getStatus()
|
||||||
|
assertTrue(response.isSuccessful)
|
||||||
|
|
||||||
|
val body: StatusResponse? = response.body()
|
||||||
|
assertNotNull(body)
|
||||||
|
assertEquals("1.2.3", body!!.version)
|
||||||
|
assertEquals(true, body.gateway_running)
|
||||||
|
assertEquals(4, body.active_sessions)
|
||||||
|
assertEquals(true, body.auth_required)
|
||||||
|
|
||||||
|
// Platform statuses
|
||||||
|
assertEquals(2, body.gateway_platforms?.size)
|
||||||
|
assertEquals("connected", body.gateway_platforms?.get("telegram")?.state)
|
||||||
|
assertEquals("error", body.gateway_platforms?.get("discord")?.state)
|
||||||
|
assertEquals("AUTH_FAILED", body.gateway_platforms?.get("discord")?.error_code)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getStatus_withNullFields_doesNotCrash() =
|
||||||
|
runBlocking {
|
||||||
|
mockServer.enqueue(
|
||||||
|
MockResponse()
|
||||||
|
.setResponseCode(200)
|
||||||
|
.setBody(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"version": null,
|
||||||
|
"gateway_running": null,
|
||||||
|
"active_sessions": null,
|
||||||
|
"auth_required": null,
|
||||||
|
"gateway_platforms": null
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = api.getStatus()
|
||||||
|
assertTrue(response.isSuccessful)
|
||||||
|
|
||||||
|
val body = response.body()
|
||||||
|
assertNotNull(body)
|
||||||
|
assertNull(body!!.version)
|
||||||
|
assertNull(body.gateway_running)
|
||||||
|
assertNull(body.active_sessions)
|
||||||
|
assertNull(body.auth_required)
|
||||||
|
assertNull(body.gateway_platforms)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getSessions_parsesResponse() =
|
||||||
|
runBlocking {
|
||||||
|
mockServer.enqueue(
|
||||||
|
MockResponse()
|
||||||
|
.setResponseCode(200)
|
||||||
|
.setBody(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"sessions": [
|
||||||
|
{
|
||||||
|
"id": "abc-123",
|
||||||
|
"title": "Chat about APIs",
|
||||||
|
"created_at": "2026-06-15T10:00:00Z",
|
||||||
|
"message_count": 42,
|
||||||
|
"status": "active",
|
||||||
|
"preview": "Last message preview here",
|
||||||
|
"source": "telegram"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "def-456",
|
||||||
|
"title": "Debugging build",
|
||||||
|
"created_at": "2026-06-20T14:30:00Z",
|
||||||
|
"message_count": 7,
|
||||||
|
"status": "idle",
|
||||||
|
"source": "web"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 2,
|
||||||
|
"limit": 20,
|
||||||
|
"offset": 0
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = api.getSessions(limit = 20, offset = 0)
|
||||||
|
assertTrue(response.isSuccessful)
|
||||||
|
|
||||||
|
val body = response.body()
|
||||||
|
assertNotNull(body)
|
||||||
|
assertEquals(2, body!!.sessions.size)
|
||||||
|
assertEquals(2, body.total)
|
||||||
|
|
||||||
|
val first = body.sessions[0]
|
||||||
|
assertEquals("abc-123", first.id)
|
||||||
|
assertEquals("Chat about APIs", first.title)
|
||||||
|
assertEquals(42, first.message_count)
|
||||||
|
assertEquals("active", first.status)
|
||||||
|
assertEquals("telegram", first.source)
|
||||||
|
|
||||||
|
val second = body.sessions[1]
|
||||||
|
assertEquals("def-456", second.id)
|
||||||
|
assertEquals("Debugging build", second.title)
|
||||||
|
assertEquals(7, second.message_count)
|
||||||
|
assertEquals("idle", second.status)
|
||||||
|
assertEquals("web", second.source)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getSessions_withEmptyList_parsesCorrectly() =
|
||||||
|
runBlocking {
|
||||||
|
mockServer.enqueue(
|
||||||
|
MockResponse()
|
||||||
|
.setResponseCode(200)
|
||||||
|
.setBody(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"sessions": [],
|
||||||
|
"total": 0,
|
||||||
|
"limit": 20,
|
||||||
|
"offset": 0
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = api.getSessions()
|
||||||
|
assertTrue(response.isSuccessful)
|
||||||
|
|
||||||
|
val body = response.body()
|
||||||
|
assertNotNull(body)
|
||||||
|
assertTrue(body!!.sessions.isEmpty())
|
||||||
|
assertEquals(0, body.total)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getSessionMessages_parsesResponse() =
|
||||||
|
runBlocking {
|
||||||
|
mockServer.enqueue(
|
||||||
|
MockResponse()
|
||||||
|
.setResponseCode(200)
|
||||||
|
.setBody(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Hello Hermes",
|
||||||
|
"timestamp": "1718000000"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Hi! How can I help?",
|
||||||
|
"timestamp": "1718000010"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"content": "Result: 42",
|
||||||
|
"timestamp": "1718000020",
|
||||||
|
"type": "tool_execution"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = api.getSessionMessages("test-session-id")
|
||||||
|
assertTrue(response.isSuccessful)
|
||||||
|
|
||||||
|
val body = response.body()
|
||||||
|
assertNotNull(body)
|
||||||
|
|
||||||
|
val messages: List<SessionMessage> = body!!.messages
|
||||||
|
assertEquals(3, messages.size)
|
||||||
|
|
||||||
|
assertEquals("user", messages[0].role)
|
||||||
|
assertEquals("Hello Hermes", messages[0].content)
|
||||||
|
assertEquals("1718000000", messages[0].timestamp)
|
||||||
|
|
||||||
|
assertEquals("assistant", messages[1].role)
|
||||||
|
assertEquals("Hi! How can I help?", messages[1].content)
|
||||||
|
|
||||||
|
assertEquals("tool", messages[2].role)
|
||||||
|
assertEquals("tool_execution", messages[2].type)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getSessionMessages_withNullRole_doesNotCrash() =
|
||||||
|
runBlocking {
|
||||||
|
mockServer.enqueue(
|
||||||
|
MockResponse()
|
||||||
|
.setResponseCode(200)
|
||||||
|
.setBody(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": null,
|
||||||
|
"content": "Plain content",
|
||||||
|
"timestamp": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = api.getSessionMessages("any-id")
|
||||||
|
assertTrue(response.isSuccessful)
|
||||||
|
|
||||||
|
val body = response.body()
|
||||||
|
assertNotNull(body)
|
||||||
|
assertEquals(1, body!!.messages.size)
|
||||||
|
assertNull(body.messages[0].role)
|
||||||
|
assertEquals("Plain content", body.messages[0].content)
|
||||||
|
assertNull(body.messages[0].timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getSessionMessages_encodesSessionIdWithSlashes() =
|
||||||
|
runBlocking {
|
||||||
|
val sessionId = "session/with/slashes"
|
||||||
|
mockServer.enqueue(
|
||||||
|
MockResponse()
|
||||||
|
.setResponseCode(200)
|
||||||
|
.setBody(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "test",
|
||||||
|
"timestamp": "1000"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = api.getSessionMessages(sessionId)
|
||||||
|
assertTrue(response.isSuccessful)
|
||||||
|
|
||||||
|
// Verify the request path preserves slashes
|
||||||
|
val request = mockServer.takeRequest()
|
||||||
|
assertTrue(
|
||||||
|
"Slash-encoded session ID should appear in path",
|
||||||
|
request.path!!.contains("session/with/slashes"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,42 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.fakes
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.local.ChatMessageDao
|
||||||
|
import com.m57.hermescontrol.data.local.ChatMessageEntity
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.ConcurrentMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory [ChatMessageDao] for use in tests.
|
||||||
|
* Uses [ConcurrentHashMap] for thread safety — no manual synchronization needed.
|
||||||
|
*/
|
||||||
|
class FakeChatMessageDao : ChatMessageDao {
|
||||||
|
private val messages: ConcurrentMap<String, ChatMessageEntity> = ConcurrentHashMap()
|
||||||
|
|
||||||
|
override suspend fun sessionExists(sessionId: String): Boolean = messages.values.any { it.sessionId == sessionId }
|
||||||
|
|
||||||
|
override suspend fun getMessagesForSession(sessionId: String): List<ChatMessageEntity> =
|
||||||
|
messages.values
|
||||||
|
.filter { it.sessionId == sessionId }
|
||||||
|
.sortedBy { it.timestamp }
|
||||||
|
|
||||||
|
override suspend fun upsert(message: ChatMessageEntity) {
|
||||||
|
messages[message.id] = message
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun upsertAll(messageList: List<ChatMessageEntity>) {
|
||||||
|
messageList.forEach { messages[it.id] = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Direct access for test setup — bypasses the suspend modifier. */
|
||||||
|
fun addMessageDirect(message: ChatMessageEntity) {
|
||||||
|
messages[message.id] = message
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset all stored messages. */
|
||||||
|
fun clear() {
|
||||||
|
messages.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the number of stored messages. */
|
||||||
|
fun count(): Int = messages.size
|
||||||
|
}
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.fakes
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.ui.chat.ChatPersistenceRepository
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory [ChatPersistenceRepository] for tests.
|
||||||
|
*
|
||||||
|
* Wraps [FakeChatMessageDao] so tests can read/write messages
|
||||||
|
* without Room or SQLCipher. Pass to [ChatViewModel]'s `repo`
|
||||||
|
* constructor parameter in tests.
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* val fakeRepo = FakeChatPersistenceRepository()
|
||||||
|
* fakeRepo.dao.addMessageDirect(someEntity)
|
||||||
|
* val vm = ChatViewModel(app, startCleanup, fakeRepo)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class FakeChatPersistenceRepository(
|
||||||
|
val dao: FakeChatMessageDao = FakeChatMessageDao(),
|
||||||
|
) : ChatPersistenceRepository(dao) {
|
||||||
|
/** Clear all stored messages. */
|
||||||
|
fun clear() = dao.clear()
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ retrofit = "3.0.0"
|
|||||||
gson = "2.14.0"
|
gson = "2.14.0"
|
||||||
securityCrypto = "1.0.0"
|
securityCrypto = "1.0.0"
|
||||||
mockk = "1.14.11"
|
mockk = "1.14.11"
|
||||||
|
turbine = "1.2.0"
|
||||||
coil = "2.7.0"
|
coil = "2.7.0"
|
||||||
room = "2.7.1"
|
room = "2.7.1"
|
||||||
sqlcipher = "4.16.0"
|
sqlcipher = "4.16.0"
|
||||||
@@ -55,6 +56,7 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t
|
|||||||
leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanary" }
|
leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanary" }
|
||||||
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
|
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
|
||||||
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
|
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
|
||||||
|
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
|
||||||
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" }
|
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" }
|
||||||
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" }
|
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" }
|
||||||
androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" }
|
androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" }
|
||||||
|
|||||||
Reference in new issue
Block a user