diff --git a/.gitignore b/.gitignore index b0381a65..b3e53d5c 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ skills-lock.json .gemini/ .claude/ .artifacts/ +vm_repo.txt diff --git a/app/src/main/java/com/awan/app/AwanApp.kt b/app/src/main/java/com/awan/app/AwanApp.kt index 3c0de9e2..cbef905f 100644 --- a/app/src/main/java/com/awan/app/AwanApp.kt +++ b/app/src/main/java/com/awan/app/AwanApp.kt @@ -72,6 +72,8 @@ import com.awan.feature.onboarding.api.OnboardingRoute import com.awan.feature.onboarding.impl.navigation.onboardingEntry import com.awan.feature.profile.api.DailyZonesRoute import com.awan.feature.profile.api.EditRoutineRoute +import com.awan.feature.profile.api.McpInfoRoute +import com.awan.feature.profile.api.McpSettingsRoute import com.awan.feature.profile.impl.navigation.profileEntry import com.awan.feature.splash.api.SplashRoute import com.awan.feature.splash.impl.navigation.splashEntry @@ -250,7 +252,9 @@ fun AwanApp( onNavigateToEditRoutine = { templateId -> navigator.navigate(EditRoutineRoute(templateId)) }, onLogout = { navigator.replaceAll(LoginRoute) }, onBack = { navigator.goBack()}, - onNavigateToInventory = { navigator.navigate(InventoryRoute) } + onNavigateToInventory = { navigator.navigate(InventoryRoute) }, + onNavigateToMcpSettings = { navigator.navigate(McpSettingsRoute) }, + onNavigateToMcpInfo = { navigator.navigate(McpInfoRoute) }, ) goalPreviewEntry( onBack = { navigator.goBack() }, diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 3a9af14a..d701eb13 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) implementation(libs.okhttp) + 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 new file mode 100644 index 00000000..31521c11 --- /dev/null +++ b/core/data/src/main/kotlin/com/awan/app/core/data/mcp/di/McpDataModule.kt @@ -0,0 +1,20 @@ +package com.awan.app.core.data.mcp.di + +import com.awan.app.core.data.mcp.repository.McpRepositoryImpl +import com.awan.app.core.domain.mcp.repository.McpRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class McpDataModule { + + @Binds + @Singleton + abstract fun bindMcpRepository( + impl: McpRepositoryImpl, + ): McpRepository +} diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/mcp/mapper/McpMappers.kt b/core/data/src/main/kotlin/com/awan/app/core/data/mcp/mapper/McpMappers.kt new file mode 100644 index 00000000..99258558 --- /dev/null +++ b/core/data/src/main/kotlin/com/awan/app/core/data/mcp/mapper/McpMappers.kt @@ -0,0 +1,40 @@ +package com.awan.app.core.data.mcp.mapper + +import com.awan.app.core.database.model.McpTokenEntity +import com.awan.app.core.domain.mcp.model.CreatedMcpToken +import com.awan.app.core.domain.mcp.model.McpToken +import com.awan.app.core.network.dto.mcp.ApiKeyResponseDto +import com.awan.app.core.network.dto.mcp.ApiKeySummaryDto + +fun ApiKeySummaryDto.toEntity(): McpTokenEntity = McpTokenEntity( + id = id, + name = name, + maskedToken = keyPrefix, + createdAt = createdAt, + lastUsedAt = null, +) + +fun ApiKeyResponseDto.toDomain(): CreatedMcpToken = CreatedMcpToken( + id = id, + name = name, + rawToken = keyValue, + maskedToken = if (keyValue.length >= 12) keyValue.take(12) + "..." else keyValue, + createdAt = createdAt, +) + +fun ApiKeyResponseDto.toEntity(): McpTokenEntity = McpTokenEntity( + id = id, + name = name, + maskedToken = if (keyValue.length >= 12) keyValue.take(12) + "..." else keyValue, + createdAt = createdAt, + lastUsedAt = null, +) + +fun McpTokenEntity.toDomain(): McpToken = McpToken( + id = id, + name = name, + maskedToken = maskedToken, + createdAt = createdAt, + lastUsedAt = lastUsedAt, +) + 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 new file mode 100644 index 00000000..c2207889 --- /dev/null +++ b/core/data/src/main/kotlin/com/awan/app/core/data/mcp/repository/McpRepositoryImpl.kt @@ -0,0 +1,133 @@ +package com.awan.app.core.data.mcp.repository + +import com.awan.app.core.common.dispatcher.AwanDispatchers +import com.awan.app.core.common.dispatcher.Dispatcher +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.database.dao.McpTokenDao +import com.awan.app.core.domain.mcp.model.CreatedMcpToken +import com.awan.app.core.domain.mcp.model.McpConnectionDetails +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 kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +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, + private val mcpTokenDao: McpTokenDao, + private val connectivityMonitor: NetworkConnectivityMonitor, + @Dispatcher(AwanDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, +) : McpRepository { + + override fun getMcpConnectionDetails(): Flow> = flow { + emit( + Result.Success( + 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) + } + } catch (e: Exception) { + if (e is CancellationException) throw e + // If network sync fails, fallback to Room cached tokens + } + } + 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 + } + createdToken + } + } + + 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 (result is Result.Success) { + try { + mcpTokenDao.deleteMcpToken(id) + } catch (e: Exception) { + if (e is CancellationException) throw e + } + } + return result + } + + 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) + + 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 + } + createdToken + } + } +} + 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 new file mode 100644 index 00000000..a7f07364 --- /dev/null +++ b/core/data/src/test/java/com/awan/app/core/data/mcp/McpRepositoryImplTest.kt @@ -0,0 +1,342 @@ +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.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 +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 { + var apiKeysList = mutableListOf() + var createApiKeyResponse = ApiKeyResponseDto( + id = "token-1", + name = "Default Token", + keyValue = "raw-secret-123", + createdAt = "2026-08-11T00:00:00Z", + ) + var shouldFailWithException: Exception? = 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 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 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) + } +} + +private class FakeMcpTokenDao : McpTokenDao { + private val tokensState = MutableStateFlow>(emptyList()) + val storedTokens: List get() = tokensState.value + var shouldFailOnUpsert: Boolean = false + var replaceCount: Int = 0 + + override fun getMcpTokens(): Flow> = tokensState + + override suspend fun upsertMcpTokens(tokens: List) { + if (shouldFailOnUpsert) { + throw IllegalStateException("Database write error") + } + val current = tokensState.value.toMutableList() + tokens.forEach { newToken -> + current.removeAll { it.id == newToken.id } + current.add(newToken) + } + tokensState.value = current + } + + override suspend fun deleteMcpToken(id: String) { + tokensState.value = tokensState.value.filterNot { it.id == id } + } + + override suspend fun clearAll() { + tokensState.value = emptyList() + } + + override suspend fun replaceMcpTokens(tokens: List) { + replaceCount++ + clearAll() + upsertMcpTokens(tokens) + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +class McpRepositoryImplTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + private val onlineMonitor = object : NetworkConnectivityMonitor { + override val isOnline: Flow = flowOf(true) + override fun isCurrentlyOnline(): Boolean = true + } + + private val offlineMonitor = object : NetworkConnectivityMonitor { + override val isOnline: Flow = flowOf(false) + override fun isCurrentlyOnline(): Boolean = false + } + + private fun buildRepository( + apiService: McpApiService = FakeMcpApiService(), + tokenDao: McpTokenDao = FakeMcpTokenDao(), + monitor: NetworkConnectivityMonitor = onlineMonitor, + ) = McpRepositoryImpl( + mcpApiService = apiService, + mcpTokenDao = tokenDao, + connectivityMonitor = monitor, + ioDispatcher = testDispatcher, + ) + + @Test + fun `getMcpConnectionDetails returns static connection details`() = runTest(testDispatcher) { + val repository = buildRepository(monitor = offlineMonitor) + + val result = repository.getMcpConnectionDetails().first() + + assertTrue(result is Result.Success) + val details = (result as Result.Success).data + assertEquals("${BuildConfig.AWAN_BASE_URL.trimEnd('/')}/v1/mcp", details.mcpUrl) + assertEquals("awan-android-client", details.clientId) + } + + @Test + fun `getMcpTokens fetches remote api keys and updates Room atomically when online`() = runTest(testDispatcher) { + val apiService = FakeMcpApiService().apply { + apiKeysList.add( + ApiKeySummaryDto( + id = "key-1", + name = "Claude Desktop", + keyPrefix = "mcp_...123", + createdAt = "2026-08-11T00:00:00Z", + ) + ) + } + val tokenDao = FakeMcpTokenDao() + val repository = buildRepository(apiService = apiService, tokenDao = tokenDao, monitor = onlineMonitor) + + val result = repository.getMcpTokens().first() + + assertTrue(result is Result.Success) + val tokens = (result as Result.Success).data + assertEquals(1, tokens.size) + assertEquals("Claude Desktop", tokens.first().name) + assertEquals(1, tokenDao.storedTokens.size) + assertEquals("Claude Desktop", tokenDao.storedTokens.first().name) + assertEquals("mcp_...123", tokenDao.storedTokens.first().maskedToken) + assertEquals(1, tokenDao.replaceCount) + } + + @Test + fun `getMcpTokens falls back to Room cached tokens when offline`() = runTest(testDispatcher) { + val tokenDao = FakeMcpTokenDao().apply { + upsertMcpTokens( + listOf( + McpTokenEntity( + id = "token-cached", + name = "Cached Token", + maskedToken = "mcp_...cached", + createdAt = "2026-08-10T00:00:00Z", + ) + ) + ) + } + val repository = buildRepository(tokenDao = tokenDao, monitor = offlineMonitor) + + val result = repository.getMcpTokens().first() + + assertTrue(result is Result.Success) + val tokens = (result as Result.Success).data + assertEquals(1, tokens.size) + assertEquals("Cached Token", tokens.first().name) + } + + @Test + fun `createMcpToken succeeds when online and upserts entity into Room`() = runTest(testDispatcher) { + val apiService = FakeMcpApiService() + val tokenDao = FakeMcpTokenDao() + val repository = buildRepository(apiService = apiService, tokenDao = tokenDao, monitor = onlineMonitor) + + val result = repository.createMcpToken("Claude Desktop") + + assertTrue(result is Result.Success) + val createdToken = (result as Result.Success).data + assertEquals("Claude Desktop", createdToken.name) + assertEquals("raw-secret-123", createdToken.rawToken) + assertEquals("Claude Desktop", apiService.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 tokenDao = FakeMcpTokenDao().apply { shouldFailOnUpsert = true } + val repository = buildRepository(apiService = apiService, tokenDao = tokenDao, monitor = onlineMonitor) + + val result = repository.createMcpToken("Claude Desktop") + + assertTrue(result is Result.Success) + val createdToken = (result as Result.Success).data + assertEquals("Claude Desktop", createdToken.name) + assertEquals("raw-secret-123", createdToken.rawToken) + } + + @Test + fun `createMcpToken returns network error when offline`() = runTest(testDispatcher) { + val repository = buildRepository(monitor = offlineMonitor) + + val result = repository.createMcpToken("Claude Desktop") + + assertTrue(result is Result.Error) + assertTrue((result as Result.Error).error is AppError.Network) + } + + @Test + fun `deleteMcpToken revokes token on network and deletes from Room`() = runTest(testDispatcher) { + val apiService = FakeMcpApiService() + val tokenDao = FakeMcpTokenDao().apply { + upsertMcpTokens( + listOf( + McpTokenEntity("token-1", "Test", "mcp_...123", "2026-08-11T00:00:00Z") + ) + ) + } + val repository = buildRepository(apiService = apiService, tokenDao = tokenDao, monitor = onlineMonitor) + + val result = repository.deleteMcpToken("token-1") + + assertTrue(result is Result.Success) + assertEquals("token-1", apiService.lastRevokedId) + assertTrue(tokenDao.storedTokens.none { it.id == "token-1" }) + } + + @Test + fun `deleteMcpToken returns network error when offline`() = runTest(testDispatcher) { + val repository = buildRepository(monitor = offlineMonitor) + + val result = repository.deleteMcpToken("token-1") + + assertTrue(result is Result.Error) + assertTrue((result as Result.Error).error is AppError.Network) + } + + @Test + fun `regenerateMcpToken creates new token, revokes old token on network, and updates Room`() = runTest(testDispatcher) { + val apiService = FakeMcpApiService().apply { + createApiKeyResponse = ApiKeyResponseDto( + id = "token-2", + name = "Claude Desktop", + keyValue = "new-raw-secret", + createdAt = "2026-08-11T01:00:00Z", + ) + } + val tokenDao = FakeMcpTokenDao().apply { + upsertMcpTokens( + listOf( + McpTokenEntity("token-1", "Claude Desktop", "mcp_...old", "2026-08-11T00:00:00Z") + ) + ) + } + val repository = buildRepository(apiService = apiService, 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) + assertTrue(tokenDao.storedTokens.none { it.id == "token-1" }) + assertEquals(1, tokenDao.storedTokens.size) + assertEquals("token-2", tokenDao.storedTokens.first().id) + } + + @Test + fun `regenerateMcpToken keeps old Room token when revocation fails`() = runTest(testDispatcher) { + val apiService = FakeMcpApiService().apply { + createApiKeyResponse = ApiKeyResponseDto( + id = "token-2", + name = "Claude Desktop", + keyValue = "new-raw-secret", + createdAt = "2026-08-11T01:00:00Z", + ) + revokeHttpErrorCode = 500 + } + val tokenDao = FakeMcpTokenDao().apply { + upsertMcpTokens( + listOf( + McpTokenEntity("token-1", "Claude Desktop", "mcp_...old", "2026-08-11T00:00:00Z") + ) + ) + } + val repository = buildRepository(apiService = apiService, tokenDao = tokenDao, monitor = onlineMonitor) + + val result = repository.regenerateMcpToken("token-1") + + assertTrue(result is Result.Error) + assertEquals(1, tokenDao.storedTokens.size) + assertEquals("token-1", tokenDao.storedTokens.first().id) + } + @Test + fun `regenerateMcpToken preserves raw token success even if Room write fails`() = runTest(testDispatcher) { + val apiService = FakeMcpApiService().apply { + createApiKeyResponse = ApiKeyResponseDto( + id = "token-2", + name = "Claude Desktop", + keyValue = "new-raw-secret", + createdAt = "2026-08-11T01:00:00Z", + ) + } + val tokenDao = FakeMcpTokenDao().apply { shouldFailOnUpsert = true } + val repository = buildRepository(apiService = apiService, 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) + } +} + 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 new file mode 100644 index 00000000..5142afad --- /dev/null +++ b/core/database/schemas/com.awan.app.core.database.AwanDatabase/2.json @@ -0,0 +1,968 @@ +{ + "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 0695d6bc..0a374545 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 @@ -5,6 +5,7 @@ import androidx.room.RoomDatabase import com.awan.app.core.database.dao.CachedScheduleDateDao import com.awan.app.core.database.dao.CategoryDao import com.awan.app.core.database.dao.GoalDao +import com.awan.app.core.database.dao.McpTokenDao import com.awan.app.core.database.dao.SessionDao import com.awan.app.core.database.dao.StoreDao import com.awan.app.core.database.dao.TaskDao @@ -16,6 +17,7 @@ import com.awan.app.core.database.model.CachedScheduleDateEntity import com.awan.app.core.database.model.CategoryEntity import com.awan.app.core.database.model.EquippedItemEntity import com.awan.app.core.database.model.GoalEntity +import com.awan.app.core.database.model.McpTokenEntity import com.awan.app.core.database.model.OwnedItemEntity import com.awan.app.core.database.model.SessionEntity import com.awan.app.core.database.model.StoreItemEntity @@ -52,8 +54,9 @@ import com.awan.app.core.database.model.ZoneEntity StoreItemEntity::class, OwnedItemEntity::class, EquippedItemEntity::class, + McpTokenEntity::class, ], - version = 1, + version = 2, exportSchema = true, ) abstract class AwanDatabase : RoomDatabase() { @@ -77,4 +80,6 @@ abstract class AwanDatabase : RoomDatabase() { abstract fun cachedScheduleDateDao(): CachedScheduleDateDao abstract fun storeDao(): StoreDao + + abstract fun mcpTokenDao(): McpTokenDao } diff --git a/core/database/src/main/kotlin/com/awan/app/core/database/dao/McpTokenDao.kt b/core/database/src/main/kotlin/com/awan/app/core/database/dao/McpTokenDao.kt new file mode 100644 index 00000000..e66ace8c --- /dev/null +++ b/core/database/src/main/kotlin/com/awan/app/core/database/dao/McpTokenDao.kt @@ -0,0 +1,29 @@ +package com.awan.app.core.database.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Upsert +import com.awan.app.core.database.model.McpTokenEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface McpTokenDao { + @Query("SELECT * FROM mcp_tokens ORDER BY createdAt DESC") + fun getMcpTokens(): Flow> + + @Upsert + suspend fun upsertMcpTokens(tokens: List) + + @Query("DELETE FROM mcp_tokens WHERE id = :id") + suspend fun deleteMcpToken(id: String) + + @Query("DELETE FROM mcp_tokens") + suspend fun clearAll() + + @Transaction + suspend fun replaceMcpTokens(tokens: List) { + clearAll() + upsertMcpTokens(tokens) + } +} 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 27889191..4828f5cc 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,10 +2,13 @@ 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 import com.awan.app.core.database.dao.GoalDao +import com.awan.app.core.database.dao.McpTokenDao import com.awan.app.core.database.dao.SessionDao import com.awan.app.core.database.dao.StoreDao import com.awan.app.core.database.dao.TaskDao @@ -30,6 +33,23 @@ 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( @@ -39,6 +59,7 @@ object DatabaseModule { AwanDatabase::class.java, "awan-database", ) + .addMigrations(MIGRATION_1_2) .fallbackToDestructiveMigration(dropAllTables = true) .build() @@ -81,4 +102,8 @@ object DatabaseModule { @Provides fun providesStoreDao(database: AwanDatabase): StoreDao = database.storeDao() + + @Provides + fun providesMcpTokenDao(database: AwanDatabase): McpTokenDao = + database.mcpTokenDao() } diff --git a/core/database/src/main/kotlin/com/awan/app/core/database/model/McpTokenEntity.kt b/core/database/src/main/kotlin/com/awan/app/core/database/model/McpTokenEntity.kt new file mode 100644 index 00000000..9752977e --- /dev/null +++ b/core/database/src/main/kotlin/com/awan/app/core/database/model/McpTokenEntity.kt @@ -0,0 +1,13 @@ +package com.awan.app.core.database.model + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "mcp_tokens") +data class McpTokenEntity( + @PrimaryKey val id: String, + val name: String, + val maskedToken: String, + val createdAt: String, + val lastUsedAt: String? = null, +) diff --git a/core/design-system/src/main/java/com/awan/app/core/designsystem/AwanButton.kt b/core/design-system/src/main/java/com/awan/app/core/designsystem/AwanButton.kt index 209de7a0..e6e3a8d2 100644 --- a/core/design-system/src/main/java/com/awan/app/core/designsystem/AwanButton.kt +++ b/core/design-system/src/main/java/com/awan/app/core/designsystem/AwanButton.kt @@ -223,14 +223,15 @@ fun AwanButton( val minTouchPx = minTouchSize.roundToPx() val safeMaxWidth = constraints.maxWidth.coerceAtLeast(0) val safeMaxHeight = constraints.maxHeight.coerceAtLeast(0) - val minW = maxOf(constraints.minWidth, minTouchPx).coerceIn(0, safeMaxWidth) - val minH = maxOf(constraints.minHeight, minTouchPx).coerceIn(0, safeMaxHeight) + + val minW = if (safeMaxWidth > 0) maxOf(constraints.minWidth, minTouchPx).coerceAtMost(safeMaxWidth) else 0 + val minH = if (safeMaxHeight > 0) maxOf(constraints.minHeight, minTouchPx).coerceAtMost(safeMaxHeight) else 0 val safeConstraints = Constraints( minWidth = minW, - maxWidth = safeMaxWidth, + maxWidth = maxOf(safeMaxWidth, minW), minHeight = minH, - maxHeight = safeMaxHeight, + maxHeight = maxOf(safeMaxHeight, minH), ) val facePlaceable = measurables[1].measure(safeConstraints) diff --git a/core/design-system/src/main/java/com/awan/app/core/designsystem/AwanCard.kt b/core/design-system/src/main/java/com/awan/app/core/designsystem/AwanCard.kt index 8bfb64c2..bd324668 100644 --- a/core/design-system/src/main/java/com/awan/app/core/designsystem/AwanCard.kt +++ b/core/design-system/src/main/java/com/awan/app/core/designsystem/AwanCard.kt @@ -87,14 +87,16 @@ fun AwanCard( } ) { measurables, constraints -> // Face is measured first. Coerce constraints to be valid. - val minW = constraints.minWidth.coerceIn(0, constraints.maxWidth) - val minH = constraints.minHeight.coerceIn(0, constraints.maxHeight) + val safeMaxWidth = constraints.maxWidth.coerceAtLeast(0) + val safeMaxHeight = constraints.maxHeight.coerceAtLeast(0) + val minW = constraints.minWidth.coerceIn(0, safeMaxWidth) + val minH = constraints.minHeight.coerceIn(0, safeMaxHeight) val safeConstraints = Constraints( minWidth = minW, - maxWidth = constraints.maxWidth, + maxWidth = maxOf(safeMaxWidth, minW), minHeight = minH, - maxHeight = constraints.maxHeight + maxHeight = maxOf(safeMaxHeight, minH) ) val facePlaceable = measurables[1].measure(safeConstraints) diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts index 4e6c9bae..72510145 100644 --- a/core/domain/build.gradle.kts +++ b/core/domain/build.gradle.kts @@ -14,6 +14,7 @@ dependencies { implementation(libs.kotlinx.coroutines.core) compileOnly(libs.javax.inject) testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) // The parser's regexes run on Android's ICU engine, which the JVM suite cannot speak for. androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/model/CreatedMcpToken.kt b/core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/model/CreatedMcpToken.kt new file mode 100644 index 00000000..98aad99e --- /dev/null +++ b/core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/model/CreatedMcpToken.kt @@ -0,0 +1,9 @@ +package com.awan.app.core.domain.mcp.model + +data class CreatedMcpToken( + val id: String, + val name: String, + val rawToken: String, + val maskedToken: String, + val createdAt: String, +) diff --git a/core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/model/McpConnectionDetails.kt b/core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/model/McpConnectionDetails.kt new file mode 100644 index 00000000..e65b3cd4 --- /dev/null +++ b/core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/model/McpConnectionDetails.kt @@ -0,0 +1,6 @@ +package com.awan.app.core.domain.mcp.model + +data class McpConnectionDetails( + val mcpUrl: String, + val clientId: String, +) diff --git a/core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/model/McpToken.kt b/core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/model/McpToken.kt new file mode 100644 index 00000000..d36fa0e6 --- /dev/null +++ b/core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/model/McpToken.kt @@ -0,0 +1,9 @@ +package com.awan.app.core.domain.mcp.model + +data class McpToken( + val id: String, + val name: String, + val maskedToken: String, + val createdAt: String, + val lastUsedAt: String? = null, +) diff --git a/core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/repository/McpRepository.kt b/core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/repository/McpRepository.kt new file mode 100644 index 00000000..bee60bd8 --- /dev/null +++ b/core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/repository/McpRepository.kt @@ -0,0 +1,15 @@ +package com.awan.app.core.domain.mcp.repository + +import com.awan.app.core.common.result.Result +import com.awan.app.core.domain.mcp.model.CreatedMcpToken +import com.awan.app.core.domain.mcp.model.McpConnectionDetails +import com.awan.app.core.domain.mcp.model.McpToken +import kotlinx.coroutines.flow.Flow + +interface McpRepository { + fun getMcpConnectionDetails(): Flow> + fun getMcpTokens(): Flow>> + suspend fun createMcpToken(name: String): Result + suspend fun deleteMcpToken(id: String): Result + suspend fun regenerateMcpToken(id: String): Result +} diff --git a/core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/usecase/CreateMcpTokenUseCase.kt b/core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/usecase/CreateMcpTokenUseCase.kt new file mode 100644 index 00000000..70f0ce6f --- /dev/null +++ b/core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/usecase/CreateMcpTokenUseCase.kt @@ -0,0 +1,12 @@ +package com.awan.app.core.domain.mcp.usecase + +import com.awan.app.core.common.result.Result +import com.awan.app.core.domain.mcp.model.CreatedMcpToken +import com.awan.app.core.domain.mcp.repository.McpRepository +import javax.inject.Inject + +class CreateMcpTokenUseCase @Inject constructor( + private val repository: McpRepository, +) { + suspend operator fun invoke(name: String): Result = repository.createMcpToken(name) +} diff --git a/core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/usecase/DeleteMcpTokenUseCase.kt b/core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/usecase/DeleteMcpTokenUseCase.kt new file mode 100644 index 00000000..a86f1250 --- /dev/null +++ b/core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/usecase/DeleteMcpTokenUseCase.kt @@ -0,0 +1,11 @@ +package com.awan.app.core.domain.mcp.usecase + +import com.awan.app.core.common.result.Result +import com.awan.app.core.domain.mcp.repository.McpRepository +import javax.inject.Inject + +class DeleteMcpTokenUseCase @Inject constructor( + private val repository: McpRepository, +) { + suspend operator fun invoke(id: String): Result = repository.deleteMcpToken(id) +} diff --git a/core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/usecase/GetMcpConnectionDetailsUseCase.kt b/core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/usecase/GetMcpConnectionDetailsUseCase.kt new file mode 100644 index 00000000..a6d3a9c9 --- /dev/null +++ b/core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/usecase/GetMcpConnectionDetailsUseCase.kt @@ -0,0 +1,13 @@ +package com.awan.app.core.domain.mcp.usecase + +import com.awan.app.core.common.result.Result +import com.awan.app.core.domain.mcp.model.McpConnectionDetails +import com.awan.app.core.domain.mcp.repository.McpRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class GetMcpConnectionDetailsUseCase @Inject constructor( + private val repository: McpRepository, +) { + operator fun invoke(): Flow> = repository.getMcpConnectionDetails() +} diff --git a/core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/usecase/GetMcpTokensUseCase.kt b/core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/usecase/GetMcpTokensUseCase.kt new file mode 100644 index 00000000..d0956713 --- /dev/null +++ b/core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/usecase/GetMcpTokensUseCase.kt @@ -0,0 +1,13 @@ +package com.awan.app.core.domain.mcp.usecase + +import com.awan.app.core.common.result.Result +import com.awan.app.core.domain.mcp.model.McpToken +import com.awan.app.core.domain.mcp.repository.McpRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class GetMcpTokensUseCase @Inject constructor( + private val repository: McpRepository, +) { + operator fun invoke(): Flow>> = repository.getMcpTokens() +} diff --git a/core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/usecase/RegenerateMcpTokenUseCase.kt b/core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/usecase/RegenerateMcpTokenUseCase.kt new file mode 100644 index 00000000..670ab118 --- /dev/null +++ b/core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/usecase/RegenerateMcpTokenUseCase.kt @@ -0,0 +1,12 @@ +package com.awan.app.core.domain.mcp.usecase + +import com.awan.app.core.common.result.Result +import com.awan.app.core.domain.mcp.model.CreatedMcpToken +import com.awan.app.core.domain.mcp.repository.McpRepository +import javax.inject.Inject + +class RegenerateMcpTokenUseCase @Inject constructor( + private val repository: McpRepository, +) { + suspend operator fun invoke(id: String): Result = repository.regenerateMcpToken(id) +} diff --git a/core/domain/src/test/kotlin/com/awan/app/core/domain/mcp/usecase/McpUseCasesTest.kt b/core/domain/src/test/kotlin/com/awan/app/core/domain/mcp/usecase/McpUseCasesTest.kt new file mode 100644 index 00000000..10f78230 --- /dev/null +++ b/core/domain/src/test/kotlin/com/awan/app/core/domain/mcp/usecase/McpUseCasesTest.kt @@ -0,0 +1,147 @@ +package com.awan.app.core.domain.mcp.usecase + +import com.awan.app.core.common.error.AppError +import com.awan.app.core.common.result.Result +import com.awan.app.core.domain.mcp.model.CreatedMcpToken +import com.awan.app.core.domain.mcp.model.McpConnectionDetails +import com.awan.app.core.domain.mcp.model.McpToken +import com.awan.app.core.domain.mcp.repository.McpRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class McpUseCasesTest { + + private lateinit var fakeRepository: FakeMcpRepository + private lateinit var getMcpConnectionDetailsUseCase: GetMcpConnectionDetailsUseCase + private lateinit var getMcpTokensUseCase: GetMcpTokensUseCase + private lateinit var createMcpTokenUseCase: CreateMcpTokenUseCase + private lateinit var deleteMcpTokenUseCase: DeleteMcpTokenUseCase + private lateinit var regenerateMcpTokenUseCase: RegenerateMcpTokenUseCase + + @Before + fun setUp() { + fakeRepository = FakeMcpRepository() + getMcpConnectionDetailsUseCase = GetMcpConnectionDetailsUseCase(fakeRepository) + getMcpTokensUseCase = GetMcpTokensUseCase(fakeRepository) + createMcpTokenUseCase = CreateMcpTokenUseCase(fakeRepository) + deleteMcpTokenUseCase = DeleteMcpTokenUseCase(fakeRepository) + regenerateMcpTokenUseCase = RegenerateMcpTokenUseCase(fakeRepository) + } + + @Test + fun `GetMcpConnectionDetailsUseCase returns connection details from repository`() = runTest { + val result = getMcpConnectionDetailsUseCase().first() + assertTrue(result is Result.Success) + val data = (result as Result.Success).data + assertEquals("https://api.awan.app/mcp", data.mcpUrl) + assertEquals("client-123", data.clientId) + } + + @Test + fun `GetMcpTokensUseCase returns list of tokens from repository`() = runTest { + val result = getMcpTokensUseCase().first() + assertTrue(result is Result.Success) + val tokens = (result as Result.Success).data + assertEquals(1, tokens.size) + assertEquals("Claude Desktop", tokens.first().name) + } + + @Test + fun `CreateMcpTokenUseCase succeeds and returns created token`() = runTest { + val result = createMcpTokenUseCase("Cursor") + assertTrue(result is Result.Success) + val created = (result as Result.Success).data + assertEquals("Cursor", created.name) + assertEquals("raw_secret_token_123", created.rawToken) + } + + @Test + fun `CreateMcpTokenUseCase propagates repository error`() = runTest { + fakeRepository.shouldReturnError = true + val result = createMcpTokenUseCase("Error Token") + assertTrue(result is Result.Error) + assertEquals(AppError.Network, (result as Result.Error).error) + } + + @Test + fun `DeleteMcpTokenUseCase succeeds when deleting valid token`() = runTest { + val result = deleteMcpTokenUseCase("token-1") + assertTrue(result is Result.Success) + } + + @Test + fun `RegenerateMcpTokenUseCase succeeds and returns new created token`() = runTest { + val result = regenerateMcpTokenUseCase("token-1") + assertTrue(result is Result.Success) + val created = (result as Result.Success).data + assertEquals("token-1", created.id) + assertEquals("raw_regenerated_token_456", created.rawToken) + } + + private class FakeMcpRepository : McpRepository { + var shouldReturnError = false + + override fun getMcpConnectionDetails(): Flow> { + return flowOf( + Result.Success( + McpConnectionDetails( + mcpUrl = "https://api.awan.app/mcp", + clientId = "client-123", + ), + ), + ) + } + + override fun getMcpTokens(): Flow>> { + return flowOf( + Result.Success( + listOf( + McpToken( + id = "token-1", + name = "Claude Desktop", + maskedToken = "••••••••abc123", + createdAt = "2026-08-11T00:00:00Z", + ), + ), + ), + ) + } + + override suspend fun createMcpToken(name: String): Result { + if (shouldReturnError) return Result.Error(AppError.Network) + return Result.Success( + CreatedMcpToken( + id = "token-new", + name = name, + rawToken = "raw_secret_token_123", + maskedToken = "••••••••token_123", + createdAt = "2026-08-11T00:00:00Z", + ), + ) + } + + override suspend fun deleteMcpToken(id: String): Result { + if (shouldReturnError) return Result.Error(AppError.Network) + return Result.Success(Unit) + } + + override suspend fun regenerateMcpToken(id: String): Result { + if (shouldReturnError) return Result.Error(AppError.Network) + return Result.Success( + CreatedMcpToken( + id = id, + name = "Regenerated Token", + rawToken = "raw_regenerated_token_456", + maskedToken = "••••••••token_456", + createdAt = "2026-08-11T00:00:00Z", + ), + ) + } + } +} diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 63924adb..75fa2c2c 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -41,7 +41,7 @@ dependencies { implementation(project(":core:datastore")) // Networking - implementation(libs.retrofit) + api(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 new file mode 100644 index 00000000..eecef632 --- /dev/null +++ b/core/network/src/main/kotlin/com/awan/app/core/network/api/McpApiService.kt @@ -0,0 +1,23 @@ +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 +import retrofit2.http.POST +import retrofit2.http.Path + +interface McpApiService { + @GET("v1/api-keys") + suspend fun getApiKeys(): Response> + + @POST("v1/api-keys") + suspend fun createApiKey(@Body request: CreateApiKeyRequestDto): Response + + @DELETE("v1/api-keys/{keyId}") + suspend fun revokeApiKey(@Path("keyId") keyId: String): Response +} + diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/di/NetworkModule.kt b/core/network/src/main/kotlin/com/awan/app/core/network/di/NetworkModule.kt index c9a00116..7c5decc4 100644 --- a/core/network/src/main/kotlin/com/awan/app/core/network/di/NetworkModule.kt +++ b/core/network/src/main/kotlin/com/awan/app/core/network/di/NetworkModule.kt @@ -2,6 +2,7 @@ package com.awan.app.core.network.di import android.content.Context import com.awan.app.core.network.BuildConfig +import com.awan.app.core.network.api.McpApiService import com.awan.app.core.network.api.AuthApiService import com.awan.app.core.network.api.CategoryApiService import com.awan.app.core.network.api.GoalApiService @@ -176,6 +177,11 @@ object NetworkModule { fun providesGoalApiService(retrofit: Retrofit): GoalApiService = retrofit.create(GoalApiService::class.java) + @Provides + @Singleton + fun providesMcpApiService(retrofit: Retrofit): McpApiService = + retrofit.create(McpApiService::class.java) + @Provides @Singleton fun providesDeviceIdProvider( diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/ApiKeyResponseDto.kt b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/ApiKeyResponseDto.kt new file mode 100644 index 00000000..3a14f2a1 --- /dev/null +++ b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/ApiKeyResponseDto.kt @@ -0,0 +1,12 @@ +package com.awan.app.core.network.dto.mcp + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ApiKeyResponseDto( + @SerialName("id") val id: String, + @SerialName("name") val name: String, + @SerialName("keyValue") val keyValue: String, + @SerialName("createdAt") val createdAt: String, +) diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/ApiKeySummaryDto.kt b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/ApiKeySummaryDto.kt new file mode 100644 index 00000000..391855bd --- /dev/null +++ b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/ApiKeySummaryDto.kt @@ -0,0 +1,12 @@ +package com.awan.app.core.network.dto.mcp + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ApiKeySummaryDto( + @SerialName("id") val id: String, + @SerialName("name") val name: String, + @SerialName("keyPrefix") val keyPrefix: String, + @SerialName("createdAt") val createdAt: String, +) diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreateApiKeyRequestDto.kt b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreateApiKeyRequestDto.kt new file mode 100644 index 00000000..85875be9 --- /dev/null +++ b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreateApiKeyRequestDto.kt @@ -0,0 +1,9 @@ +package com.awan.app.core.network.dto.mcp + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CreateApiKeyRequestDto( + @SerialName("name") val name: String, +) diff --git a/docs/feature/mcp/2026-08-11-deep-review-remediation-plan.md b/docs/feature/mcp/2026-08-11-deep-review-remediation-plan.md new file mode 100644 index 00000000..bf46d542 --- /dev/null +++ b/docs/feature/mcp/2026-08-11-deep-review-remediation-plan.md @@ -0,0 +1,321 @@ +# MCP Integration Deep Review Remediation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Resolve all 11 findings identified by `/deep-review` on branch `feature/AWAN-210-mcp-settings`, covering database transaction safety, raw token preservation on DB failure, ViewModel event collection, state hoisting, `LocalClipboardManager` adoption, accessibility localization, and Compose design system token compliance. + +**Architecture:** Now in Android (NiA) Clean Architecture (`presentation → domain ← data`). Fixes touch `:core:database`, `:core:data`, `:feature:profile:api`, and `:feature:profile:impl`. + +**Tech Stack:** Kotlin 2.4.0, Jetpack Compose BOM 2026.06.01, Compose Styles API, Room Database v2, Hilt DI, Navigation 3. + +--- + +## Global Constraints + +- **Branch:** Work on `feature/AWAN-210-mcp-settings`. +- **Clean Architecture:** ViewModels consume domain use cases ONLY. Repository interfaces stay in `:core:domain`. +- **Localization:** All user-facing strings and content descriptions must be localized in `strings.xml` (EN & AR) with `profile_mcp_*` prefix. +- **Styling:** Replace all hardcoded `16.dp`, `12.dp`, `8.dp`, `4.dp` with `AwanTheme.spacing` tokens (`spacing.xs`, `spacing.sm`, `spacing.md`, `spacing.lg`). +- **Security:** Ensure raw token returned by backend is NEVER lost due to local database cache failures, and display raw token strictly once in `CreatedTokenModal`. + +--- + +## Plan Overview & Remediation Strategy + +```mermaid +graph TD + A[Deep Review Findings] --> B[Task 1: Core Data & Database Security/Transaction Safety] + A --> C[Task 2: ViewModel State Hoisting & Event Collection] + A --> D[Task 3: Compose UI, LocalClipboardManager & Accessibility Localization] + A --> E[Task 4: Design System Tokens & Mapping Extensions] + + B --> F[Atomic Room Transaction & Cancellation Exception Handling] + C --> G[State Hoisting in McpSettingsState & ObserveAsEvents Collection] + D --> H[LocalClipboardManager + Localized Content Descriptions] + E --> I[AwanTheme.spacing & DTO/Entity Mapper Extensions] +``` + +--- + +## Task Decomposition + +### Task 1: Core Data & Database Security / Transaction Safety (Must Fix #1, #2, #4 & Consider #11) + +**Files:** +- Modify: `core/database/src/main/kotlin/com/awan/app/core/database/dao/McpTokenDao.kt` +- Modify: `core/data/src/main/kotlin/com/awan/app/core/data/mcp/repository/McpRepositoryImpl.kt` +- Create: `core/data/src/main/kotlin/com/awan/app/core/data/mcp/mapper/McpMappers.kt` +- Modify: `core/data/src/test/java/com/awan/app/core/data/mcp/McpRepositoryImplTest.kt` + +**Interfaces:** +- Consumes: `McpTokenDao`, `McpApiService`. +- Produces: Atomic transaction cache updates, non-blocking raw token delivery on local DB write failure, and re-thrown `CancellationException`. + +- [ ] **Step 1: Add `@Transaction` atomic cache refresh function to `McpTokenDao.kt`** + +```kotlin +@Dao +interface McpTokenDao { + @Query("SELECT * FROM mcp_tokens ORDER BY createdAt DESC") + fun getMcpTokens(): Flow> + + @Upsert + suspend fun upsertMcpTokens(tokens: List) + + @Query("DELETE FROM mcp_tokens WHERE id = :id") + suspend fun deleteMcpToken(id: String) + + @Query("DELETE FROM mcp_tokens") + suspend fun clearAll() + + @Transaction + suspend fun replaceMcpTokens(tokens: List) { + clearAll() + upsertMcpTokens(tokens) + } +} +``` + +- [ ] **Step 2: Create DTO & Entity Mapper extension functions in `core/data/src/main/kotlin/com/awan/app/core/data/mcp/mapper/McpMappers.kt`** + +```kotlin +fun McpTokenResponseDto.toEntity(): McpTokenEntity = McpTokenEntity( + id = id, + name = name, + maskedToken = maskedToken, + createdAt = createdAt, + lastUsedAt = lastUsedAt +) + +fun CreatedMcpTokenResponseDto.toEntity(): McpTokenEntity = McpTokenEntity( + id = id, + name = name, + maskedToken = maskedToken, + createdAt = createdAt, + lastUsedAt = null +) + +fun McpTokenEntity.toDomain(): McpToken = McpToken( + id = id, + name = name, + maskedToken = maskedToken, + createdAt = createdAt, + lastUsedAt = lastUsedAt +) +``` + +- [ ] **Step 3: Fix `McpRepositoryImpl.kt` cache sync & raw token preservation** + +1. Replace `clearAll()` + `upsertMcpTokens()` with atomic `mcpTokenDao.replaceMcpTokens(entities)`. +2. In `createMcpToken` and `regenerateMcpToken`, call API first to receive `CreatedMcpTokenResponseDto`. Then, attempt Room `upsertMcpTokens` inside a `try-catch`. If Room upsert fails, log/swallow the local cache error so that the returned `Result.Success(createdToken)` containing the one-time raw token is **never dropped**. +3. In catch blocks, re-throw `CancellationException`: `if (e is CancellationException) throw e`. + +- [ ] **Step 4: Verify Repository Unit Tests** + +```bash +./gradlew :core:data:testDebugUnitTest +``` + +- [ ] **Step 5: Commit Task 1** + +```bash +git add core/database/ core/data/ +git commit -m "AWAN-210: Fix Room atomic transaction, preserve one-time raw tokens on DB failure, and rethrow CancellationException" +``` + +--- + +### Task 2: ViewModel State Hoisting & Event Collection (Must Fix #3 & Should Fix #5) + +**Files:** +- Modify: `feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsState.kt` +- Modify: `feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsAction.kt` +- Modify: `feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsViewModel.kt` +- Modify: `feature/profile/impl/src/main/java/com/awan/feature/profile/impl/navigation/McpSettingsRouteScreen.kt` +- Modify: `feature/profile/impl/src/test/java/com/awan/feature/profile/impl/presentation/McpSettingsViewModelTest.kt` + +**Interfaces:** +- Consumes: `McpSettingsState`, `McpSettingsAction`, `McpSettingsViewModel`. +- Produces: Fully state-hoisted UI state flow (surviving configuration changes) and consumed event channel in `McpSettingsRouteScreen`. + +- [ ] **Step 1: Hoist UI Dialog State into `McpSettingsState` and `McpSettingsAction`** + +```kotlin +data class McpSettingsState( + val connectionDetails: McpConnectionDetails? = null, + val tokens: List = emptyList(), + val createdToken: CreatedMcpToken? = null, + val showAddTokenDialog: Boolean = false, + val newTokenName: String = "", + val deletingToken: McpToken? = null, + val regeneratingToken: McpToken? = null, + val isLoading: Boolean = false, + val isCreating: Boolean = false, + val userMessage: String? = null, +) + +sealed interface McpSettingsAction { + data object ShowAddTokenDialog : McpSettingsAction + data object HideAddTokenDialog : McpSettingsAction + data class UpdateNewTokenName(val name: String) : McpSettingsAction + data class ShowDeleteDialog(val token: McpToken) : McpSettingsAction + data object HideDeleteDialog : McpSettingsAction + data class ShowRegenerateDialog(val token: McpToken) : McpSettingsAction + data object HideRegenerateDialog : McpSettingsAction + data class CreateToken(val name: String) : McpSettingsAction + data class DeleteToken(val id: String) : McpSettingsAction + data class RegenerateToken(val id: String) : McpSettingsAction + data object DismissCreatedModal : McpSettingsAction + data object DismissError : McpSettingsAction + data object Refresh : McpSettingsAction +} +``` + +- [ ] **Step 2: Update `McpSettingsViewModel.kt` to handle new state hoisting actions** + +- [ ] **Step 3: Collect ViewModel Events in `McpSettingsRouteScreen.kt`** + +```kotlin +@Composable +fun McpSettingsRouteScreen( + onNavigateToInfo: () -> Unit, + onBack: () -> Unit, + viewModel: McpSettingsViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + ObserveAsEvents(viewModel.events) { event -> + when (event) { + is McpSettingsEvent.Error -> { + Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() + } + McpSettingsEvent.TokenCreated -> {} + McpSettingsEvent.TokenDeleted -> {} + McpSettingsEvent.TokenRegenerated -> {} + } + } + + McpSettingsScreen( + uiState = uiState, + onAction = viewModel::onAction, + onInfoClick = onNavigateToInfo, + onBackClick = onBack + ) +} +``` + +- [ ] **Step 4: Verify ViewModel Unit Tests** + +```bash +./gradlew :feature:profile:impl:testDebugUnitTest --tests "com.awan.feature.profile.impl.presentation.McpSettingsViewModelTest" +``` + +- [ ] **Step 5: Commit Task 2** + +```bash +git add feature/profile/impl/ +git commit -m "AWAN-210: Hoist UI dialog state to ViewModel and collect events in McpSettingsRouteScreen" +``` + +--- + +### Task 3: Compose UI, LocalClipboardManager & Accessibility Localization (Should Fix #6, #7, #8, #9) + +**Files:** +- Modify: `feature/profile/impl/src/main/res/values/strings.xml` +- Modify: `feature/profile/impl/src/main/res/values-ar/strings.xml` +- Modify: `feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/McpSettingsScreen.kt` +- Modify: `feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/McpInfoScreen.kt` +- Modify: `feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/components/CreatedTokenModal.kt` + +**Interfaces:** +- Consumes: Localized string resources, `LocalClipboardManager`. +- Produces: Accessible, localized Compose UI using native `LocalClipboardManager.current`. + +- [ ] **Step 1: Add Localized Strings for Content Descriptions & Formatted Punctuation** + +In `values/strings.xml`: +```xml +MCP Setup Info +Copy MCP Server URL +Copy OAuth Client ID +Copy Configuration Snippet +Delete Token %1$s +Regenerate Token %1$s +%1$s. %2$s +``` + +In `values-ar/strings.xml`: +```xml +معلومات إعداد MCP +نسخ رابط خادم MCP +نسخ معرف العميل +نسخ البرمجية النصية للتكوين +حذف الرمز %1$s +إعادة إنشاء الرمز %1$s +%1$s. %2$s +``` + +- [ ] **Step 2: Replace Context Clipboard with `LocalClipboardManager.current`** + +In `CreatedTokenModal.kt`, `McpSettingsScreen.kt`, `McpInfoScreen.kt`: +```kotlin +val clipboardManager = LocalClipboardManager.current +// On copy click: +clipboardManager.setText(AnnotatedString(textToCopy)) +``` + +- [ ] **Step 3: Update `McpSettingsScreen.kt` & `McpInfoScreen.kt` content descriptions & localized formatting** + +- [ ] **Step 4: Commit Task 3** + +```bash +git add feature/profile/impl/ +git commit -m "AWAN-210: Use LocalClipboardManager, add localized content descriptions, and format string punctuation" +``` + +--- + +### Task 4: Design System Tokens & Component Refactoring (Consider #10) + +**Files:** +- Modify: `feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/McpSettingsScreen.kt` +- Modify: `feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/McpInfoScreen.kt` +- Modify: `feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/components/CreatedTokenModal.kt` + +**Interfaces:** +- Consumes: `AwanTheme.spacing` tokens (`spacing.xs`, `spacing.sm`, `spacing.md`, `spacing.lg`), `AwanCard`. +- Produces: Design-system compliant composables without hardcoded pixel dimensions or manual card borders. + +- [ ] **Step 1: Replace hardcoded `16.dp`, `12.dp`, `8.dp`, `4.dp` with `AwanTheme.spacing` tokens** +- Replace `16.dp` -> `AwanTheme.spacing.md` +- Replace `12.dp` -> `AwanTheme.spacing.sm` (or `spacing.md`) +- Replace `8.dp` -> `AwanTheme.spacing.xs` +- Replace `20.dp` -> `AwanTheme.spacing.lg` + +- [ ] **Step 2: Refactor reinvented bordered boxes to use `AwanCard` / `AwanTheme` surfaces** + +- [ ] **Step 3: Commit Task 4** + +```bash +git add feature/profile/impl/ +git commit -m "AWAN-210: Refactor MCP UI to use AwanTheme spacing tokens and AwanCard design components" +``` + +--- + +## Verification Plan + +### Automated Tests +- Core Domain Unit Tests: `./gradlew :core:domain:testDebugUnitTest` +- Core Data Unit Tests: `./gradlew :core:data:testDebugUnitTest` +- Profile Feature Unit Tests: `./gradlew :feature:profile:impl:testDebugUnitTest` +- Full project build, tests & lint: `./gradlew assembleDebug testDebugUnitTest lint` + +### Manual Verification +1. Open app, navigate to **Profile -> Settings -> MCP Integration**. +2. Rotate device on `McpSettingsScreen` while "Add Token" dialog is open or token name is typed: verify dialog and typed text survive rotation. +3. Test copying MCP URL, Client ID, raw token, and code snippets: verify toast appears and clipboard contains copied text via `LocalClipboardManager`. +4. Enable TalkBack / Accessibility inspector: verify all copy/info/action buttons announce localized content descriptions. +5. Trigger token creation: verify one-time raw token modal displays correctly with `AwanTheme.spacing` tokens. diff --git a/docs/feature/mcp/2026-08-11-mcp-bottom-sheets-plan.md b/docs/feature/mcp/2026-08-11-mcp-bottom-sheets-plan.md new file mode 100644 index 00000000..999a7721 --- /dev/null +++ b/docs/feature/mcp/2026-08-11-mcp-bottom-sheets-plan.md @@ -0,0 +1,44 @@ +# MCP Bottom Sheets Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Convert token creation input (Add Token) and token reveal/copy (Created Token Modal) dialogs into Material 3 `ModalBottomSheet` containers, while keeping delete warning and regenerate confirmation dialogs as `AwanDialog` alert popups. + +**Architecture:** Presentation UI refactoring in `:feature:profile:impl`. + +--- + +## Proposed Changes + +### Feature Profile UI (`:feature:profile:impl`) + +#### [MODIFY] `McpSettingsScreen.kt` + +- Replace `Dialog` for `uiState.showAddTokenDialog` with `ModalBottomSheet`: + - Use `@OptIn(ExperimentalMaterial3Api::class)`. + - Pass `onDismissRequest = { onAction(McpSettingsAction.HideAddTokenDialog) }`. + - Style sheet container with `AwanTheme.colors.surface`, horizontal padding `AwanTheme.spacing.lg`, and bottom padding `AwanTheme.spacing.xl`. +- Preserve `AwanDialog` for `uiState.deletingToken` (delete confirmation). +- Preserve `AwanDialog` for `uiState.regeneratingToken` (regenerate confirmation). + +#### [MODIFY] `CreatedTokenModal.kt` + +- Replace `Dialog` with `ModalBottomSheet`: + - Use `@OptIn(ExperimentalMaterial3Api::class)`. + - Pass `onDismissRequest = onDismiss` and `rememberModalBottomSheetState(skipPartiallyExpanded = true)`. + - Render token copy details, warning banner, and copy button inside bottom sheet layout. + +--- + +## Verification Plan + +### Automated Tests +- Feature profile unit tests: `./gradlew :feature:profile:impl:testDebugUnitTest` +- Full project build & lint: `./gradlew assembleDebug testDebugUnitTest lint` + +### Manual Verification +1. Open app, navigate to **Profile -> Settings -> MCP Integration**. +2. Click **Add Token**: verify bottom sheet slides up with text field to enter token name. +3. Submit token name: verify bottom sheet dismisses and **Created Token Bottom Sheet** slides up displaying the raw token and Copy button. +4. Regenerate a token: click regenerate in row -> verify confirmation popup appears as **AwanDialog** -> click confirm -> verify **Created Token Bottom Sheet** slides up displaying new raw token. +5. Delete a token: click delete in row -> verify delete confirmation popup remains an **AwanDialog** alert. diff --git a/docs/feature/mcp/2026-08-11-mcp-dialog-crash-fix-plan.md b/docs/feature/mcp/2026-08-11-mcp-dialog-crash-fix-plan.md new file mode 100644 index 00000000..4e4764ed --- /dev/null +++ b/docs/feature/mcp/2026-08-11-mcp-dialog-crash-fix-plan.md @@ -0,0 +1,78 @@ +# MCP Dialog Crash Fix & Padding Optimization Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Resolve the `java.lang.IllegalArgumentException: maxWidth must be >= than minWidth` crash occurring when opening `Dialog` containers (e.g. Add Token dialog) and reduce dialog content paddings to provide ample space for contents. + +**Root Cause:** +When a `Dialog` layout passes tight horizontal constraints to `AwanCard` and `AwanButton`, `StyleOuterNode` subtracts internal padding (e.g. 16dp/32dp) from incoming `maxWidth`. When `maxWidth` drops below padding width, subtracting padding yields a negative `maxWidth` while `minWidth` is clamped to `0`, causing `Constraints(minWidth = 0, maxWidth = -18)` to throw `IllegalArgumentException`. + +--- + +## Proposed Changes + +### Core Design System (`:core:design-system`) + +#### [MODIFY] `AwanButton.kt` +Guarantee that `safeConstraints` passed to `measurables[1].measure(safeConstraints)` never has `maxWidth < minWidth` or invalid bounds: + +```kotlin +val minTouchPx = minTouchSize.roundToPx() +val safeMaxWidth = constraints.maxWidth.coerceAtLeast(0) +val safeMaxHeight = constraints.maxHeight.coerceAtLeast(0) + +val minW = if (safeMaxWidth > 0) maxOf(constraints.minWidth, minTouchPx).coerceAtMost(safeMaxWidth) else 0 +val minH = if (safeMaxHeight > 0) maxOf(constraints.minHeight, minTouchPx).coerceAtMost(safeMaxHeight) else 0 + +val safeConstraints = Constraints( + minWidth = minW, + maxWidth = maxOf(safeMaxWidth, minW), + minHeight = minH, + maxHeight = maxOf(safeMaxHeight, minH), +) +``` + +#### [MODIFY] `AwanCard.kt` +Apply matching constraint safety to `AwanCard`: + +```kotlin +val safeMaxWidth = constraints.maxWidth.coerceAtLeast(0) +val safeMaxHeight = constraints.maxHeight.coerceAtLeast(0) +val minW = constraints.minWidth.coerceIn(0, safeMaxWidth) +val minH = constraints.minHeight.coerceIn(0, safeMaxHeight) + +val safeConstraints = Constraints( + minWidth = minW, + maxWidth = maxOf(safeMaxWidth, minW), + minHeight = minH, + maxHeight = maxOf(safeMaxHeight, minH) +) +``` + +--- + +### Feature Profile UI (`:feature:profile:impl`) + +#### [MODIFY] `McpSettingsScreen.kt` & `CreatedTokenModal.kt` +Reduce dialog card outer padding and inner `contentPadding` for optimal layout space: + +- Change `showAddTokenDialog` `AwanCard`: + - Outer modifier padding: `AwanTheme.spacing.sm` (12dp) instead of `spacing.md` (16dp). + - Inner `contentPadding`: `PaddingValues(AwanTheme.spacing.md)` (16dp) instead of `spacing.xl` (32dp). +- Change `CreatedTokenModal` `AwanCard`: + - Outer modifier padding: `AwanTheme.spacing.sm` (12dp). + - Inner `contentPadding`: `PaddingValues(AwanTheme.spacing.md)` (16dp). + +--- + +## Verification Plan + +### Automated Tests +- Design system tests: `./gradlew :core:design-system:testDebugUnitTest` +- Feature profile tests: `./gradlew :feature:profile:impl:testDebugUnitTest` +- Full project build & lint: `./gradlew assembleDebug testDebugUnitTest lint` + +### Manual Verification +1. Open app, navigate to **Profile -> Settings -> MCP Integration**. +2. Click **Add Token**: verify dialog opens cleanly without crashing and input field + action buttons fit comfortably. +3. Create a token: verify `CreatedTokenModal` displays raw token and action buttons cleanly without layout clipping or crashes. diff --git a/docs/feature/mcp/2026-08-11-mcp-info-connection-details-plan.md b/docs/feature/mcp/2026-08-11-mcp-info-connection-details-plan.md new file mode 100644 index 00000000..104a40fd --- /dev/null +++ b/docs/feature/mcp/2026-08-11-mcp-info-connection-details-plan.md @@ -0,0 +1,124 @@ +# MCP Info Screen Connection Details & Copy Icon Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a Server Connection Details card at the top of `McpInfoScreen` displaying the MCP Server URL and OAuth Client ID with copy buttons, and ensure copy icon tinting on Claude Desktop section matches `AwanTheme.colors.sky` (primary blue). + +**Architecture:** Presentation UI update in `:feature:profile:impl`. + +--- + +## Proposed Changes + +### Feature Profile UI (`:feature:profile:impl`) + +#### [MODIFY] `McpInfoScreen.kt` + +1. Add Server Connection Details card at top of content column: + - **MCP Server URL**: `https://backend-production-c701.up.railway.app/api/v1/mcp` with copy button and `SelectionContainer`. + - **OAuth Client ID**: `awan-android-client` with copy button and `SelectionContainer`. +2. Update copy icon tinting across Claude Desktop section, Cursor section, and Connection Details to use `AwanTheme.colors.sky` (primary blue) explicitly. + +```kotlin +// Server Connection Details Card at top of Column +AwanCard( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(AwanTheme.spacing.md) +) { + Column(verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.sm)) { + AwanText( + text = stringResource(ProfileR.string.profile_mcp_connection_title), + style = AwanTheme.styles.headingText + ) + + val mcpUrl = "https://backend-production-c701.up.railway.app/api/v1/mcp" + val clientId = "awan-android-client" + + // MCP Server URL + Column(verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.xxs)) { + AwanText( + text = stringResource(ProfileR.string.profile_mcp_url_label), + style = AwanTheme.styles.captionText + ) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(AwanTheme.spacing.xs)) + .background(AwanTheme.colors.disabledSurface) + .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(AwanTheme.spacing.xs)) + .padding(horizontal = AwanTheme.spacing.sm, vertical = AwanTheme.spacing.xs), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + SelectionContainer { + AwanText(text = mcpUrl, style = AwanTheme.styles.bodyText, modifier = Modifier.weight(1f)) + } + IconButton( + onClick = { + clipboardManager.setText(AnnotatedString(mcpUrl)) + Toast.makeText(context, copiedToastMessage, Toast.LENGTH_SHORT).show() + }, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = stringResource(ProfileR.string.profile_mcp_cd_copy_url), + tint = AwanTheme.colors.sky, + modifier = Modifier.size(AwanTheme.spacing.md) + ) + } + } + } + + // Client ID + Column(verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.xxs)) { + AwanText( + text = stringResource(ProfileR.string.profile_mcp_client_id_label), + style = AwanTheme.styles.captionText + ) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(AwanTheme.spacing.xs)) + .background(AwanTheme.colors.disabledSurface) + .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(AwanTheme.spacing.xs)) + .padding(horizontal = AwanTheme.spacing.sm, vertical = AwanTheme.spacing.xs), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + SelectionContainer { + AwanText(text = clientId, style = AwanTheme.styles.bodyText, modifier = Modifier.weight(1f)) + } + IconButton( + onClick = { + clipboardManager.setText(AnnotatedString(clientId)) + Toast.makeText(context, copiedToastMessage, Toast.LENGTH_SHORT).show() + }, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = stringResource(ProfileR.string.profile_mcp_cd_copy_client_id), + tint = AwanTheme.colors.sky, + modifier = Modifier.size(AwanTheme.spacing.md) + ) + } + } + } + } +} +``` + +--- + +## Verification Plan + +### Automated Tests +- Feature profile unit tests: `./gradlew :feature:profile:impl:testDebugUnitTest` +- Full project build & lint: `./gradlew assembleDebug testDebugUnitTest lint` + +### Manual Verification +1. Open app, navigate to **Profile -> Settings -> MCP Integration -> Info (i)**. +2. Verify top section displays Connection Details (Server URL and Client ID) with copy buttons. +3. Click copy buttons for Server URL and Client ID: verify toast appears and text is copied. +4. Verify copy icon tint on Claude Desktop section is styled with primary sky blue. diff --git a/docs/feature/mcp/2026-08-11-mcp-info-copyable-json-plan.md b/docs/feature/mcp/2026-08-11-mcp-info-copyable-json-plan.md new file mode 100644 index 00000000..8ee8ddeb --- /dev/null +++ b/docs/feature/mcp/2026-08-11-mcp-info-copyable-json-plan.md @@ -0,0 +1,54 @@ +# MCP Info Screen Copyable JSON Snippets Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Enable text selection (`SelectionContainer`) for JSON configuration snippets in `McpInfoScreen` so users can highlight and copy any arbitrary sub-string, and verify copy icon functionality on the Claude Desktop configuration section. + +**Architecture:** Presentation UI update in `:feature:profile:impl`. + +--- + +## Proposed Changes + +### Feature Profile UI (`:feature:profile:impl`) + +#### [MODIFY] `McpInfoScreen.kt` + +1. Import `androidx.compose.foundation.text.selection.SelectionContainer`. +2. Wrap `AwanText` displaying `claudeSnippet` inside `SelectionContainer`. +3. Wrap `AwanText` displaying `cursorSnippet` inside `SelectionContainer`. +4. Ensure the copy icon (`IconButton` with `Icons.Default.ContentCopy`) on the Claude section header uses `LocalClipboardManager.current` to copy the full `claudeSnippet` with a Toast feedback notification. + +```kotlin +// Claude Desktop Guide Box +Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(AwanTheme.spacing.xs)) + .background(AwanTheme.colors.disabledSurface) + .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(AwanTheme.spacing.xs)) + .padding(AwanTheme.spacing.sm) +) { + SelectionContainer { + AwanText( + text = claudeSnippet, + style = AwanTheme.styles.bodyText.let { + it.copy(textStyle = it.textStyle.copy(fontFamily = FontFamily.Monospace)) + } + ) + } +} +``` + +--- + +## Verification Plan + +### Automated Tests +- Feature profile unit tests: `./gradlew :feature:profile:impl:testDebugUnitTest` +- Full project build & lint: `./gradlew assembleDebug testDebugUnitTest lint` + +### Manual Verification +1. Open app, navigate to **Profile -> Settings -> MCP Integration -> Info (i)**. +2. Long-press on any line of the `claude_desktop_config.json` text block: verify text handles appear allowing arbitrary text selection and copying. +3. Click the copy icon on the Claude Desktop section header: verify full JSON snippet is copied to clipboard with Toast feedback. diff --git a/docs/feature/mcp/2026-08-11-mcp-integration-plan.md b/docs/feature/mcp/2026-08-11-mcp-integration-plan.md new file mode 100644 index 00000000..a1493fe4 --- /dev/null +++ b/docs/feature/mcp/2026-08-11-mcp-integration-plan.md @@ -0,0 +1,520 @@ +# MCP Integration & Token Management Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add Model Context Protocol (MCP) integration to Awan, including MCP connection details, token management (Create, Delete, Regenerate) with one-time token reveal, and an interactive MCP setup info screen. + +**Architecture:** Now in Android (NiA) architecture with Clean Architecture (`presentation → domain ← data`). Retrofit API endpoints in `:core:network`, Room DAO & caching in `:core:database`, offline-first `McpRepositoryImpl` in `:core:data`, use cases in `:core:domain`, and MVI screens (`McpSettingsScreen`, `McpInfoScreen`) in `:feature:profile`. + +**Tech Stack:** Kotlin 2.4.0, Jetpack Compose BOM 2026.06.01, Compose Styles API, Navigation 3, Hilt DI, Retrofit + Kotlinx Serialization, Room database v2. + +--- + +## Global Constraints + +- **Git & Branching:** Create feature branch `feature/AWAN-210-mcp-settings` from `origin/develop`. +- **Clean Architecture:** ViewModels consume domain use cases ONLY (never repositories/DAOs directly). Repository interfaces live in `:core:domain`. +- **Localization:** All user-facing strings must be in `strings.xml` (both English `res/values/` and Arabic `res/values-ar/`). +- **Styling:** Jetpack Compose Styles API (`AwanTheme.colors`, `AwanTheme.styles`, `AwanTheme.spacing`), no hardcoded colors/dimensions. +- **Security:** Raw token is displayed ONLY ONCE in a secure modal/sheet upon creation/regeneration with a Copy button and warning. Token list rows show obscured/masked tokens and copy is disabled after initial creation. + +--- + +## Plan Overview & Subsystems + +```mermaid +graph TD + A[SettingsCard in ProfileScreen] -->|Click MCP Settings| B[McpSettingsRouteScreen] + B -->|Click Info Icon| C[McpInfoRouteScreen] + B -->|Click Add Token| D[Create Token Dialog] + D -->|Submit| E[One-Time Reveal Sheet] + B -->|Click Regenerate| F[Regenerate Confirm Dialog] + F -->|Confirm| E + B -->|Click Delete| G[Delete Confirm Dialog] + + B --> H[McpSettingsViewModel] + H --> I[GetMcpConnectionDetailsUseCase] + H --> J[GetMcpTokensUseCase] + H --> K[CreateMcpTokenUseCase] + H --> L[DeleteMcpTokenUseCase] + H --> M[RegenerateMcpTokenUseCase] + + I & J & K & L & M --> N[McpRepository Contract] + N --> O[McpRepositoryImpl] + O --> P[McpApiService Retrofit] + O --> Q[McpTokenDao Room Database v2] +``` + +--- + +## Task Decomposition + +### Task 1: Feature Branch Creation & Strings Setup + +**Files:** +- Modify: `Z:\Business\Awan\Awan-Android\feature\profile\impl\src\main\res\values\strings.xml` +- Modify: `Z:\Business\Awan\Awan-Android\feature\profile\impl\src\main\res\values-ar\strings.xml` + +**Interfaces:** +- Consumes: User-approved feature branch `feature/AWAN-210-mcp-settings`. +- Produces: String resources for MCP settings, token management, dialogs, copy feedback, and setup instructions in EN & AR. + +- [ ] **Step 1: Create git branch `feature/AWAN-210-mcp-settings` from `origin/develop`** + +```bash +git checkout -b feature/AWAN-210-mcp-settings origin/develop +``` + +- [ ] **Step 2: Add MCP String resources in `res/values/strings.xml`** + +```xml + +MCP Integration +Connect AI assistants (Claude, Cursor) via Model Context Protocol +Connection Details +MCP Server URL +OAuth Client ID +API Tokens +Add New Token +Token Name (e.g. Claude Desktop) +Token key is hidden for security +Token can only be copied when initially created +Token Created Successfully! +Make sure to copy your personal access token now. You won’t be able to see it again! +Copy Token +Token copied to clipboard +Delete Token? +Are you sure you want to delete this MCP token? AI assistants using this token will lose access immediately. +Regenerate Token? +Regenerating this token will revoke the existing key. Any connected agent will need the new token. +How to Connect your AI Assistant +1. Copy the MCP Server URL and Client ID above. +2. Create an API Token and copy the key immediately. +3. Paste the configuration into your assistant (e.g. claude_desktop_config.json). +``` + +- [ ] **Step 3: Add corresponding Arabic strings in `res/values-ar/strings.xml`** + +```xml +تكامل MCP +ربط المساعدين الذكيين (Claude, Cursor) عبر بروتوكول MCP +تفاصيل الاتصال +رابط خادم MCP +معرف العميل (Client ID) +رموز الوصول (Tokens) +إضافة رمز جديد +اسم الرمز (مثال: Claude Desktop) +تم إخفاء المفتاح لأسباب أمنية +يمكن نسخ الرمز فقط عند إنشائه لأول مرة +تم إنشاء الرمز بنجاح! +احرص على نسخ رمز الوصول الخاص بك الآن. لن تتمكن من رؤيته مرة أخرى! +نسخ الرمز +تم نسخ الرمز إلى الحافظة +حذف الرمز؟ +هل أنت تأكد من حذف رمز MCP هذا؟ سيفقد المساعد الذكي الوصول فوراً. +إعادة إنشاء الرمز؟ +إعادة إنشاء الرمز ستلغي المفتاح الحالي. ستحتاج إلى تحديثه في المساعد الذكي. +كيفية ربط مساعدك الذكي +1. انسخ رابط خادم MCP ومعرف العميل أعلاه. +2. أنشئ رمز وصول واحفظ المفتاح فوراً. +3. قم بتضمين الإعدادات في ملف التكوين (مثل claude_desktop_config.json). +``` + +- [ ] **Step 4: Commit Task 1** + +```bash +git add feature/profile/impl/src/main/res/values/strings.xml feature/profile/impl/src/main/res/values-ar/strings.xml +git commit -m "AWAN-210: Add localized string resources for MCP settings and token management" +``` + +--- + +### Task 2: Core Domain Layer (`:core:domain`) + +**Files:** +- Create: `core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/model/McpConnectionDetails.kt` +- Create: `core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/model/McpToken.kt` +- Create: `core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/model/CreatedMcpToken.kt` +- Create: `core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/repository/McpRepository.kt` +- Create: `core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/usecase/GetMcpConnectionDetailsUseCase.kt` +- Create: `core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/usecase/GetMcpTokensUseCase.kt` +- Create: `core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/usecase/CreateMcpTokenUseCase.kt` +- Create: `core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/usecase/DeleteMcpTokenUseCase.kt` +- Create: `core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/usecase/RegenerateMcpTokenUseCase.kt` +- Test: `core/domain/src/test/kotlin/com/awan/app/core/domain/mcp/usecase/McpUseCasesTest.kt` + +**Interfaces:** +- Consumes: `:core:common` (`Result`, `AppError`). +- Produces: `McpRepository` contract and use cases consumed by `McpSettingsViewModel`. + +- [ ] **Step 1: Write Unit Test for Use Cases** + +```kotlin +class McpUseCasesTest { + private val fakeRepository = FakeMcpRepository() + private val getTokensUseCase = GetMcpTokensUseCase(fakeRepository) + private val createTokenUseCase = CreateMcpTokenUseCase(fakeRepository) + + @Test + fun `createMcpToken returns created token`() = runTest { + val result = createTokenUseCase("Claude Desktop") + assertThat(result).isInstanceOf>() + } +} +``` + +- [ ] **Step 2: Create Domain Models & Repository Interface** + +```kotlin +data class McpConnectionDetails( + val mcpUrl: String, + val clientId: String +) + +data class McpToken( + val id: String, + val name: String, + val maskedToken: String, + val createdAt: String, + val lastUsedAt: String? = null +) + +data class CreatedMcpToken( + val id: String, + val name: String, + val rawToken: String, + val maskedToken: String, + val createdAt: String +) + +interface McpRepository { + fun getMcpConnectionDetails(): Flow> + fun getMcpTokens(): Flow>> + suspend fun createMcpToken(name: String): Result + suspend fun deleteMcpToken(id: String): Result + suspend fun regenerateMcpToken(id: String): Result +} +``` + +- [ ] **Step 3: Create Use Cases** + +```kotlin +class GetMcpConnectionDetailsUseCase @Inject constructor( + private val repository: McpRepository +) { + operator fun invoke(): Flow> = repository.getMcpConnectionDetails() +} + +class GetMcpTokensUseCase @Inject constructor( + private val repository: McpRepository +) { + operator fun invoke(): Flow>> = repository.getMcpTokens() +} + +class CreateMcpTokenUseCase @Inject constructor( + private val repository: McpRepository +) { + suspend operator fun invoke(name: String): Result = repository.createMcpToken(name) +} + +class DeleteMcpTokenUseCase @Inject constructor( + private val repository: McpRepository +) { + suspend operator fun invoke(id: String): Result = repository.deleteMcpToken(id) +} + +class RegenerateMcpTokenUseCase @Inject constructor( + private val repository: McpRepository +) { + suspend operator fun invoke(id: String): Result = repository.regenerateMcpToken(id) +} +``` + +- [ ] **Step 4: Verify Unit Tests Pass** + +```bash +./gradlew :core:domain:testDebugUnitTest +``` + +- [ ] **Step 5: Commit Task 2** + +```bash +git add core/domain/ +git commit -m "AWAN-210: Add MCP domain models, repository contract, and use cases" +``` + +--- + +### Task 3: Core Network & Database Layer (`:core:network`, `:core:database`, `:core:data`) + +**Files:** +- Create: `core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpConnectionDetailsDto.kt` +- Create: `core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpTokenResponseDto.kt` +- Create: `core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreateMcpTokenRequestDto.kt` +- Create: `core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreatedMcpTokenResponseDto.kt` +- Create: `core/network/src/main/kotlin/com/awan/app/core/network/api/McpApiService.kt` +- Create: `core/database/src/main/kotlin/com/awan/app/core/database/model/McpTokenEntity.kt` +- Create: `core/database/src/main/kotlin/com/awan/app/core/database/dao/McpTokenDao.kt` +- Modify: `core/database/src/main/kotlin/com/awan/app/core/database/AwanDatabase.kt` (v1 -> v2 migration) +- Create: `core/data/src/main/kotlin/com/awan/app/core/data/mcp/repository/McpRepositoryImpl.kt` +- Create: `core/data/src/main/kotlin/com/awan/app/core/data/mcp/di/McpDataModule.kt` +- Test: `core/data/src/test/kotlin/com/awan/app/core/data/mcp/McpRepositoryImplTest.kt` + +**Interfaces:** +- Consumes: `:core:network`, `:core:database`. +- Produces: `McpRepositoryImpl` bound to `McpRepository` via Hilt `@Binds`. + +- [ ] **Step 1: Create Network DTOs & McpApiService Interface** + +```kotlin +@Serializable +data class McpConnectionDetailsDto( + @SerialName("mcpUrl") val mcpUrl: String, + @SerialName("clientId") val clientId: String +) + +@Serializable +data class McpTokenResponseDto( + @SerialName("id") val id: String, + @SerialName("name") val name: String, + @SerialName("maskedToken") val maskedToken: String, + @SerialName("createdAt") val createdAt: String, + @SerialName("lastUsedAt") val lastUsedAt: String? = null +) + +@Serializable +data class CreateMcpTokenRequestDto( + @SerialName("name") val name: String +) + +@Serializable +data class CreatedMcpTokenResponseDto( + @SerialName("id") val id: String, + @SerialName("name") val name: String, + @SerialName("rawToken") val rawToken: String, + @SerialName("maskedToken") val maskedToken: String, + @SerialName("createdAt") val createdAt: String +) + +interface McpApiService { + @GET("v1/mcp/settings/connection-details") + suspend fun getConnectionDetails(): McpConnectionDetailsDto + + @GET("v1/mcp/tokens") + suspend fun getTokens(): List + + @POST("v1/mcp/tokens") + suspend fun createToken(@Body request: CreateMcpTokenRequestDto): CreatedMcpTokenResponseDto + + @DELETE("v1/mcp/tokens/{id}") + suspend fun deleteToken(@Path("id") id: String) + + @POST("v1/mcp/tokens/{id}/regenerate") + suspend fun regenerateToken(@Path("id") id: String): CreatedMcpTokenResponseDto +} +``` + +- [ ] **Step 2: Create Database Entity & DAO** + +```kotlin +@Entity(tableName = "mcp_tokens") +data class McpTokenEntity( + @PrimaryKey val id: String, + val name: String, + val maskedToken: String, + val createdAt: String, + val lastUsedAt: String? = null +) + +@Dao +interface McpTokenDao { + @Query("SELECT * FROM mcp_tokens ORDER BY createdAt DESC") + fun getMcpTokens(): Flow> + + @Upsert + suspend fun upsertMcpTokens(tokens: List) + + @Query("DELETE FROM mcp_tokens WHERE id = :id") + suspend fun deleteMcpToken(id: String) + + @Query("DELETE FROM mcp_tokens") + suspend fun clearAll() +} +``` + +- [ ] **Step 3: Update AwanDatabase & Migration v1 -> v2** + +```kotlin +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() + ) + } +} +``` + +- [ ] **Step 4: Implement McpRepositoryImpl with Offline-First DAO + Remote Sync & Network Error Handling** + +- [ ] **Step 5: Verify Repository Unit Tests** + +```bash +./gradlew :core:data:testDebugUnitTest +``` + +- [ ] **Step 6: Commit Task 3** + +```bash +git add core/network/ core/database/ core/data/ +git commit -m "AWAN-210: Add MCP network DTOs, Room entity/DAO, Room migration v1->v2, and McpRepositoryImpl" +``` + +--- + +### Task 4: UI Presentation Layer (`:feature:profile`) + +**Files:** +- Create: `feature/profile/api/src/main/java/com/awan/feature/profile/api/McpSettingsRoute.kt` +- Create: `feature/profile/api/src/main/java/com/awan/feature/profile/api/McpInfoRoute.kt` +- Create: `feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsState.kt` +- Create: `feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsAction.kt` +- Create: `feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsEvent.kt` +- Create: `feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsViewModel.kt` +- Create: `feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/McpSettingsScreen.kt` +- Create: `feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/McpInfoScreen.kt` +- Create: `feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/components/CreatedTokenModal.kt` +- Test: `feature/profile/impl/src/test/java/com/awan/feature/profile/impl/presentation/McpSettingsViewModelTest.kt` + +**Interfaces:** +- Consumes: `:core:domain` use cases (`GetMcpConnectionDetailsUseCase`, `GetMcpTokensUseCase`, `CreateMcpTokenUseCase`, `DeleteMcpTokenUseCase`, `RegenerateMcpTokenUseCase`). +- Produces: `McpSettingsScreen` UI and `McpInfoScreen` UI. + +- [ ] **Step 1: Create Routes in `:feature:profile:api`** + +```kotlin +@Serializable +data object McpSettingsRoute : Route + +@Serializable +data object McpInfoRoute : Route +``` + +- [ ] **Step 2: Create ViewModel, State, Action & Event** + +- State holds `connectionDetails`, `tokens`, `createdToken` (non-null triggers modal), `isLoading`, `isCreating`, `userMessage`. +- ViewModel manages creating, deleting, regenerating tokens and exposing UI state. + +- [ ] **Step 3: Write ViewModel Unit Test** + +```bash +./gradlew :feature:profile:impl:testDebugUnitTest --tests "com.awan.feature.profile.impl.presentation.McpSettingsViewModelTest" +``` + +- [ ] **Step 4: Build `CreatedTokenModal` (One-Time Reveal Sheet/Dialog)** +- Monospaced text box displaying raw token. +- Copy button with clipboard manager toast/snackbar. +- High-visibility security warning notice banner. + +- [ ] **Step 5: Build `McpSettingsScreen` & `McpInfoScreen` Composables** +- Jetpack Compose Styles API (`AwanTheme`, `AwanCard`, `AwanButton`, `AwanText`, `AwanTextField`). +- Info button in top bar navigating to `McpInfoRoute`. + +- [ ] **Step 6: Commit Task 4** + +```bash +git add feature/profile/ +git commit -m "AWAN-210: Add MCP settings state, ViewModel, McpSettingsScreen, McpInfoScreen, and CreatedTokenModal" +``` + +--- + +### Task 5: Navigation Wiring & Profile Integration + +**Files:** +- Modify: `feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/components/SettingsCard.kt` +- Modify: `feature/profile/impl/src/main/java/com/awan/feature/profile/impl/navigation/ProfileEntryProvider.kt` +- Modify: `app/src/main/java/com/awan/app/AwanApp.kt` + +**Interfaces:** +- Consumes: `McpSettingsRoute`, `McpInfoRoute`, `profileEntry`. +- Produces: Complete end-to-end user navigation flow from Profile -> SettingsCard -> MCP Settings -> MCP Info. + +- [ ] **Step 1: Add "MCP Integration" row in `SettingsCard.kt`** + +```kotlin +PreferenceRow( + icon = Icons.Default.VpnKey, // or Key/Extension + title = stringResource(ProfileR.string.profile_mcp_title), + onClick = { onSettingsClick("mcp") }, + showDivider = true, + iconColor = AwanTheme.colors.primary +) +``` + +- [ ] **Step 2: Wire `McpSettingsRoute` and `McpInfoRoute` entries in `ProfileEntryProvider.kt`** + +```kotlin +entry { + McpSettingsRouteScreen( + onNavigateToInfo = { navigator.navigate(McpInfoRoute) }, + onBack = onBack + ) +} + +entry { + McpInfoRouteScreen( + onBack = onBack + ) +} +``` + +- [ ] **Step 3: Update `AwanApp.kt` `profileEntry` handler** + +```kotlin +onNavigateToMcpSettings = { navigator.navigate(McpSettingsRoute) } +``` + +- [ ] **Step 4: Verify Full App Build & Unit Tests** + +```bash +./gradlew assembleDebug testDebugUnitTest +``` + +- [ ] **Step 5: Commit Task 5** + +```bash +git add feature/profile/ app/ +git commit -m "AWAN-210: Integrate MCP Settings and Info screen navigation into Profile module and AwanApp" +``` + +--- + +## Verification Plan + +### Automated Tests +- Unit Tests for Domain Use Cases: `./gradlew :core:domain:testDebugUnitTest` +- Unit Tests for Repository & Data Layer: `./gradlew :core:data:testDebugUnitTest` +- Unit Tests for McpSettingsViewModel: `./gradlew :feature:profile:impl:testDebugUnitTest` +- Full project clean build & unit tests: `./gradlew assembleDebug testDebugUnitTest` + +### Manual Verification +1. Launch app and navigate to **Profile** tab. +2. Verify "MCP Integration" item appears under **Settings**. +3. Click "MCP Integration" and verify navigation to `McpSettingsScreen`. +4. Verify MCP Server URL (`mcpUrl`) and Client ID (`clientId`) display correctly with copy buttons. +5. Click **Info Icon** in top bar, verify navigation to `McpInfoScreen` with step-by-step setup guides. +6. Return to MCP Settings, click **Add New Token**, type token name "Claude Desktop". +7. Verify **One-Time Token Modal** appears displaying the raw token, Copy button, and security warning banner. +8. Copy token and close modal. Verify token appears in token list as obscured (`••••••••token_suffix`). +9. Verify token list row Copy button is disabled/greyed out with prompt explaining copy is only available during creation. +10. Click **Regenerate**, confirm dialog, verify new raw token modal appears. +11. Click **Delete**, confirm dialog, verify token is removed from list. diff --git a/docs/feature/mcp/2026-08-11-postman-api-migration-plan.md b/docs/feature/mcp/2026-08-11-postman-api-migration-plan.md new file mode 100644 index 00000000..dcc005a2 --- /dev/null +++ b/docs/feature/mcp/2026-08-11-postman-api-migration-plan.md @@ -0,0 +1,142 @@ +# MCP Integration Postman API Migration Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace placeholder `/v1/mcp/tokens` and `/v1/mcp/settings/connection-details` API endpoints with the official Postman backend endpoints from the **Awan** collection (**API Keys** folder): +- `GET v1/api-keys`: List active API keys (`id`, `name`, `keyPrefix`, `createdAt`). +- `POST v1/api-keys`: Create new API key (`id`, `name`, `keyValue`, `createdAt`). +- `DELETE v1/api-keys/{keyId}`: Revoke API key (204 No Content). +- Static Server Connection Details (`https://backend-production-c701.up.railway.app/api/v1/mcp`). + +**Architecture:** Now in Android (NiA) Clean Architecture (`presentation → domain ← data`). Fixes touch `:core:network`, `:core:database`, `:core:data`, `:core:domain`, and `:feature:profile:impl`. + +--- + +## Postman API Contract Alignment + +```mermaid +sequenceDiagram + autonumber + participant App as Android App (Repository) + participant Room as Room Cache (mcp_tokens) + participant API as Backend (v1/api-keys) + + rect rgb(240, 248, 255) + note right of App: List Tokens + App->>API: GET /v1/api-keys (Bearer JWT) + API-->>App: 200 OK [ApiKeySummaryResponse(id, name, keyPrefix, createdAt)] + App->>Room: replaceMcpTokens(entities) + end + + rect rgb(255, 245, 238) + note right of App: Create Token + App->>API: POST /v1/api-keys { "name": "Claude Desktop" } + API-->>App: 200 OK ApiKeyResponse(id, name, keyValue, createdAt) + App->>Room: upsertMcpTokens([entity]) + App-->>App: Return CreatedMcpToken(rawToken = keyValue) + end + + rect rgb(245, 255, 250) + note right of App: Delete/Revoke Token + App->>API: DELETE /v1/api-keys/{keyId} + API-->>App: 204 No Content + App->>Room: deleteMcpToken(id) + end +``` + +--- + +## Proposed Changes + +### Core Network Layer (`:core:network`) + +#### [MODIFY] `McpApiService.kt` +Replace fake endpoints with official `/v1/api-keys` endpoints: + +```kotlin +interface McpApiService { + @GET("v1/api-keys") + suspend fun getApiKeys(): Response> + + @POST("v1/api-keys") + suspend fun createApiKey(@Body request: CreateApiKeyRequestDto): Response + + @DELETE("v1/api-keys/{keyId}") + suspend fun revokeApiKey(@Path("keyId") keyId: String): Response +} +``` + +#### [NEW] `ApiKeySummaryDto.kt`, `CreateApiKeyRequestDto.kt`, `ApiKeyResponseDto.kt` +Define network DTOs matching Postman specs exactly: + +```kotlin +@Serializable +data class ApiKeySummaryDto( + @SerialName("id") val id: String, + @SerialName("name") val name: String, + @SerialName("keyPrefix") val keyPrefix: String, + @SerialName("createdAt") val createdAt: String, +) + +@Serializable +data class CreateApiKeyRequestDto( + @SerialName("name") val name: String, +) + +@Serializable +data class ApiKeyResponseDto( + @SerialName("id") val id: String, + @SerialName("name") val name: String, + @SerialName("keyValue") val keyValue: String, + @SerialName("createdAt") val createdAt: String, +) +``` + +--- + +### Core Data & Database Layer (`:core:data` & `:core:database`) + +#### [MODIFY] `McpMappers.kt` +Map `ApiKeySummaryDto` and `ApiKeyResponseDto` to `McpTokenEntity` & domain `McpToken`: + +```kotlin +fun ApiKeySummaryDto.toEntity(): McpTokenEntity = McpTokenEntity( + id = id, + name = name, + maskedToken = keyPrefix, + createdAt = createdAt, + lastUsedAt = null +) + +fun ApiKeyResponseDto.toDomain(): CreatedMcpToken = CreatedMcpToken( + id = id, + name = name, + rawToken = keyValue, + maskedToken = if (keyValue.length >= 12) keyValue.take(12) + "..." else keyValue, + createdAt = createdAt +) +``` + +#### [MODIFY] `McpRepositoryImpl.kt` +1. `getMcpConnectionDetails()`: Provide static connection details without making non-existent network calls: + `McpConnectionDetails(mcpUrl = "https://backend-production-c701.up.railway.app/api/v1/mcp", clientId = "awan-android-client")` +2. `getMcpTokens()`: Execute `mcpApiService.getApiKeys()`, map DTOs to entities, and call atomic `replaceMcpTokens`. +3. `createMcpToken(name)`: Call `mcpApiService.createApiKey(CreateApiKeyRequestDto(name = name))`. +4. `deleteMcpToken(id)`: Call `mcpApiService.revokeApiKey(id)`. +5. `regenerateMcpToken(id)`: Call `createApiKey` with the existing token name, then call `revokeApiKey(id)`. + +--- + +## Verification Plan + +### Automated Tests +- Core Network & Data Unit Tests: `./gradlew :core:network:testDebugUnitTest :core:data:testDebugUnitTest` +- Feature Profile Unit Tests: `./gradlew :feature:profile:impl:testDebugUnitTest` +- Full project build & lint: `./gradlew assembleDebug testDebugUnitTest lint` + +### Manual Verification +1. Run app on device/emulator. +2. Navigate to **Profile -> Settings -> MCP Integration**. +3. Verify `GET v1/api-keys` returns HTTP 200 (instead of 500) and displays token list or empty state. +4. Verify `POST v1/api-keys` creates key and returns `keyValue` in `CreatedTokenModal`. +5. Verify `DELETE v1/api-keys/{id}` revokes key with HTTP 204. diff --git a/feature/profile/api/src/main/java/com/awan/feature/profile/api/McpInfoRoute.kt b/feature/profile/api/src/main/java/com/awan/feature/profile/api/McpInfoRoute.kt new file mode 100644 index 00000000..adecf9a5 --- /dev/null +++ b/feature/profile/api/src/main/java/com/awan/feature/profile/api/McpInfoRoute.kt @@ -0,0 +1,7 @@ +package com.awan.feature.profile.api + +import com.awan.core.navigation.Route +import kotlinx.serialization.Serializable + +@Serializable +data object McpInfoRoute : Route diff --git a/feature/profile/api/src/main/java/com/awan/feature/profile/api/McpSettingsRoute.kt b/feature/profile/api/src/main/java/com/awan/feature/profile/api/McpSettingsRoute.kt new file mode 100644 index 00000000..e978685d --- /dev/null +++ b/feature/profile/api/src/main/java/com/awan/feature/profile/api/McpSettingsRoute.kt @@ -0,0 +1,7 @@ +package com.awan.feature.profile.api + +import com.awan.core.navigation.Route +import kotlinx.serialization.Serializable + +@Serializable +data object McpSettingsRoute : Route diff --git a/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/navigation/McpInfoRouteScreen.kt b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/navigation/McpInfoRouteScreen.kt new file mode 100644 index 00000000..2480085f --- /dev/null +++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/navigation/McpInfoRouteScreen.kt @@ -0,0 +1,21 @@ +package com.awan.feature.profile.impl.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.awan.feature.profile.impl.presentation.McpSettingsViewModel +import com.awan.feature.profile.impl.ui.McpInfoScreen + +@Composable +fun McpInfoRouteScreen( + viewModel: McpSettingsViewModel = hiltViewModel(), + onBack: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + McpInfoScreen( + mcpUrl = uiState.connectionDetails?.mcpUrl.orEmpty(), + onBackClick = onBack + ) +} diff --git a/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/navigation/McpSettingsRouteScreen.kt b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/navigation/McpSettingsRouteScreen.kt new file mode 100644 index 00000000..0a9adbc9 --- /dev/null +++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/navigation/McpSettingsRouteScreen.kt @@ -0,0 +1,24 @@ +package com.awan.feature.profile.impl.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.awan.feature.profile.impl.presentation.McpSettingsViewModel +import com.awan.feature.profile.impl.ui.McpSettingsScreen + +@Composable +fun McpSettingsRouteScreen( + viewModel: McpSettingsViewModel = hiltViewModel(), + onNavigateToInfo: () -> Unit, + onBack: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + McpSettingsScreen( + uiState = uiState, + onAction = viewModel::onAction, + onInfoClick = onNavigateToInfo, + onBackClick = onBack, + ) +} diff --git a/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/navigation/ProfileEntryProvider.kt b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/navigation/ProfileEntryProvider.kt index cda412e0..33029b9e 100644 --- a/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/navigation/ProfileEntryProvider.kt +++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/navigation/ProfileEntryProvider.kt @@ -1,19 +1,19 @@ package com.awan.feature.profile.impl.navigation -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.runtime.EntryProviderScope import com.awan.core.navigation.Route import com.awan.feature.profile.api.DailyZonesRoute import com.awan.feature.profile.api.EditRoutineRoute +import com.awan.feature.profile.api.McpInfoRoute +import com.awan.feature.profile.api.McpSettingsRoute import com.awan.feature.profile.api.ProfileRoute fun EntryProviderScope.profileEntry( onNavigateToDailyZones: () -> Unit, onNavigateToEditRoutine: (String?) -> Unit, onNavigateToInventory: () -> Unit, + onNavigateToMcpSettings: () -> Unit, + onNavigateToMcpInfo: () -> Unit, onLogout: () -> Unit, onBack: () -> Unit, ) { @@ -21,6 +21,7 @@ fun EntryProviderScope.profileEntry( ProfileRouteScreen( onDailyZonesClick = onNavigateToDailyZones, onInventoryClick = onNavigateToInventory, + onNavigateToMcpSettings = onNavigateToMcpSettings, onLogout = onLogout ) } @@ -39,4 +40,17 @@ fun EntryProviderScope.profileEntry( onBack = onBack ) } + + entry { + McpSettingsRouteScreen( + onNavigateToInfo = onNavigateToMcpInfo, + onBack = onBack + ) + } + + entry { + McpInfoRouteScreen( + onBack = onBack + ) + } } diff --git a/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/navigation/ProfileRouteScreen.kt b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/navigation/ProfileRouteScreen.kt index 3e0db73b..03fa4aaf 100644 --- a/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/navigation/ProfileRouteScreen.kt +++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/navigation/ProfileRouteScreen.kt @@ -12,6 +12,7 @@ fun ProfileRouteScreen( viewModel: ProfileViewModel = hiltViewModel(), onDailyZonesClick: () -> Unit, onInventoryClick: () -> Unit, + onNavigateToMcpSettings: () -> Unit, onLogout: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -22,7 +23,11 @@ fun ProfileRouteScreen( onAction = viewModel::onAction, onDailyZonesClick = onDailyZonesClick, onInventoryClick = onInventoryClick, - onSettingsClick = { }, + onSettingsClick = { settingKey -> + if (settingKey == "mcp") { + onNavigateToMcpSettings() + } + }, onLogout = onLogout, ) } diff --git a/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsAction.kt b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsAction.kt new file mode 100644 index 00000000..1c07545a --- /dev/null +++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsAction.kt @@ -0,0 +1,19 @@ +package com.awan.feature.profile.impl.presentation + +import com.awan.app.core.domain.mcp.model.McpToken + +sealed interface McpSettingsAction { + data object ShowAddTokenDialog : McpSettingsAction + data object HideAddTokenDialog : McpSettingsAction + data class UpdateNewTokenName(val name: String) : McpSettingsAction + data class ShowDeleteDialog(val token: McpToken) : McpSettingsAction + data object HideDeleteDialog : McpSettingsAction + data class ShowRegenerateDialog(val token: McpToken) : McpSettingsAction + data object HideRegenerateDialog : McpSettingsAction + data class CreateToken(val name: String) : McpSettingsAction + data class DeleteToken(val id: String) : McpSettingsAction + data class RegenerateToken(val id: String) : McpSettingsAction + data object DismissCreatedModal : McpSettingsAction + data object DismissError : McpSettingsAction + data object Refresh : McpSettingsAction +} diff --git a/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsEvent.kt b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsEvent.kt new file mode 100644 index 00000000..cdc461a1 --- /dev/null +++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsEvent.kt @@ -0,0 +1,11 @@ +package com.awan.feature.profile.impl.presentation + +import com.awan.app.core.common.text.UiText +import com.awan.app.core.domain.mcp.model.CreatedMcpToken + +sealed interface McpSettingsEvent { + data class TokenCreated(val token: CreatedMcpToken) : McpSettingsEvent + data object TokenDeleted : McpSettingsEvent + data class TokenRegenerated(val token: CreatedMcpToken) : McpSettingsEvent + data class Error(val message: UiText) : McpSettingsEvent +} diff --git a/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsState.kt b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsState.kt new file mode 100644 index 00000000..c6cf9f13 --- /dev/null +++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsState.kt @@ -0,0 +1,20 @@ +package com.awan.feature.profile.impl.presentation + +import com.awan.app.core.common.text.UiText +import com.awan.app.core.domain.mcp.model.CreatedMcpToken +import com.awan.app.core.domain.mcp.model.McpConnectionDetails +import com.awan.app.core.domain.mcp.model.McpToken + +data class McpSettingsState( + val connectionDetails: McpConnectionDetails? = null, + val tokens: List = emptyList(), + val createdToken: CreatedMcpToken? = null, + val showAddTokenDialog: Boolean = false, + val newTokenName: String = "", + val deletingToken: McpToken? = null, + val regeneratingToken: McpToken? = null, + val isLoading: Boolean = false, + val isCreating: Boolean = false, + val userMessage: UiText? = null, + val error: UiText? = null, +) diff --git a/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsViewModel.kt b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsViewModel.kt new file mode 100644 index 00000000..17bfe39e --- /dev/null +++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsViewModel.kt @@ -0,0 +1,187 @@ +package com.awan.feature.profile.impl.presentation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.awan.app.core.common.result.Result +import com.awan.app.core.domain.mcp.usecase.CreateMcpTokenUseCase +import com.awan.app.core.domain.mcp.usecase.DeleteMcpTokenUseCase +import com.awan.app.core.domain.mcp.usecase.GetMcpConnectionDetailsUseCase +import com.awan.app.core.domain.mcp.usecase.GetMcpTokensUseCase +import com.awan.app.core.domain.mcp.usecase.RegenerateMcpTokenUseCase +import com.awan.feature.profile.impl.helpers.ProfileErrorMapper +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class McpSettingsViewModel @Inject constructor( + private val getMcpConnectionDetailsUseCase: GetMcpConnectionDetailsUseCase, + private val getMcpTokensUseCase: GetMcpTokensUseCase, + private val createMcpTokenUseCase: CreateMcpTokenUseCase, + private val deleteMcpTokenUseCase: DeleteMcpTokenUseCase, + private val regenerateMcpTokenUseCase: RegenerateMcpTokenUseCase, +) : ViewModel() { + + private val _uiState = MutableStateFlow(McpSettingsState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = Channel(Channel.BUFFERED) + val events: Flow = _events.receiveAsFlow() + + private var loadJob: Job? = null + + init { + loadData() + } + + fun onAction(action: McpSettingsAction) { + when (action) { + McpSettingsAction.ShowAddTokenDialog -> _uiState.update { it.copy(showAddTokenDialog = true, newTokenName = "") } + McpSettingsAction.HideAddTokenDialog -> _uiState.update { it.copy(showAddTokenDialog = false, newTokenName = "") } + is McpSettingsAction.UpdateNewTokenName -> _uiState.update { it.copy(newTokenName = action.name) } + is McpSettingsAction.ShowDeleteDialog -> _uiState.update { it.copy(deletingToken = action.token) } + McpSettingsAction.HideDeleteDialog -> _uiState.update { it.copy(deletingToken = null) } + is McpSettingsAction.ShowRegenerateDialog -> _uiState.update { it.copy(regeneratingToken = action.token) } + McpSettingsAction.HideRegenerateDialog -> _uiState.update { it.copy(regeneratingToken = null) } + is McpSettingsAction.CreateToken -> createToken(action.name) + is McpSettingsAction.DeleteToken -> deleteToken(action.id) + is McpSettingsAction.RegenerateToken -> regenerateToken(action.id) + McpSettingsAction.DismissCreatedModal -> _uiState.update { it.copy(createdToken = null) } + McpSettingsAction.DismissError -> _uiState.update { it.copy(error = null) } + McpSettingsAction.Refresh -> loadData() + } + } + + private fun loadData() { + loadJob?.cancel() + loadJob = viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + var detailsLoaded = false + var tokensLoaded = false + + launch { + getMcpConnectionDetailsUseCase().collect { result -> + when (result) { + is Result.Success -> { + detailsLoaded = true + _uiState.update { + it.copy( + connectionDetails = result.data, + isLoading = !detailsLoaded || !tokensLoaded, + ) + } + } + is Result.Error -> { + detailsLoaded = true + val uiError = ProfileErrorMapper.mapToUiText(result.error) + _uiState.update { + it.copy(error = uiError, isLoading = !detailsLoaded || !tokensLoaded) + } + } + Result.Loading -> _uiState.update { it.copy(isLoading = true) } + } + } + } + + launch { + getMcpTokensUseCase().collect { result -> + when (result) { + is Result.Success -> { + tokensLoaded = true + _uiState.update { + it.copy(tokens = result.data, isLoading = !detailsLoaded || !tokensLoaded) + } + } + is Result.Error -> { + tokensLoaded = true + val uiError = ProfileErrorMapper.mapToUiText(result.error) + _uiState.update { + it.copy(error = uiError, isLoading = !detailsLoaded || !tokensLoaded) + } + } + Result.Loading -> Unit + } + } + } + } + } + + private fun createToken(name: String) { + if (name.isBlank()) return + viewModelScope.launch { + _uiState.update { it.copy(isCreating = true, error = null) } + when (val result = createMcpTokenUseCase(name)) { + is Result.Success -> { + val created = result.data + _uiState.update { state -> + state.copy( + isCreating = false, + createdToken = created, + showAddTokenDialog = false, + newTokenName = "", + ) + } + _events.send(McpSettingsEvent.TokenCreated(created)) + } + is Result.Error -> { + val uiError = ProfileErrorMapper.mapToUiText(result.error) + _uiState.update { it.copy(isCreating = false, showAddTokenDialog = false, error = uiError) } + _events.send(McpSettingsEvent.Error(uiError)) + } + Result.Loading -> Unit + } + } + } + + private fun deleteToken(id: String) { + viewModelScope.launch { + when (val result = deleteMcpTokenUseCase(id)) { + is Result.Success -> { + _uiState.update { state -> + state.copy( + deletingToken = null, + ) + } + _events.send(McpSettingsEvent.TokenDeleted) + } + is Result.Error -> { + val uiError = ProfileErrorMapper.mapToUiText(result.error) + _uiState.update { it.copy(error = uiError, deletingToken = null) } + _events.send(McpSettingsEvent.Error(uiError)) + } + Result.Loading -> Unit + } + } + } + + private fun regenerateToken(id: String) { + viewModelScope.launch { + when (val result = regenerateMcpTokenUseCase(id)) { + is Result.Success -> { + val regenerated = result.data + _uiState.update { state -> + state.copy( + createdToken = regenerated, + regeneratingToken = null, + ) + } + _events.send(McpSettingsEvent.TokenRegenerated(regenerated)) + } + is Result.Error -> { + val uiError = ProfileErrorMapper.mapToUiText(result.error) + _uiState.update { it.copy(error = uiError, regeneratingToken = null) } + _events.send(McpSettingsEvent.Error(uiError)) + } + Result.Loading -> Unit + } + } + } +} diff --git a/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/McpInfoScreen.kt b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/McpInfoScreen.kt new file mode 100644 index 00000000..d0002b20 --- /dev/null +++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/McpInfoScreen.kt @@ -0,0 +1,299 @@ +package com.awan.feature.profile.impl.ui + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import com.awan.app.core.designsystem.AwanBackButton +import com.awan.app.core.designsystem.AwanCard +import com.awan.app.core.designsystem.AwanText +import com.awan.app.core.designsystem.AwanTheme +import com.awan.feature.profile.impl.R as ProfileR + +internal fun formatMcpTokenCreationDate(createdAt: String): String = + createdAt.trim().substringBefore('T').substringBefore(' ') + +@Composable +fun McpInfoScreen( + mcpUrl: String, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current + val aiSetupPrompt = stringResource(ProfileR.string.profile_mcp_ai_setup_prompt, mcpUrl) + val claudeCopiedToastMessage = stringResource(ProfileR.string.profile_mcp_claude_config_copied) + val cursorCopiedToastMessage = stringResource(ProfileR.string.profile_mcp_cursor_config_copied) + val aiPromptCopiedToastMessage = stringResource(ProfileR.string.profile_mcp_ai_prompt_copied) + + val claudeSnippet = """ + { + "mcpServers": { + "awan": { + "command": "npx", + "args": [ + "-y", + "@awan/mcp-server", + "--url", "$mcpUrl", + "--token", "YOUR_API_TOKEN" + ] + } + } + } + """.trimIndent() + + val cursorSnippet = """ + { + "mcp": { + "servers": { + "awan": { + "url": "$mcpUrl", + "headers": { + "Authorization": "Bearer YOUR_API_TOKEN" + } + } + } + } + } + """.trimIndent() + + Scaffold( + topBar = { + Row( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = AwanTheme.spacing.md, vertical = AwanTheme.spacing.sm), + horizontalArrangement = Arrangement.spacedBy(AwanTheme.spacing.md), + verticalAlignment = Alignment.CenterVertically + ) { + AwanBackButton(onClick = onBackClick) + AwanText( + text = stringResource(ProfileR.string.profile_mcp_info_title), + style = AwanTheme.styles.titleText + ) + } + }, + containerColor = AwanTheme.colors.background, + modifier = modifier + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + .padding(horizontal = AwanTheme.spacing.lg, vertical = AwanTheme.spacing.md), + verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.md) + ) { + // Setup steps card + AwanCard( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(AwanTheme.spacing.md) + ) { + Column(verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.sm)) { + AwanText( + text = stringResource(ProfileR.string.profile_mcp_setup_title), + style = AwanTheme.styles.headingText + ) + AwanText( + text = stringResource(ProfileR.string.profile_mcp_info_step1), + style = AwanTheme.styles.bodyText + ) + AwanText( + text = stringResource(ProfileR.string.profile_mcp_info_step2), + style = AwanTheme.styles.bodyText + ) + } + } + + // Claude Desktop Guide + AwanCard( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(AwanTheme.spacing.md) + ) { + Column(verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.xs)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + AwanText( + text = "Claude Desktop (claude_desktop_config.json)", + style = AwanTheme.styles.headingText, + modifier = Modifier.weight(1f), + maxLines = 2 + ) + IconButton( + onClick = { + clipboardManager.setText(AnnotatedString(claudeSnippet)) + Toast.makeText( + context, + claudeCopiedToastMessage, + Toast.LENGTH_SHORT + ).show() + }, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = stringResource(ProfileR.string.profile_mcp_cd_copy_snippet), + tint = AwanTheme.colors.sky, + modifier = Modifier.size(AwanTheme.spacing.md) + ) + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(AwanTheme.spacing.xs)) + .background(AwanTheme.colors.disabledSurface) + .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(AwanTheme.spacing.xs)) + .padding(AwanTheme.spacing.sm) + ) { + SelectionContainer { + AwanText( + text = claudeSnippet, + style = AwanTheme.styles.bodyText.let { it.copy(textStyle = it.textStyle.copy(fontFamily = FontFamily.Monospace)) } + ) + } + } + } + } + + // Cursor Guide + AwanCard( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(AwanTheme.spacing.md) + ) { + Column(verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.xs)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + AwanText( + text = "Cursor IDE Setup", + style = AwanTheme.styles.headingText, + modifier = Modifier.weight(1f), + maxLines = 2 + ) + IconButton( + onClick = { + clipboardManager.setText(AnnotatedString(cursorSnippet)) + Toast.makeText( + context, + cursorCopiedToastMessage, + Toast.LENGTH_SHORT + ).show() + }, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = stringResource(ProfileR.string.profile_mcp_cd_copy_snippet), + tint = AwanTheme.colors.sky, + modifier = Modifier.size(AwanTheme.spacing.md) + ) + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(AwanTheme.spacing.xs)) + .background(AwanTheme.colors.disabledSurface) + .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(AwanTheme.spacing.xs)) + .padding(AwanTheme.spacing.sm) + ) { + SelectionContainer { + AwanText( + text = cursorSnippet, + style = AwanTheme.styles.bodyText.let { it.copy(textStyle = it.textStyle.copy(fontFamily = FontFamily.Monospace)) } + ) + } + } + } + } + // AI-assisted setup + AwanCard( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(AwanTheme.spacing.md) + ) { + Column(verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.xs)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + AwanText( + text = stringResource(ProfileR.string.profile_mcp_ai_setup_title), + style = AwanTheme.styles.headingText, + modifier = Modifier.weight(1f), + maxLines = 2 + ) + IconButton( + onClick = { + clipboardManager.setText(AnnotatedString(aiSetupPrompt)) + Toast.makeText( + context, + aiPromptCopiedToastMessage, + Toast.LENGTH_SHORT + ).show() + }, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = stringResource(ProfileR.string.profile_mcp_cd_copy_ai_prompt), + tint = AwanTheme.colors.sky, + modifier = Modifier.size(AwanTheme.spacing.md) + ) + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(AwanTheme.spacing.xs)) + .background(AwanTheme.colors.disabledSurface) + .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(AwanTheme.spacing.xs)) + .padding(AwanTheme.spacing.sm) + ) { + SelectionContainer { + AwanText( + text = aiSetupPrompt, + style = AwanTheme.styles.bodyText.let { it.copy(textStyle = it.textStyle.copy(fontFamily = FontFamily.Monospace)) } + ) + } + } + } + } + } + } +} diff --git a/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/McpSettingsScreen.kt b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/McpSettingsScreen.kt new file mode 100644 index 00000000..6e85df1d --- /dev/null +++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/McpSettingsScreen.kt @@ -0,0 +1,420 @@ +package com.awan.feature.profile.impl.ui + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +import com.awan.app.core.designsystem.AwanBackButton +import com.awan.app.core.designsystem.AwanButton +import com.awan.app.core.designsystem.AwanButtonVariant +import com.awan.app.core.designsystem.AwanCard +import com.awan.app.core.designsystem.AwanDialog +import com.awan.app.core.designsystem.AwanErrorSnackbar +import com.awan.app.core.designsystem.AwanText +import com.awan.app.core.designsystem.AwanTextField +import com.awan.app.core.designsystem.AwanTheme +import com.awan.app.core.domain.mcp.model.McpToken +import com.awan.feature.profile.impl.R as ProfileR +import com.awan.feature.profile.impl.presentation.McpSettingsAction +import com.awan.feature.profile.impl.presentation.McpSettingsState +import com.awan.feature.profile.impl.ui.components.CreatedTokenModal + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun McpSettingsScreen( + uiState: McpSettingsState, + onAction: (McpSettingsAction) -> Unit, + onInfoClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current + val urlCopiedToastMessage = stringResource(ProfileR.string.profile_mcp_url_copied) + + + if (uiState.createdToken != null) { + CreatedTokenModal( + createdToken = uiState.createdToken, + onDismiss = { onAction(McpSettingsAction.DismissCreatedModal) } + ) + } + + if (uiState.showAddTokenDialog) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = { onAction(McpSettingsAction.HideAddTokenDialog) }, + sheetState = sheetState, + containerColor = AwanTheme.colors.surface + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AwanTheme.spacing.lg) + .padding(bottom = AwanTheme.spacing.xl), + verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.md) + ) { + AwanText( + text = stringResource(ProfileR.string.profile_mcp_add_token), + style = AwanTheme.styles.titleText + ) + AwanTextField( + value = uiState.newTokenName, + onValueChange = { onAction(McpSettingsAction.UpdateNewTokenName(it)) }, + placeholder = stringResource(ProfileR.string.profile_mcp_token_name_hint), + modifier = Modifier.fillMaxWidth() + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(AwanTheme.spacing.sm) + ) { + AwanButton( + onClick = { onAction(McpSettingsAction.HideAddTokenDialog) }, + modifier = Modifier.weight(1f), + variant = AwanButtonVariant.Quiet + ) { + AwanText(stringResource(ProfileR.string.profile_cancel)) + } + AwanButton( + onClick = { + if (uiState.newTokenName.isNotBlank()) { + onAction(McpSettingsAction.CreateToken(uiState.newTokenName)) + } + }, + modifier = Modifier.weight(1f), + enabled = uiState.newTokenName.isNotBlank() && !uiState.isCreating + ) { + if (uiState.isCreating) { + CircularProgressIndicator(modifier = Modifier.size(AwanTheme.spacing.md), color = AwanTheme.colors.surface) + } else { + AwanText(stringResource(ProfileR.string.profile_mcp_add_token)) + } + } + } + } + } + } + + if (uiState.deletingToken != null) { + AwanDialog( + title = stringResource(ProfileR.string.profile_mcp_token_delete_confirm_title), + body = stringResource(ProfileR.string.profile_mcp_token_delete_confirm_body), + primaryLabel = stringResource(ProfileR.string.profile_routine_delete), + primaryVariant = AwanButtonVariant.Destructive, + onPrimary = { + onAction(McpSettingsAction.DeleteToken(uiState.deletingToken.id)) + }, + secondaryLabel = stringResource(ProfileR.string.profile_cancel), + onSecondary = { onAction(McpSettingsAction.HideDeleteDialog) }, + onDismiss = { onAction(McpSettingsAction.HideDeleteDialog) } + ) + } + + if (uiState.regeneratingToken != null) { + AwanDialog( + title = stringResource(ProfileR.string.profile_mcp_token_regenerate_confirm_title), + body = stringResource(ProfileR.string.profile_mcp_token_regenerate_confirm_body), + primaryLabel = stringResource(ProfileR.string.profile_zone_confirm), + primaryVariant = AwanButtonVariant.Primary, + onPrimary = { + onAction(McpSettingsAction.RegenerateToken(uiState.regeneratingToken.id)) + }, + secondaryLabel = stringResource(ProfileR.string.profile_cancel), + onSecondary = { onAction(McpSettingsAction.HideRegenerateDialog) }, + onDismiss = { onAction(McpSettingsAction.HideRegenerateDialog) } + ) + } + + Scaffold( + topBar = { + Row( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = AwanTheme.spacing.md, vertical = AwanTheme.spacing.sm), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + AwanBackButton(onClick = onBackClick) + AwanText( + text = stringResource(ProfileR.string.profile_mcp_title), + style = AwanTheme.styles.titleText + ) + IconButton(onClick = onInfoClick) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = stringResource(ProfileR.string.profile_mcp_cd_info), + tint = AwanTheme.colors.sky + ) + } + } + }, + containerColor = AwanTheme.colors.background, + modifier = modifier + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = AwanTheme.spacing.lg, vertical = AwanTheme.spacing.md), + verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.md) + ) { + // Connection Details Card + AwanCard( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(AwanTheme.spacing.md) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.sm) + ) { + AwanText( + text = stringResource(ProfileR.string.profile_mcp_connection_title), + style = AwanTheme.styles.headingText + ) + + val details = uiState.connectionDetails + val mcpUrl = details?.mcpUrl ?: "https://mcp.awan.app/v1" + + // MCP URL Row + Column(verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.xxs)) { + AwanText( + text = stringResource(ProfileR.string.profile_mcp_url_label), + style = AwanTheme.styles.captionText + ) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(AwanTheme.spacing.xs)) + .background(AwanTheme.colors.disabledSurface) + .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(AwanTheme.spacing.xs)) + .padding(horizontal = AwanTheme.spacing.sm, vertical = AwanTheme.spacing.xs), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + AwanText( + text = mcpUrl, + style = AwanTheme.styles.bodyText.let { it.copy(textStyle = it.textStyle.copy(fontFamily = FontFamily.Monospace)) }, + modifier = Modifier.weight(1f) + ) + IconButton( + onClick = { + clipboardManager.setText(AnnotatedString(mcpUrl)) + Toast.makeText( + context, + urlCopiedToastMessage, + Toast.LENGTH_SHORT + ).show() + }, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = stringResource(ProfileR.string.profile_mcp_cd_copy_url), + tint = AwanTheme.colors.textSecondary, + modifier = Modifier.size(AwanTheme.spacing.md) + ) + } + } + } + + } + } + + // Tokens Card + AwanCard( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(AwanTheme.spacing.md) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.sm) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + AwanText( + text = stringResource(ProfileR.string.profile_mcp_tokens_title), + style = AwanTheme.styles.headingText + ) + AwanButton( + onClick = { onAction(McpSettingsAction.ShowAddTokenDialog) }, + variant = AwanButtonVariant.Quiet + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(AwanTheme.spacing.xxs), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(AwanTheme.spacing.md) + ) + AwanText(stringResource(ProfileR.string.profile_mcp_add_token)) + } + } + } + + if (uiState.tokens.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = AwanTheme.spacing.md), + contentAlignment = Alignment.Center + ) { + AwanText( + text = "No tokens added yet", + style = AwanTheme.styles.bodySecondaryText + ) + } + } else { + uiState.tokens.forEach { token -> + TokenItemRow( + token = token, + onRegenerate = { onAction(McpSettingsAction.ShowRegenerateDialog(token)) }, + onDelete = { onAction(McpSettingsAction.ShowDeleteDialog(token)) } + ) + } + } + + // Security notice + AwanText( + text = stringResource( + ProfileR.string.profile_mcp_token_notice_formatted, + stringResource(ProfileR.string.profile_mcp_token_obscured_notice), + stringResource(ProfileR.string.profile_mcp_token_copy_disabled) + ), + style = AwanTheme.styles.captionText, + modifier = Modifier.padding(top = AwanTheme.spacing.xxs) + ) + } + } + } + + if (uiState.error != null) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(AwanTheme.spacing.lg), + contentAlignment = Alignment.BottomCenter + ) { + AwanErrorSnackbar( + message = uiState.error.asString(), + onDismiss = { onAction(McpSettingsAction.DismissError) } + ) + } + } + } + } +} + +@Composable +private fun TokenItemRow( + token: McpToken, + onRegenerate: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(AwanTheme.spacing.sm)) + .background(AwanTheme.colors.disabledSurface) + .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(AwanTheme.spacing.sm)) + .padding(AwanTheme.spacing.sm) + ) { + Column(verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.xs)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + AwanText( + text = token.name, + style = AwanTheme.styles.bodyText + ) + Row(horizontalArrangement = Arrangement.spacedBy(AwanTheme.spacing.xxs)) { + IconButton( + onClick = onRegenerate, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = stringResource(ProfileR.string.profile_mcp_cd_regenerate_token, token.name), + tint = AwanTheme.colors.sky, + modifier = Modifier.size(18.dp) + ) + } + IconButton( + onClick = onDelete, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(ProfileR.string.profile_mcp_cd_delete_token, token.name), + tint = AwanTheme.colors.destructive, + modifier = Modifier.size(18.dp) + ) + } + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + AwanText( + text = token.maskedToken, + style = AwanTheme.styles.captionText.let { it.copy(textStyle = it.textStyle.copy(fontFamily = FontFamily.Monospace)) } + ) + AwanText( + text = stringResource( + ProfileR.string.profile_mcp_token_created_on, + formatMcpTokenCreationDate(token.createdAt) + ), + style = AwanTheme.styles.captionText + ) + } + } + } +} diff --git a/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/components/CreatedTokenModal.kt b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/components/CreatedTokenModal.kt new file mode 100644 index 00000000..3302902c --- /dev/null +++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/components/CreatedTokenModal.kt @@ -0,0 +1,152 @@ +package com.awan.feature.profile.impl.ui.components + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +import com.awan.app.core.designsystem.AwanButton +import com.awan.app.core.designsystem.AwanButtonVariant +import com.awan.app.core.designsystem.AwanText +import com.awan.app.core.designsystem.AwanTheme +import com.awan.app.core.domain.mcp.model.CreatedMcpToken +import com.awan.feature.profile.impl.R as ProfileR + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CreatedTokenModal( + createdToken: CreatedMcpToken, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current + val copiedToastMessage = stringResource(ProfileR.string.profile_mcp_token_copied) + var copied by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = AwanTheme.colors.surface, + modifier = modifier, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AwanTheme.spacing.lg) + .padding(bottom = AwanTheme.spacing.xl), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.md) + ) { + AwanText( + text = stringResource(ProfileR.string.profile_mcp_token_created_banner_title), + style = AwanTheme.styles.titleText + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(AwanTheme.spacing.sm)) + .background(AwanTheme.colors.destructive.copy(alpha = 0.1f)) + .border(1.dp, AwanTheme.colors.destructive, RoundedCornerShape(AwanTheme.spacing.sm)) + .padding(AwanTheme.spacing.md) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(AwanTheme.spacing.sm), + verticalAlignment = Alignment.Top + ) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = null, + tint = AwanTheme.colors.destructive, + modifier = Modifier.size(AwanTheme.spacing.lg) + ) + AwanText( + text = stringResource(ProfileR.string.profile_mcp_token_created_banner_warning), + style = AwanTheme.styles.bodySecondaryText.let { it.copy(color = AwanTheme.colors.destructive) } + ) + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(AwanTheme.spacing.sm)) + .background(AwanTheme.colors.disabledSurface) + .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(AwanTheme.spacing.sm)) + .padding(AwanTheme.spacing.md), + contentAlignment = Alignment.Center + ) { + AwanText( + text = createdToken.rawToken, + style = AwanTheme.styles.bodyText.let { it.copy(textStyle = it.textStyle.copy(fontFamily = FontFamily.Monospace)) }, + ) + } + + AwanButton( + onClick = { + clipboardManager.setText(AnnotatedString(createdToken.rawToken)) + copied = true + Toast.makeText(context, copiedToastMessage, Toast.LENGTH_SHORT).show() + }, + modifier = Modifier.fillMaxWidth(), + variant = AwanButtonVariant.Secondary + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(AwanTheme.spacing.xs), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = null, + modifier = Modifier.size(AwanTheme.spacing.md) + ) + AwanText( + text = if (copied) { + stringResource(ProfileR.string.profile_mcp_token_copied) + } else { + stringResource(ProfileR.string.profile_mcp_token_copy) + } + ) + } + } + + AwanButton( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth(), + variant = AwanButtonVariant.Primary + ) { + AwanText(stringResource(ProfileR.string.profile_close)) + } + } + } +} diff --git a/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/components/SettingsCard.kt b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/components/SettingsCard.kt index a37d8964..ae33358e 100644 --- a/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/components/SettingsCard.kt +++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/components/SettingsCard.kt @@ -45,6 +45,13 @@ fun SettingsCard( showDivider = true, iconColor = AwanTheme.colors.sky ) + PreferenceRow( + icon = Icons.Default.VpnKey, + title = stringResource(ProfileR.string.profile_mcp_title), + onClick = { onSettingsClick("mcp") }, + showDivider = true, + iconColor = AwanTheme.colors.sky + ) PreferenceRow( icon = Icons.AutoMirrored.Filled.Logout, title = stringResource(ProfileR.string.profile_logout), diff --git a/feature/profile/impl/src/main/res/values-ar/strings.xml b/feature/profile/impl/src/main/res/values-ar/strings.xml index 8a385cba..06280db3 100644 --- a/feature/profile/impl/src/main/res/values-ar/strings.xml +++ b/feature/profile/impl/src/main/res/values-ar/strings.xml @@ -184,4 +184,42 @@ ج س ح + + + تكامل MCP + ربط المساعدين الذكيين (Claude, Cursor) عبر بروتوكول MCP + تفاصيل الاتصال + رابط خادم MCP + رموز الوصول (Tokens) + إضافة رمز جديد + اسم الرمز (مثال: Claude Desktop) + تم إخفاء المفتاح لأسباب أمنية + يمكن نسخ الرمز فقط عند إنشائه لأول مرة + %1$s. %2$s + تم إنشاء الرمز بنجاح! + احرص على نسخ رمز الوصول الخاص بك الآن. لن تتمكن من رؤيته مرة أخرى! + نسخ الرمز + تم نسخ الرمز إلى الحافظة + تم نسخ رابط خادم MCP إلى الحافظة + تم نسخ إعداد Claude Desktop إلى الحافظة + تم نسخ إعداد Cursor إلى الحافظة + تم نسخ مطالبة إعداد الذكاء الاصطناعي إلى الحافظة + حذف الرمز؟ + هل أنت تأكد من حذف رمز MCP هذا؟ سيفقد المساعد الذكي الوصول فوراً. + إعادة إنشاء الرمز؟ + إعادة إنشاء الرمز ستلغي المفتاح الحالي. ستحتاج إلى تحديثه في المساعد الذكي. + كيفية ربط مساعدك الذكي + تعليمات الإعداد + 1. احصل على رمز API من شاشة تكامل MCP. اضغط على إضافة رمز جديد وانسخه من رسالة النجاح التي تظهر مرة واحدة. + 2. انسخ إعداداً من الأسفل واستبدل YOUR_API_TOKEN بالرمز الخاص بك، أو انسخ مطالبة إعداد الذكاء الاصطناعي والصقها في مساعدك. + أو دع مساعدك الذكي يتولى الإعداد + نسخ مطالبة إعداد الذكاء الاصطناعي + صِلني بخادم Awan عبر MCP.\n\nأولاً، اطلب مني رمز MCP API الخاص بـ Awan. لا تتابع قبل أن أقدمه لك.\n\nبعد أن أرسل الرمز، أعد إعداد خادم Awan MCP لهذا المساعد باستخدام:\n- رابط الخادم: %1$s\n- التفويض: Bearer [الرمز الذي أقدمه]\n\nتولَّ إعداد MCP بنفسك، وتحقق من الاتصال، وأخبرني عند جاهزيته. + + تاريخ الإنشاء: %1$s + معلومات إعداد MCP + نسخ رابط خادم MCP + نسخ البرمجية النصية للتكوين + حذف الرمز %1$s + إعادة إنشاء الرمز %1$s diff --git a/feature/profile/impl/src/main/res/values/strings.xml b/feature/profile/impl/src/main/res/values/strings.xml index 6f437789..ef4d68d9 100644 --- a/feature/profile/impl/src/main/res/values/strings.xml +++ b/feature/profile/impl/src/main/res/values/strings.xml @@ -184,4 +184,42 @@ Fri Sat Sun + + + MCP Integration + Connect AI assistants (Claude, Cursor) via Model Context Protocol + Connection Details + MCP Server URL + API Tokens + Add New Token + Token Name (e.g. Claude Desktop) + Token key is hidden for security + Token can only be copied when initially created + %1$s. %2$s + Token Created Successfully! + Make sure to copy your personal access token now. You won\'t be able to see it again! + Copy Token + Token copied to clipboard + MCP server URL copied to clipboard + Claude Desktop configuration copied to clipboard + Cursor configuration copied to clipboard + AI setup prompt copied to clipboard + Delete Token? + Are you sure you want to delete this MCP token? AI assistants using this token will lose access immediately. + Regenerate Token? + Regenerating this token will revoke the existing key. Any connected agent will need the new token. + How to Connect your AI Assistant + Setup Instructions + 1. Get an API token from the MCP Integration screen. Tap Add New Token and copy it from the one-time success dialog. + 2. Copy a configuration below and replace YOUR_API_TOKEN with your token, or copy the AI setup prompt below and paste it into your assistant. + Or let your AI assistant set it up + Copy AI setup prompt + Connect me to Awan through MCP.\n\nFirst, ask me for my Awan MCP API token. Do not continue until I provide it.\n\nAfter I provide the token, configure the Awan MCP server for this assistant using:\n- Server URL: %1$s\n- Authorization: Bearer [the token I provide]\n\nHandle the MCP setup yourself, verify the connection, and tell me when it is ready. + + Created: %1$s + MCP Setup Info + Copy MCP Server URL + Copy Configuration Snippet + Delete Token %1$s + Regenerate Token %1$s diff --git a/feature/profile/impl/src/test/java/com/awan/feature/profile/impl/presentation/McpSettingsViewModelTest.kt b/feature/profile/impl/src/test/java/com/awan/feature/profile/impl/presentation/McpSettingsViewModelTest.kt new file mode 100644 index 00000000..3df4d7bf --- /dev/null +++ b/feature/profile/impl/src/test/java/com/awan/feature/profile/impl/presentation/McpSettingsViewModelTest.kt @@ -0,0 +1,221 @@ +package com.awan.feature.profile.impl.presentation + +import com.awan.app.core.common.error.AppError +import com.awan.app.core.common.result.Result +import com.awan.app.core.domain.mcp.model.CreatedMcpToken +import com.awan.app.core.domain.mcp.model.McpConnectionDetails +import com.awan.app.core.domain.mcp.model.McpToken +import com.awan.app.core.domain.mcp.repository.McpRepository +import com.awan.app.core.domain.mcp.usecase.CreateMcpTokenUseCase +import com.awan.app.core.domain.mcp.usecase.DeleteMcpTokenUseCase +import com.awan.app.core.domain.mcp.usecase.GetMcpConnectionDetailsUseCase +import com.awan.app.core.domain.mcp.usecase.GetMcpTokensUseCase +import com.awan.app.core.domain.mcp.usecase.RegenerateMcpTokenUseCase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class McpSettingsViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + private lateinit var fakeRepository: FakeMcpRepository + private lateinit var viewModel: McpSettingsViewModel + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + fakeRepository = FakeMcpRepository() + viewModel = McpSettingsViewModel( + getMcpConnectionDetailsUseCase = GetMcpConnectionDetailsUseCase(fakeRepository), + getMcpTokensUseCase = GetMcpTokensUseCase(fakeRepository), + createMcpTokenUseCase = CreateMcpTokenUseCase(fakeRepository), + deleteMcpTokenUseCase = DeleteMcpTokenUseCase(fakeRepository), + regenerateMcpTokenUseCase = RegenerateMcpTokenUseCase(fakeRepository), + ) + } + + @After + fun tearDown() = Dispatchers.resetMain() + + @Test + fun `initial state loads connection details and tokens`() = runTest(testDispatcher) { + val state = viewModel.uiState.value + assertNotNull(state.connectionDetails) + assertEquals("https://mcp.awan.app/v1", state.connectionDetails?.mcpUrl) + assertEquals(1, state.tokens.size) + assertEquals("Claude Desktop", state.tokens.first().name) + } + + @Test + fun `ShowAddTokenDialog and UpdateNewTokenName actions update state correctly`() = runTest(testDispatcher) { + viewModel.onAction(McpSettingsAction.ShowAddTokenDialog) + assert(viewModel.uiState.value.showAddTokenDialog) + + viewModel.onAction(McpSettingsAction.UpdateNewTokenName("My Token")) + assertEquals("My Token", viewModel.uiState.value.newTokenName) + + viewModel.onAction(McpSettingsAction.HideAddTokenDialog) + assert(!viewModel.uiState.value.showAddTokenDialog) + assertEquals("", viewModel.uiState.value.newTokenName) + } + + @Test + fun `ShowDeleteDialog and HideDeleteDialog update deletingToken in state`() = runTest(testDispatcher) { + val token = McpToken("token-1", "Claude Desktop", "••••••••abcd", "2026-08-11T00:00:00Z") + viewModel.onAction(McpSettingsAction.ShowDeleteDialog(token)) + assertEquals(token, viewModel.uiState.value.deletingToken) + + viewModel.onAction(McpSettingsAction.HideDeleteDialog) + assertNull(viewModel.uiState.value.deletingToken) + } + + @Test + fun `ShowRegenerateDialog and HideRegenerateDialog update regeneratingToken in state`() = runTest(testDispatcher) { + val token = McpToken("token-1", "Claude Desktop", "••••••••abcd", "2026-08-11T00:00:00Z") + viewModel.onAction(McpSettingsAction.ShowRegenerateDialog(token)) + assertEquals(token, viewModel.uiState.value.regeneratingToken) + + viewModel.onAction(McpSettingsAction.HideRegenerateDialog) + assertNull(viewModel.uiState.value.regeneratingToken) + } + + @Test + fun `CreateToken action creates token, sets createdToken, resets dialog, and emits TokenCreated event`() = runTest(testDispatcher) { + val events = mutableListOf() + val job = launch { viewModel.events.toList(events) } + + viewModel.onAction(McpSettingsAction.ShowAddTokenDialog) + viewModel.onAction(McpSettingsAction.UpdateNewTokenName("Cursor")) + viewModel.onAction(McpSettingsAction.CreateToken("Cursor")) + + val state = viewModel.uiState.value + assertNotNull(state.createdToken) + assertEquals("Cursor", state.createdToken?.name) + assertEquals("raw_secret_cursor_key", state.createdToken?.rawToken) + assert(!state.showAddTokenDialog) + assertEquals("", state.newTokenName) + assertEquals(1, events.size) + assert(events.first() is McpSettingsEvent.TokenCreated) + + job.cancel() + } + + @Test + fun createTokenErrorClosesAddTokenDialogAndExposesSnackbarError() = runTest(testDispatcher) { + fakeRepository.createResult = Result.Error(AppError.Network) + + viewModel.onAction(McpSettingsAction.ShowAddTokenDialog) + viewModel.onAction(McpSettingsAction.UpdateNewTokenName("Cursor")) + viewModel.onAction(McpSettingsAction.CreateToken("Cursor")) + + val state = viewModel.uiState.value + assert(!state.showAddTokenDialog) + assertNotNull(state.error) + } + @Test + fun `DeleteToken action removes token from state, clears deletingToken, and emits TokenDeleted event`() = runTest(testDispatcher) { + val events = mutableListOf() + val job = launch { viewModel.events.toList(events) } + + val token = McpToken("token-1", "Claude Desktop", "••••••••abcd", "2026-08-11T00:00:00Z") + viewModel.onAction(McpSettingsAction.ShowDeleteDialog(token)) + viewModel.onAction(McpSettingsAction.DeleteToken("token-1")) + + val state = viewModel.uiState.value + assertEquals(0, state.tokens.size) + assertNull(state.deletingToken) + assertEquals(1, events.size) + assert(events.first() is McpSettingsEvent.TokenDeleted) + + job.cancel() + } + + @Test + fun `RegenerateToken action sets new createdToken, clears regeneratingToken, and emits TokenRegenerated event`() = runTest(testDispatcher) { + val events = mutableListOf() + val job = launch { viewModel.events.toList(events) } + + val token = McpToken("token-1", "Claude Desktop", "••••••••abcd", "2026-08-11T00:00:00Z") + viewModel.onAction(McpSettingsAction.ShowRegenerateDialog(token)) + viewModel.onAction(McpSettingsAction.RegenerateToken("token-1")) + + val state = viewModel.uiState.value + assertNotNull(state.createdToken) + assertEquals("raw_regenerated_token-1", state.createdToken?.rawToken) + assertNull(state.regeneratingToken) + assertEquals(1, events.size) + assert(events.first() is McpSettingsEvent.TokenRegenerated) + + job.cancel() + } + + @Test + fun `DismissCreatedModal clears createdToken in state`() = runTest(testDispatcher) { + viewModel.onAction(McpSettingsAction.CreateToken("Test")) + assertNotNull(viewModel.uiState.value.createdToken) + + viewModel.onAction(McpSettingsAction.DismissCreatedModal) + assertNull(viewModel.uiState.value.createdToken) + } + + private class FakeMcpRepository : McpRepository { + var createResult: Result? = null + private val tokensList = mutableListOf( + McpToken("token-1", "Claude Desktop", "••••••••abcd", "2026-08-11T00:00:00Z") + ) + private val tokensFlow = MutableStateFlow>>(Result.Success(tokensList)) + + override fun getMcpConnectionDetails(): Flow> { + return flowOf(Result.Success(McpConnectionDetails("https://mcp.awan.app/v1", "awan-android-client"))) + } + + override fun getMcpTokens(): Flow>> = tokensFlow + + override suspend fun createMcpToken(name: String): Result { + createResult?.let { return it } + val created = CreatedMcpToken( + id = "token-${System.currentTimeMillis()}", + name = name, + rawToken = "raw_secret_${name.lowercase()}_key", + maskedToken = "••••••••secret", + createdAt = "2026-08-11T00:00:00Z" + ) + tokensList.add(McpToken(created.id, created.name, created.maskedToken, created.createdAt)) + tokensFlow.value = Result.Success(tokensList.toList()) + return Result.Success(created) + } + + override suspend fun deleteMcpToken(id: String): Result { + tokensList.removeAll { it.id == id } + tokensFlow.value = Result.Success(tokensList.toList()) + return Result.Success(Unit) + } + + override suspend fun regenerateMcpToken(id: String): Result { + val created = CreatedMcpToken( + id = id, + name = "Regenerated", + rawToken = "raw_regenerated_$id", + maskedToken = "••••••••regen", + createdAt = "2026-08-11T00:00:00Z" + ) + return Result.Success(created) + } + } +} diff --git a/feature/profile/impl/src/test/java/com/awan/feature/profile/impl/ui/McpSettingsDateFormatterTest.kt b/feature/profile/impl/src/test/java/com/awan/feature/profile/impl/ui/McpSettingsDateFormatterTest.kt new file mode 100644 index 00000000..b2d1532c --- /dev/null +++ b/feature/profile/impl/src/test/java/com/awan/feature/profile/impl/ui/McpSettingsDateFormatterTest.kt @@ -0,0 +1,15 @@ +package com.awan.feature.profile.impl.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class McpSettingsDateFormatterTest { + + @Test + fun `formats token creation timestamp as date only`() { + assertEquals( + "2026-08-11", + formatMcpTokenCreationDate("2026-08-11T12:34:56.789Z") + ) + } +}