From 14c6a36bf6d933fc729008f58e10a5fa5692fcc6 Mon Sep 17 00:00:00 2001 From: "ZEIAD-LAPTOP\\zeiad" Date: Tue, 11 Aug 2026 16:00:54 +0300 Subject: [PATCH 1/2] refactor mcp transport behind remote datasource --- core/data/build.gradle.kts | 2 - .../app/core/data/mcp/di/McpDataModule.kt | 10 +- .../data/mcp/remote/McpRemoteDataSource.kt | 11 ++ .../mcp/remote/McpRemoteDataSourceImpl.kt | 29 +++++ .../data/mcp/repository/McpRepositoryImpl.kt | 105 ++++++++---------- .../core/data/mcp/McpRepositoryImplTest.kt | 88 ++++++--------- .../mcp/remote/McpRemoteDataSourceImplTest.kt | 66 +++++++++++ core/network/build.gradle.kts | 2 +- .../app/core/network/api/McpApiService.kt | 7 +- ...026-08-11-data-layer-transport-boundary.md | 26 +++++ 10 files changed, 227 insertions(+), 119 deletions(-) create mode 100644 core/data/src/main/kotlin/com/awan/app/core/data/mcp/remote/McpRemoteDataSource.kt create mode 100644 core/data/src/main/kotlin/com/awan/app/core/data/mcp/remote/McpRemoteDataSourceImpl.kt create mode 100644 core/data/src/test/java/com/awan/app/core/data/mcp/remote/McpRemoteDataSourceImplTest.kt create mode 100644 docs/feature/mcp/2026-08-11-data-layer-transport-boundary.md diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 8b1703b7..816710de 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -22,8 +22,6 @@ dependencies { implementation(libs.okhttp) implementation(platform(libs.firebase.bom)) implementation(libs.firebase.messaging) - implementation(libs.retrofit) - testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) } diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/mcp/di/McpDataModule.kt b/core/data/src/main/kotlin/com/awan/app/core/data/mcp/di/McpDataModule.kt index 31521c11..bc3b00c4 100644 --- a/core/data/src/main/kotlin/com/awan/app/core/data/mcp/di/McpDataModule.kt +++ b/core/data/src/main/kotlin/com/awan/app/core/data/mcp/di/McpDataModule.kt @@ -1,6 +1,8 @@ package com.awan.app.core.data.mcp.di import com.awan.app.core.data.mcp.repository.McpRepositoryImpl +import com.awan.app.core.data.mcp.remote.McpRemoteDataSource +import com.awan.app.core.data.mcp.remote.McpRemoteDataSourceImpl import com.awan.app.core.domain.mcp.repository.McpRepository import dagger.Binds import dagger.Module @@ -10,7 +12,13 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -abstract class McpDataModule { +internal abstract class McpDataModule { + + @Binds + @Singleton + abstract fun bindMcpRemoteDataSource( + impl: McpRemoteDataSourceImpl, + ): McpRemoteDataSource @Binds @Singleton diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/mcp/remote/McpRemoteDataSource.kt b/core/data/src/main/kotlin/com/awan/app/core/data/mcp/remote/McpRemoteDataSource.kt new file mode 100644 index 00000000..68f7dcd7 --- /dev/null +++ b/core/data/src/main/kotlin/com/awan/app/core/data/mcp/remote/McpRemoteDataSource.kt @@ -0,0 +1,11 @@ +package com.awan.app.core.data.mcp.remote + +import com.awan.app.core.common.result.Result +import com.awan.app.core.network.dto.mcp.ApiKeyResponseDto +import com.awan.app.core.network.dto.mcp.ApiKeySummaryDto + +internal interface McpRemoteDataSource { + suspend fun getApiKeys(): Result> + suspend fun createApiKey(name: String): Result + suspend fun revokeApiKey(id: String): Result +} diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/mcp/remote/McpRemoteDataSourceImpl.kt b/core/data/src/main/kotlin/com/awan/app/core/data/mcp/remote/McpRemoteDataSourceImpl.kt new file mode 100644 index 00000000..d4eb364e --- /dev/null +++ b/core/data/src/main/kotlin/com/awan/app/core/data/mcp/remote/McpRemoteDataSourceImpl.kt @@ -0,0 +1,29 @@ +package com.awan.app.core.data.mcp.remote + +import com.awan.app.core.common.dispatcher.AwanDispatchers +import com.awan.app.core.common.dispatcher.Dispatcher +import com.awan.app.core.common.result.Result +import com.awan.app.core.network.api.McpApiService +import com.awan.app.core.network.dto.mcp.ApiKeyResponseDto +import com.awan.app.core.network.dto.mcp.ApiKeySummaryDto +import com.awan.app.core.network.dto.mcp.CreateApiKeyRequestDto +import com.awan.app.core.network.error.safeApiCall +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.serialization.json.Json + +internal class McpRemoteDataSourceImpl @Inject constructor( + private val apiService: McpApiService, + private val json: Json, + @Dispatcher(AwanDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, +) : McpRemoteDataSource { + + override suspend fun getApiKeys(): Result> = + safeApiCall(ioDispatcher, json) { apiService.getApiKeys() } + + override suspend fun createApiKey(name: String): Result = + safeApiCall(ioDispatcher, json) { apiService.createApiKey(CreateApiKeyRequestDto(name)) } + + override suspend fun revokeApiKey(id: String): Result = + safeApiCall(ioDispatcher, json) { apiService.revokeApiKey(id) } +} diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/mcp/repository/McpRepositoryImpl.kt b/core/data/src/main/kotlin/com/awan/app/core/data/mcp/repository/McpRepositoryImpl.kt index c2207889..e63dd84f 100644 --- a/core/data/src/main/kotlin/com/awan/app/core/data/mcp/repository/McpRepositoryImpl.kt +++ b/core/data/src/main/kotlin/com/awan/app/core/data/mcp/repository/McpRepositoryImpl.kt @@ -6,6 +6,7 @@ import com.awan.app.core.common.error.AppError import com.awan.app.core.common.result.Result import com.awan.app.core.data.mcp.mapper.toDomain import com.awan.app.core.data.mcp.mapper.toEntity +import com.awan.app.core.data.mcp.remote.McpRemoteDataSource import com.awan.app.core.database.dao.McpTokenDao import com.awan.app.core.domain.mcp.model.CreatedMcpToken import com.awan.app.core.domain.mcp.model.McpConnectionDetails @@ -13,9 +14,8 @@ import com.awan.app.core.domain.mcp.model.McpToken import com.awan.app.core.domain.mcp.repository.McpRepository import com.awan.app.core.domain.network.NetworkConnectivityMonitor import com.awan.app.core.network.BuildConfig -import com.awan.app.core.network.api.McpApiService -import com.awan.app.core.network.dto.mcp.CreateApiKeyRequestDto -import com.awan.app.core.network.error.safeApiCall +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow @@ -24,13 +24,10 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import retrofit2.HttpException -import javax.inject.Inject -import javax.inject.Singleton @Singleton -class McpRepositoryImpl @Inject constructor( - private val mcpApiService: McpApiService, +internal class McpRepositoryImpl @Inject constructor( + private val remoteDataSource: McpRemoteDataSource, private val mcpTokenDao: McpTokenDao, private val connectivityMonitor: NetworkConnectivityMonitor, @Dispatcher(AwanDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, @@ -42,19 +39,18 @@ class McpRepositoryImpl @Inject constructor( McpConnectionDetails( mcpUrl = "${BuildConfig.AWAN_BASE_URL.trimEnd('/')}/v1/mcp", clientId = "awan-android-client", - ) - ) + ), + ), ) }.flowOn(ioDispatcher) override fun getMcpTokens(): Flow>> = flow { if (connectivityMonitor.isCurrentlyOnline()) { try { - val response = mcpApiService.getApiKeys() - if (response.isSuccessful) { - val dtos = response.body().orEmpty() - val entities = dtos.map { it.toEntity() } - mcpTokenDao.replaceMcpTokens(entities) + when (val result = remoteDataSource.getApiKeys()) { + is Result.Success -> mcpTokenDao.replaceMcpTokens(result.data.map { it.toEntity() }) + is Result.Error, + Result.Loading -> Unit } } catch (e: Exception) { if (e is CancellationException) throw e @@ -64,37 +60,33 @@ class McpRepositoryImpl @Inject constructor( emitAll( mcpTokenDao.getMcpTokens().map { entities -> Result.Success(entities.map { it.toDomain() }) - } + }, ) }.flowOn(ioDispatcher) override suspend fun createMcpToken(name: String): Result { - if (!connectivityMonitor.isCurrentlyOnline()) { - return Result.Error(AppError.Network) - } - return safeApiCall(ioDispatcher) { - val response = mcpApiService.createApiKey(CreateApiKeyRequestDto(name = name)) - if (!response.isSuccessful) throw HttpException(response) - val dto = response.body() ?: throw IllegalStateException("Empty response body") - val createdToken = dto.toDomain() - try { - mcpTokenDao.upsertMcpTokens(listOf(dto.toEntity())) - } catch (e: Exception) { - if (e is CancellationException) throw e - // Local DB cache write failure must NOT drop or cause failure of the returned raw token + if (!connectivityMonitor.isCurrentlyOnline()) return Result.Error(AppError.Network) + + return when (val result = remoteDataSource.createApiKey(name)) { + is Result.Success -> { + val dto = result.data + try { + mcpTokenDao.upsertMcpTokens(listOf(dto.toEntity())) + } catch (e: Exception) { + if (e is CancellationException) throw e + // Local DB cache write failure must NOT drop or cause failure of the returned raw token + } + Result.Success(dto.toDomain()) } - createdToken + is Result.Error -> Result.Error(result.error) + Result.Loading -> Result.Loading } } override suspend fun deleteMcpToken(id: String): Result { - if (!connectivityMonitor.isCurrentlyOnline()) { - return Result.Error(AppError.Network) - } - val result = safeApiCall(ioDispatcher) { - val response = mcpApiService.revokeApiKey(id) - if (!response.isSuccessful) throw HttpException(response) - } + if (!connectivityMonitor.isCurrentlyOnline()) return Result.Error(AppError.Network) + + val result = remoteDataSource.revokeApiKey(id) if (result is Result.Success) { try { mcpTokenDao.deleteMcpToken(id) @@ -106,28 +98,27 @@ class McpRepositoryImpl @Inject constructor( } override suspend fun regenerateMcpToken(id: String): Result { - if (!connectivityMonitor.isCurrentlyOnline()) { - return Result.Error(AppError.Network) - } - val existingTokenName = mcpTokenDao.getMcpTokens().first().find { it.id == id }?.name ?: "MCP Token" - return safeApiCall(ioDispatcher) { - val createResponse = mcpApiService.createApiKey(CreateApiKeyRequestDto(name = existingTokenName)) - if (!createResponse.isSuccessful) throw HttpException(createResponse) - val dto = createResponse.body() ?: throw IllegalStateException("Empty response body") - val createdToken = dto.toDomain() - - val revokeResponse = mcpApiService.revokeApiKey(id) - if (!revokeResponse.isSuccessful) throw HttpException(revokeResponse) + if (!connectivityMonitor.isCurrentlyOnline()) return Result.Error(AppError.Network) - try { - mcpTokenDao.deleteMcpToken(id) - mcpTokenDao.upsertMcpTokens(listOf(dto.toEntity())) - } catch (e: Exception) { - if (e is CancellationException) throw e - // Local DB cache write failure must NOT drop or cause failure of the returned raw token + val name = mcpTokenDao.getMcpTokens().first().find { it.id == id }?.name ?: "MCP Token" + return when (val createResult = remoteDataSource.createApiKey(name)) { + is Result.Success -> when (val revokeResult = remoteDataSource.revokeApiKey(id)) { + is Result.Success -> { + val dto = createResult.data + try { + mcpTokenDao.deleteMcpToken(id) + mcpTokenDao.upsertMcpTokens(listOf(dto.toEntity())) + } catch (e: Exception) { + if (e is CancellationException) throw e + // Local DB cache write failure must NOT drop or cause failure of the returned raw token + } + Result.Success(dto.toDomain()) + } + is Result.Error -> Result.Error(revokeResult.error) + Result.Loading -> Result.Loading } - createdToken + is Result.Error -> Result.Error(createResult.error) + Result.Loading -> Result.Loading } } } - diff --git a/core/data/src/test/java/com/awan/app/core/data/mcp/McpRepositoryImplTest.kt b/core/data/src/test/java/com/awan/app/core/data/mcp/McpRepositoryImplTest.kt index a7f07364..90e35d73 100644 --- a/core/data/src/test/java/com/awan/app/core/data/mcp/McpRepositoryImplTest.kt +++ b/core/data/src/test/java/com/awan/app/core/data/mcp/McpRepositoryImplTest.kt @@ -3,14 +3,13 @@ package com.awan.app.core.data.mcp import com.awan.app.core.common.error.AppError import com.awan.app.core.network.BuildConfig import com.awan.app.core.common.result.Result +import com.awan.app.core.data.mcp.remote.McpRemoteDataSource import com.awan.app.core.data.mcp.repository.McpRepositoryImpl import com.awan.app.core.database.dao.McpTokenDao import com.awan.app.core.database.model.McpTokenEntity import com.awan.app.core.domain.network.NetworkConnectivityMonitor -import com.awan.app.core.network.api.McpApiService import com.awan.app.core.network.dto.mcp.ApiKeyResponseDto import com.awan.app.core.network.dto.mcp.ApiKeySummaryDto -import com.awan.app.core.network.dto.mcp.CreateApiKeyRequestDto import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -18,13 +17,11 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest -import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test -import retrofit2.Response -private class FakeMcpApiService : McpApiService { +private class FakeMcpRemoteDataSource : McpRemoteDataSource { var apiKeysList = mutableListOf() var createApiKeyResponse = ApiKeyResponseDto( id = "token-1", @@ -32,42 +29,25 @@ private class FakeMcpApiService : McpApiService { keyValue = "raw-secret-123", createdAt = "2026-08-11T00:00:00Z", ) - var shouldFailWithException: Exception? = null + var getApiKeysResult: Result>? = null + var createApiKeyResult: Result? = null + var revokeApiKeyResult: Result? = null var lastCreatedName: String? = null var lastRevokedId: String? = null - var httpErrorCode: Int? = null - var revokeHttpErrorCode: Int? = null - override suspend fun getApiKeys(): Response> { - shouldFailWithException?.let { throw it } - httpErrorCode?.let { - return Response.error(it, "Error".toResponseBody(null)) - } - return Response.success(apiKeysList) - } + override suspend fun getApiKeys(): Result> = + getApiKeysResult ?: Result.Success(apiKeysList) - override suspend fun createApiKey(request: CreateApiKeyRequestDto): Response { - shouldFailWithException?.let { throw it } - httpErrorCode?.let { - return Response.error(it, "Error".toResponseBody(null)) - } - lastCreatedName = request.name - return Response.success(createApiKeyResponse.copy(name = request.name)) + override suspend fun createApiKey(name: String): Result { + lastCreatedName = name + return createApiKeyResult ?: Result.Success(createApiKeyResponse.copy(name = name)) } - override suspend fun revokeApiKey(keyId: String): Response { - shouldFailWithException?.let { throw it } - revokeHttpErrorCode?.let { - return Response.error(it, "Error".toResponseBody(null)) - } - httpErrorCode?.let { - return Response.error(it, "Error".toResponseBody(null)) - } - lastRevokedId = keyId - return Response.success(Unit) + override suspend fun revokeApiKey(id: String): Result { + lastRevokedId = id + return revokeApiKeyResult ?: Result.Success(Unit) } } - private class FakeMcpTokenDao : McpTokenDao { private val tokensState = MutableStateFlow>(emptyList()) val storedTokens: List get() = tokensState.value @@ -119,11 +99,11 @@ class McpRepositoryImplTest { } private fun buildRepository( - apiService: McpApiService = FakeMcpApiService(), + remoteDataSource: McpRemoteDataSource = FakeMcpRemoteDataSource(), tokenDao: McpTokenDao = FakeMcpTokenDao(), monitor: NetworkConnectivityMonitor = onlineMonitor, ) = McpRepositoryImpl( - mcpApiService = apiService, + remoteDataSource = remoteDataSource, mcpTokenDao = tokenDao, connectivityMonitor = monitor, ioDispatcher = testDispatcher, @@ -143,7 +123,7 @@ class McpRepositoryImplTest { @Test fun `getMcpTokens fetches remote api keys and updates Room atomically when online`() = runTest(testDispatcher) { - val apiService = FakeMcpApiService().apply { + val remoteDataSource = FakeMcpRemoteDataSource().apply { apiKeysList.add( ApiKeySummaryDto( id = "key-1", @@ -154,7 +134,7 @@ class McpRepositoryImplTest { ) } val tokenDao = FakeMcpTokenDao() - val repository = buildRepository(apiService = apiService, tokenDao = tokenDao, monitor = onlineMonitor) + val repository = buildRepository(remoteDataSource = remoteDataSource, tokenDao = tokenDao, monitor = onlineMonitor) val result = repository.getMcpTokens().first() @@ -194,9 +174,9 @@ class McpRepositoryImplTest { @Test fun `createMcpToken succeeds when online and upserts entity into Room`() = runTest(testDispatcher) { - val apiService = FakeMcpApiService() + val remoteDataSource = FakeMcpRemoteDataSource() val tokenDao = FakeMcpTokenDao() - val repository = buildRepository(apiService = apiService, tokenDao = tokenDao, monitor = onlineMonitor) + val repository = buildRepository(remoteDataSource = remoteDataSource, tokenDao = tokenDao, monitor = onlineMonitor) val result = repository.createMcpToken("Claude Desktop") @@ -204,16 +184,16 @@ class McpRepositoryImplTest { val createdToken = (result as Result.Success).data assertEquals("Claude Desktop", createdToken.name) assertEquals("raw-secret-123", createdToken.rawToken) - assertEquals("Claude Desktop", apiService.lastCreatedName) + assertEquals("Claude Desktop", remoteDataSource.lastCreatedName) assertEquals(1, tokenDao.storedTokens.size) assertEquals("token-1", tokenDao.storedTokens.first().id) } @Test fun `createMcpToken preserves raw token success even if Room write fails`() = runTest(testDispatcher) { - val apiService = FakeMcpApiService() + val remoteDataSource = FakeMcpRemoteDataSource() val tokenDao = FakeMcpTokenDao().apply { shouldFailOnUpsert = true } - val repository = buildRepository(apiService = apiService, tokenDao = tokenDao, monitor = onlineMonitor) + val repository = buildRepository(remoteDataSource = remoteDataSource, tokenDao = tokenDao, monitor = onlineMonitor) val result = repository.createMcpToken("Claude Desktop") @@ -235,7 +215,7 @@ class McpRepositoryImplTest { @Test fun `deleteMcpToken revokes token on network and deletes from Room`() = runTest(testDispatcher) { - val apiService = FakeMcpApiService() + val remoteDataSource = FakeMcpRemoteDataSource() val tokenDao = FakeMcpTokenDao().apply { upsertMcpTokens( listOf( @@ -243,12 +223,12 @@ class McpRepositoryImplTest { ) ) } - val repository = buildRepository(apiService = apiService, tokenDao = tokenDao, monitor = onlineMonitor) + val repository = buildRepository(remoteDataSource = remoteDataSource, tokenDao = tokenDao, monitor = onlineMonitor) val result = repository.deleteMcpToken("token-1") assertTrue(result is Result.Success) - assertEquals("token-1", apiService.lastRevokedId) + assertEquals("token-1", remoteDataSource.lastRevokedId) assertTrue(tokenDao.storedTokens.none { it.id == "token-1" }) } @@ -264,7 +244,7 @@ class McpRepositoryImplTest { @Test fun `regenerateMcpToken creates new token, revokes old token on network, and updates Room`() = runTest(testDispatcher) { - val apiService = FakeMcpApiService().apply { + val remoteDataSource = FakeMcpRemoteDataSource().apply { createApiKeyResponse = ApiKeyResponseDto( id = "token-2", name = "Claude Desktop", @@ -279,15 +259,15 @@ class McpRepositoryImplTest { ) ) } - val repository = buildRepository(apiService = apiService, tokenDao = tokenDao, monitor = onlineMonitor) + val repository = buildRepository(remoteDataSource = remoteDataSource, tokenDao = tokenDao, monitor = onlineMonitor) val result = repository.regenerateMcpToken("token-1") assertTrue(result is Result.Success) val createdToken = (result as Result.Success).data assertEquals("new-raw-secret", createdToken.rawToken) - assertEquals("Claude Desktop", apiService.lastCreatedName) - assertEquals("token-1", apiService.lastRevokedId) + assertEquals("Claude Desktop", remoteDataSource.lastCreatedName) + assertEquals("token-1", remoteDataSource.lastRevokedId) assertTrue(tokenDao.storedTokens.none { it.id == "token-1" }) assertEquals(1, tokenDao.storedTokens.size) assertEquals("token-2", tokenDao.storedTokens.first().id) @@ -295,14 +275,14 @@ class McpRepositoryImplTest { @Test fun `regenerateMcpToken keeps old Room token when revocation fails`() = runTest(testDispatcher) { - val apiService = FakeMcpApiService().apply { + val remoteDataSource = FakeMcpRemoteDataSource().apply { createApiKeyResponse = ApiKeyResponseDto( id = "token-2", name = "Claude Desktop", keyValue = "new-raw-secret", createdAt = "2026-08-11T01:00:00Z", ) - revokeHttpErrorCode = 500 + revokeApiKeyResult = Result.Error(AppError.Server(500)) } val tokenDao = FakeMcpTokenDao().apply { upsertMcpTokens( @@ -311,7 +291,7 @@ class McpRepositoryImplTest { ) ) } - val repository = buildRepository(apiService = apiService, tokenDao = tokenDao, monitor = onlineMonitor) + val repository = buildRepository(remoteDataSource = remoteDataSource, tokenDao = tokenDao, monitor = onlineMonitor) val result = repository.regenerateMcpToken("token-1") @@ -321,7 +301,7 @@ class McpRepositoryImplTest { } @Test fun `regenerateMcpToken preserves raw token success even if Room write fails`() = runTest(testDispatcher) { - val apiService = FakeMcpApiService().apply { + val remoteDataSource = FakeMcpRemoteDataSource().apply { createApiKeyResponse = ApiKeyResponseDto( id = "token-2", name = "Claude Desktop", @@ -330,7 +310,7 @@ class McpRepositoryImplTest { ) } val tokenDao = FakeMcpTokenDao().apply { shouldFailOnUpsert = true } - val repository = buildRepository(apiService = apiService, tokenDao = tokenDao, monitor = onlineMonitor) + val repository = buildRepository(remoteDataSource = remoteDataSource, tokenDao = tokenDao, monitor = onlineMonitor) val result = repository.regenerateMcpToken("token-1") diff --git a/core/data/src/test/java/com/awan/app/core/data/mcp/remote/McpRemoteDataSourceImplTest.kt b/core/data/src/test/java/com/awan/app/core/data/mcp/remote/McpRemoteDataSourceImplTest.kt new file mode 100644 index 00000000..8f5a7c49 --- /dev/null +++ b/core/data/src/test/java/com/awan/app/core/data/mcp/remote/McpRemoteDataSourceImplTest.kt @@ -0,0 +1,66 @@ +package com.awan.app.core.data.mcp.remote + +import com.awan.app.core.common.error.AppError +import com.awan.app.core.common.result.Result +import com.awan.app.core.network.api.McpApiService +import com.awan.app.core.network.dto.mcp.ApiKeyResponseDto +import com.awan.app.core.network.dto.mcp.ApiKeySummaryDto +import com.awan.app.core.network.dto.mcp.CreateApiKeyRequestDto +import java.io.IOException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +private class FakeMcpApiService : McpApiService { + var apiKeys = emptyList() + var getApiKeysError: Throwable? = null + + override suspend fun getApiKeys(): List { + getApiKeysError?.let { throw it } + return apiKeys + } + + override suspend fun createApiKey(request: CreateApiKeyRequestDto): ApiKeyResponseDto = + error("Not used by this test") + + override suspend fun revokeApiKey(keyId: String) = error("Not used by this test") +} + +@OptIn(ExperimentalCoroutinesApi::class) +class McpRemoteDataSourceImplTest { + + private val dispatcher = UnconfinedTestDispatcher() + + @Test + fun `getApiKeys returns the transport payload on success`() = runTest(dispatcher) { + val api = FakeMcpApiService().apply { + apiKeys = listOf( + ApiKeySummaryDto( + id = "key-1", + name = "Claude Desktop", + keyPrefix = "mcp_...123", + createdAt = "2026-08-11T00:00:00Z", + ), + ) + } + + val result = McpRemoteDataSourceImpl(api, Json, dispatcher).getApiKeys() + + assertTrue(result is Result.Success) + assertEquals("key-1", (result as Result.Success).data.single().id) + } + + @Test + fun `getApiKeys maps transport failures to AppError`() = runTest(dispatcher) { + val api = FakeMcpApiService().apply { getApiKeysError = IOException("offline") } + + val result = McpRemoteDataSourceImpl(api, Json, dispatcher).getApiKeys() + + assertTrue(result is Result.Error) + assertEquals(AppError.Network, (result as Result.Error).error) + } +} diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 75fa2c2c..63924adb 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -41,7 +41,7 @@ dependencies { implementation(project(":core:datastore")) // Networking - api(libs.retrofit) + implementation(libs.retrofit) implementation(libs.retrofit.converter.kotlinx.serialization) implementation(libs.okhttp) implementation(libs.okhttp.logging.interceptor) diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/api/McpApiService.kt b/core/network/src/main/kotlin/com/awan/app/core/network/api/McpApiService.kt index eecef632..ce795bb7 100644 --- a/core/network/src/main/kotlin/com/awan/app/core/network/api/McpApiService.kt +++ b/core/network/src/main/kotlin/com/awan/app/core/network/api/McpApiService.kt @@ -3,7 +3,6 @@ package com.awan.app.core.network.api import com.awan.app.core.network.dto.mcp.ApiKeyResponseDto import com.awan.app.core.network.dto.mcp.ApiKeySummaryDto import com.awan.app.core.network.dto.mcp.CreateApiKeyRequestDto -import retrofit2.Response import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET @@ -12,12 +11,12 @@ import retrofit2.http.Path interface McpApiService { @GET("v1/api-keys") - suspend fun getApiKeys(): Response> + suspend fun getApiKeys(): List @POST("v1/api-keys") - suspend fun createApiKey(@Body request: CreateApiKeyRequestDto): Response + suspend fun createApiKey(@Body request: CreateApiKeyRequestDto): ApiKeyResponseDto @DELETE("v1/api-keys/{keyId}") - suspend fun revokeApiKey(@Path("keyId") keyId: String): Response + suspend fun revokeApiKey(@Path("keyId") keyId: String) } diff --git a/docs/feature/mcp/2026-08-11-data-layer-transport-boundary.md b/docs/feature/mcp/2026-08-11-data-layer-transport-boundary.md new file mode 100644 index 00000000..fdbad1e3 --- /dev/null +++ b/docs/feature/mcp/2026-08-11-data-layer-transport-boundary.md @@ -0,0 +1,26 @@ +# MCP data-layer transport boundary + +## Summary + +Keep Retrofit internal to `:core:network`. MCP's repository will coordinate an +internal remote data source and Room; it will not depend on `McpApiService` or +Retrofit response types. + +## Changes + +- Restore Retrofit to an implementation dependency in `:core:network` and remove it from `:core:data`. +- Make `McpApiService` return bodies and `Unit`, so Retrofit handles non-2xx responses internally. +- Add an internal `McpRemoteDataSource` that maps API calls through `safeApiCall` and the shared JSON error mapper. +- Bind it in `McpDataModule` and inject it into `McpRepositoryImpl`. +- Preserve the public MCP repository/use-case API, Room cache behavior, offline guards, and one-time raw-token handling. + +## Verification + +- Update repository tests to use a remote-source fake. +- Add focused remote-source success and error-mapping tests. +- Run the MCP data tests, `:core:data:compileDebugKotlin`, `:app:assembleDebug`, and `git diff --check`. + +## Implementation notes (what actually differed) + +- Retrofit is now internal to `:core:network`; the MCP repository uses an internal remote data source that owns the Retrofit service and shared `safeApiCall` mapping. +- Verified with the full `:core:data:testDebugUnitTest` suite. `:app:assembleDebug` is blocked only because this new worktree excludes the existing local `app/google-services.json`. From 48fb207eba529ff3d7af62ba468dbac2ed6811ff Mon Sep 17 00:00:00 2001 From: "ZEIAD-LAPTOP\\zeiad" Date: Tue, 11 Aug 2026 16:15:04 +0300 Subject: [PATCH 2/2] revert database schema to version one --- .../1.json | 45 +- .../2.json | 968 ------------------ .../awan/app/core/database/AwanDatabase.kt | 2 +- .../app/core/database/di/DatabaseModule.kt | 20 - ...026-08-11-data-layer-transport-boundary.md | 1 + 5 files changed, 45 insertions(+), 991 deletions(-) delete mode 100644 core/database/schemas/com.awan.app.core.database.AwanDatabase/2.json diff --git a/core/database/schemas/com.awan.app.core.database.AwanDatabase/1.json b/core/database/schemas/com.awan.app.core.database.AwanDatabase/1.json index 5e324792..e568096d 100644 --- a/core/database/schemas/com.awan.app.core.database.AwanDatabase/1.json +++ b/core/database/schemas/com.awan.app.core.database.AwanDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "992ecd7c5630959737c670c515606372", + "identityHash": "9ec46d8c94844c2fe0b1333f20dc32d2", "entities": [ { "tableName": "users", @@ -917,11 +917,52 @@ "type" ] } + }, + { + "tableName": "mcp_tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `maskedToken` TEXT NOT NULL, `createdAt` TEXT NOT NULL, `lastUsedAt` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "maskedToken", + "columnName": "maskedToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUsedAt", + "columnName": "lastUsedAt", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '992ecd7c5630959737c670c515606372')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9ec46d8c94844c2fe0b1333f20dc32d2')" ] } } \ No newline at end of file diff --git a/core/database/schemas/com.awan.app.core.database.AwanDatabase/2.json b/core/database/schemas/com.awan.app.core.database.AwanDatabase/2.json deleted file mode 100644 index 5142afad..00000000 --- a/core/database/schemas/com.awan.app.core.database.AwanDatabase/2.json +++ /dev/null @@ -1,968 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 2, - "identityHash": "9ec46d8c94844c2fe0b1333f20dc32d2", - "entities": [ - { - "tableName": "users", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, `firstName` TEXT, `lastName` TEXT, `birthDate` TEXT, `points` INTEGER NOT NULL, `streak` INTEGER NOT NULL, `maxStreak` INTEGER NOT NULL, `expiryTime` INTEGER NOT NULL, `profilePictureUrl` TEXT, `isNew` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "email", - "columnName": "email", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "firstName", - "columnName": "firstName", - "affinity": "TEXT" - }, - { - "fieldPath": "lastName", - "columnName": "lastName", - "affinity": "TEXT" - }, - { - "fieldPath": "birthDate", - "columnName": "birthDate", - "affinity": "TEXT" - }, - { - "fieldPath": "points", - "columnName": "points", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "streak", - "columnName": "streak", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "maxStreak", - "columnName": "maxStreak", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTime", - "columnName": "expiryTime", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "profilePictureUrl", - "columnName": "profilePictureUrl", - "affinity": "TEXT" - }, - { - "fieldPath": "isNew", - "columnName": "isNew", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "user_preferences", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `timezone` TEXT NOT NULL, `preferredSessionDuration` INTEGER NOT NULL, `bufferBetweenSessions` INTEGER NOT NULL, `wakeupTime` TEXT NOT NULL, `sleepTime` TEXT NOT NULL, `schedulingType` TEXT NOT NULL, PRIMARY KEY(`userId`), FOREIGN KEY(`userId`) REFERENCES `users`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "userId", - "columnName": "userId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "timezone", - "columnName": "timezone", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "preferredSessionDuration", - "columnName": "preferredSessionDuration", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "bufferBetweenSessions", - "columnName": "bufferBetweenSessions", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "wakeupTime", - "columnName": "wakeupTime", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "sleepTime", - "columnName": "sleepTime", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "schedulingType", - "columnName": "schedulingType", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "userId" - ] - }, - "indices": [ - { - "name": "index_user_preferences_userId", - "unique": false, - "columnNames": [ - "userId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_user_preferences_userId` ON `${TABLE_NAME}` (`userId`)" - } - ], - "foreignKeys": [ - { - "table": "users", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "userId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "goals", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT, `status` TEXT NOT NULL, `targetDate` TEXT, `createdAt` TEXT NOT NULL, `isInbox` INTEGER NOT NULL, `expiryTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "title", - "columnName": "title", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "description", - "columnName": "description", - "affinity": "TEXT" - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "targetDate", - "columnName": "targetDate", - "affinity": "TEXT" - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "isInbox", - "columnName": "isInbox", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTime", - "columnName": "expiryTime", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "tasks", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT, `estimatedDuration` INTEGER NOT NULL, `status` TEXT NOT NULL, `mandatory` INTEGER NOT NULL, `estimatedPoints` INTEGER NOT NULL, `allowTaskSplitting` INTEGER NOT NULL, `goalId` TEXT, `categoryId` TEXT, `expiryTime` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`categoryId`) REFERENCES `categories`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "title", - "columnName": "title", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "description", - "columnName": "description", - "affinity": "TEXT" - }, - { - "fieldPath": "estimatedDuration", - "columnName": "estimatedDuration", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "mandatory", - "columnName": "mandatory", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "estimatedPoints", - "columnName": "estimatedPoints", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "allowTaskSplitting", - "columnName": "allowTaskSplitting", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "goalId", - "columnName": "goalId", - "affinity": "TEXT" - }, - { - "fieldPath": "categoryId", - "columnName": "categoryId", - "affinity": "TEXT" - }, - { - "fieldPath": "expiryTime", - "columnName": "expiryTime", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_tasks_goalId", - "unique": false, - "columnNames": [ - "goalId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_goalId` ON `${TABLE_NAME}` (`goalId`)" - }, - { - "name": "index_tasks_categoryId", - "unique": false, - "columnNames": [ - "categoryId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_tasks_categoryId` ON `${TABLE_NAME}` (`categoryId`)" - } - ], - "foreignKeys": [ - { - "table": "categories", - "onDelete": "NO ACTION", - "onUpdate": "NO ACTION", - "columns": [ - "categoryId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "task_dependencies", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`taskId` TEXT NOT NULL, `dependsOnTaskId` TEXT NOT NULL, PRIMARY KEY(`taskId`, `dependsOnTaskId`), FOREIGN KEY(`taskId`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`dependsOnTaskId`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "dependsOnTaskId", - "columnName": "dependsOnTaskId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "taskId", - "dependsOnTaskId" - ] - }, - "indices": [ - { - "name": "index_task_dependencies_taskId", - "unique": false, - "columnNames": [ - "taskId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_task_dependencies_taskId` ON `${TABLE_NAME}` (`taskId`)" - }, - { - "name": "index_task_dependencies_dependsOnTaskId", - "unique": false, - "columnNames": [ - "dependsOnTaskId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_task_dependencies_dependsOnTaskId` ON `${TABLE_NAME}` (`dependsOnTaskId`)" - } - ], - "foreignKeys": [ - { - "table": "tasks", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "taskId" - ], - "referencedColumns": [ - "id" - ] - }, - { - "table": "tasks", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "dependsOnTaskId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "templates", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `expiryTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "expiryTime", - "columnName": "expiryTime", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "template_days_of_week", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`dayOfWeek` TEXT NOT NULL, `templateId` TEXT NOT NULL, PRIMARY KEY(`dayOfWeek`), FOREIGN KEY(`templateId`) REFERENCES `templates`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "dayOfWeek", - "columnName": "dayOfWeek", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "templateId", - "columnName": "templateId", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "dayOfWeek" - ] - }, - "indices": [ - { - "name": "index_template_days_of_week_templateId", - "unique": false, - "columnNames": [ - "templateId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_template_days_of_week_templateId` ON `${TABLE_NAME}` (`templateId`)" - } - ], - "foreignKeys": [ - { - "table": "templates", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "templateId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "template_overrides", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `dateOfDay` TEXT NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "dateOfDay", - "columnName": "dateOfDay", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_template_overrides_dateOfDay", - "unique": true, - "columnNames": [ - "dateOfDay" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_template_overrides_dateOfDay` ON `${TABLE_NAME}` (`dateOfDay`)" - } - ] - }, - { - "tableName": "zones", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `startTime` TEXT NOT NULL, `endTime` TEXT NOT NULL, `color` TEXT, `templateId` TEXT, `templateOverrideId` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`templateId`) REFERENCES `templates`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`templateOverrideId`) REFERENCES `template_overrides`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "startTime", - "columnName": "startTime", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "endTime", - "columnName": "endTime", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "color", - "columnName": "color", - "affinity": "TEXT" - }, - { - "fieldPath": "templateId", - "columnName": "templateId", - "affinity": "TEXT" - }, - { - "fieldPath": "templateOverrideId", - "columnName": "templateOverrideId", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_zones_templateId", - "unique": false, - "columnNames": [ - "templateId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_zones_templateId` ON `${TABLE_NAME}` (`templateId`)" - }, - { - "name": "index_zones_templateOverrideId", - "unique": false, - "columnNames": [ - "templateOverrideId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_zones_templateOverrideId` ON `${TABLE_NAME}` (`templateOverrideId`)" - } - ], - "foreignKeys": [ - { - "table": "templates", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "templateId" - ], - "referencedColumns": [ - "id" - ] - }, - { - "table": "template_overrides", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "templateOverrideId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "categories", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `colorHex` TEXT, `icon` TEXT, `expiryTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "colorHex", - "columnName": "colorHex", - "affinity": "TEXT" - }, - { - "fieldPath": "icon", - "columnName": "icon", - "affinity": "TEXT" - }, - { - "fieldPath": "expiryTime", - "columnName": "expiryTime", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "sessions", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `taskId` TEXT NOT NULL, `zoneId` TEXT, `date` TEXT NOT NULL, `startTime` TEXT NOT NULL, `endTime` TEXT NOT NULL, `status` TEXT NOT NULL, `locked` INTEGER NOT NULL, `expiryTime` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`taskId`) REFERENCES `tasks`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "taskId", - "columnName": "taskId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "zoneId", - "columnName": "zoneId", - "affinity": "TEXT" - }, - { - "fieldPath": "date", - "columnName": "date", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "startTime", - "columnName": "startTime", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "endTime", - "columnName": "endTime", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "locked", - "columnName": "locked", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryTime", - "columnName": "expiryTime", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_sessions_taskId", - "unique": false, - "columnNames": [ - "taskId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_sessions_taskId` ON `${TABLE_NAME}` (`taskId`)" - }, - { - "name": "index_sessions_zoneId", - "unique": false, - "columnNames": [ - "zoneId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_sessions_zoneId` ON `${TABLE_NAME}` (`zoneId`)" - }, - { - "name": "index_sessions_date", - "unique": false, - "columnNames": [ - "date" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_sessions_date` ON `${TABLE_NAME}` (`date`)" - } - ], - "foreignKeys": [ - { - "table": "tasks", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "taskId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "cached_schedule_dates", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`date` TEXT NOT NULL, `lastSyncedAt` TEXT NOT NULL, `expiryTime` INTEGER NOT NULL, PRIMARY KEY(`date`))", - "fields": [ - { - "fieldPath": "date", - "columnName": "date", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastSyncedAt", - "columnName": "lastSyncedAt", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "expiryTime", - "columnName": "expiryTime", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "date" - ] - } - }, - { - "tableName": "store_items", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `image` TEXT NOT NULL, `info` TEXT, `price` INTEGER NOT NULL, `version` TEXT NOT NULL, `type` TEXT NOT NULL, `expiryTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "description", - "columnName": "description", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "image", - "columnName": "image", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "info", - "columnName": "info", - "affinity": "TEXT" - }, - { - "fieldPath": "price", - "columnName": "price", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "version", - "columnName": "version", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "expiryTime", - "columnName": "expiryTime", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "owned_items", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `itemId` TEXT NOT NULL, `boughtAt` TEXT NOT NULL, `expiryTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "boughtAt", - "columnName": "boughtAt", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "expiryTime", - "columnName": "expiryTime", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - }, - { - "tableName": "equipped_items", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` TEXT NOT NULL, `itemId` TEXT NOT NULL, `equippedAt` TEXT NOT NULL, `expiryTime` INTEGER NOT NULL, PRIMARY KEY(`type`))", - "fields": [ - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "itemId", - "columnName": "itemId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "equippedAt", - "columnName": "equippedAt", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "expiryTime", - "columnName": "expiryTime", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "type" - ] - } - }, - { - "tableName": "mcp_tokens", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `maskedToken` TEXT NOT NULL, `createdAt` TEXT NOT NULL, `lastUsedAt` TEXT, PRIMARY KEY(`id`))", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "maskedToken", - "columnName": "maskedToken", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "lastUsedAt", - "columnName": "lastUsedAt", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - } - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9ec46d8c94844c2fe0b1333f20dc32d2')" - ] - } -} \ No newline at end of file diff --git a/core/database/src/main/kotlin/com/awan/app/core/database/AwanDatabase.kt b/core/database/src/main/kotlin/com/awan/app/core/database/AwanDatabase.kt index 0a374545..74466721 100644 --- a/core/database/src/main/kotlin/com/awan/app/core/database/AwanDatabase.kt +++ b/core/database/src/main/kotlin/com/awan/app/core/database/AwanDatabase.kt @@ -56,7 +56,7 @@ import com.awan.app.core.database.model.ZoneEntity EquippedItemEntity::class, McpTokenEntity::class, ], - version = 2, + version = 1, exportSchema = true, ) abstract class AwanDatabase : RoomDatabase() { diff --git a/core/database/src/main/kotlin/com/awan/app/core/database/di/DatabaseModule.kt b/core/database/src/main/kotlin/com/awan/app/core/database/di/DatabaseModule.kt index 4828f5cc..503a23b0 100644 --- a/core/database/src/main/kotlin/com/awan/app/core/database/di/DatabaseModule.kt +++ b/core/database/src/main/kotlin/com/awan/app/core/database/di/DatabaseModule.kt @@ -2,8 +2,6 @@ package com.awan.app.core.database.di import android.content.Context import androidx.room.Room -import androidx.room.migration.Migration -import androidx.sqlite.db.SupportSQLiteDatabase import com.awan.app.core.database.AwanDatabase import com.awan.app.core.database.dao.CachedScheduleDateDao import com.awan.app.core.database.dao.CategoryDao @@ -33,23 +31,6 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) object DatabaseModule { - val MIGRATION_1_2 = object : Migration(1, 2) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL( - """ - CREATE TABLE IF NOT EXISTS `mcp_tokens` ( - `id` TEXT NOT NULL, - `name` TEXT NOT NULL, - `maskedToken` TEXT NOT NULL, - `createdAt` TEXT NOT NULL, - `lastUsedAt` TEXT, - PRIMARY KEY(`id`) - ) - """.trimIndent() - ) - } - } - @Provides @Singleton fun providesAwanDatabase( @@ -59,7 +40,6 @@ object DatabaseModule { AwanDatabase::class.java, "awan-database", ) - .addMigrations(MIGRATION_1_2) .fallbackToDestructiveMigration(dropAllTables = true) .build() diff --git a/docs/feature/mcp/2026-08-11-data-layer-transport-boundary.md b/docs/feature/mcp/2026-08-11-data-layer-transport-boundary.md index fdbad1e3..18253ca7 100644 --- a/docs/feature/mcp/2026-08-11-data-layer-transport-boundary.md +++ b/docs/feature/mcp/2026-08-11-data-layer-transport-boundary.md @@ -24,3 +24,4 @@ Retrofit response types. - Retrofit is now internal to `:core:network`; the MCP repository uses an internal remote data source that owns the Retrofit service and shared `safeApiCall` mapping. - Verified with the full `:core:data:testDebugUnitTest` suite. `:app:assembleDebug` is blocked only because this new worktree excludes the existing local `app/google-services.json`. +- Follow-up rollback keeps `AwanDatabase` at version 1, removes `MIGRATION_1_2`, deletes the v2 schema export, and regenerates schema version 1 with the MCP table.