From 2c74661fb31e75c9d3a21b798279787eb136f3e9 Mon Sep 17 00:00:00 2001 From: "ZEIAD-LAPTOP\\zeiad" Date: Tue, 11 Aug 2026 03:35:37 +0300 Subject: [PATCH 01/13] AWAN-210: Add MCP network DTOs, Room entity/DAO, Room migration v1->v2, and McpRepositoryImpl --- .../app/core/data/mcp/di/McpDataModule.kt | 20 + .../data/mcp/repository/McpRepositoryImpl.kt | 145 +++ .../core/data/mcp/McpRepositoryImplTest.kt | 277 +++++ .../2.json | 968 ++++++++++++++++++ .../awan/app/core/database/AwanDatabase.kt | 7 +- .../awan/app/core/database/dao/McpTokenDao.kt | 22 + .../app/core/database/di/DatabaseModule.kt | 25 + .../app/core/database/model/McpTokenEntity.kt | 13 + .../app/core/network/api/McpApiService.kt | 28 + .../awan/app/core/network/di/NetworkModule.kt | 5 + .../dto/mcp/CreateMcpTokenRequestDto.kt | 9 + .../dto/mcp/CreatedMcpTokenResponseDto.kt | 13 + .../dto/mcp/McpConnectionDetailsDto.kt | 10 + .../network/dto/mcp/McpTokenResponseDto.kt | 13 + 14 files changed, 1554 insertions(+), 1 deletion(-) create mode 100644 core/data/src/main/kotlin/com/awan/app/core/data/mcp/di/McpDataModule.kt create mode 100644 core/data/src/main/kotlin/com/awan/app/core/data/mcp/repository/McpRepositoryImpl.kt create mode 100644 core/data/src/test/java/com/awan/app/core/data/mcp/McpRepositoryImplTest.kt create mode 100644 core/database/schemas/com.awan.app.core.database.AwanDatabase/2.json create mode 100644 core/database/src/main/kotlin/com/awan/app/core/database/dao/McpTokenDao.kt create mode 100644 core/database/src/main/kotlin/com/awan/app/core/database/model/McpTokenEntity.kt create mode 100644 core/network/src/main/kotlin/com/awan/app/core/network/api/McpApiService.kt create mode 100644 core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreateMcpTokenRequestDto.kt create mode 100644 core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreatedMcpTokenResponseDto.kt create mode 100644 core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpConnectionDetailsDto.kt create mode 100644 core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpTokenResponseDto.kt 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/repository/McpRepositoryImpl.kt b/core/data/src/main/kotlin/com/awan/app/core/data/mcp/repository/McpRepositoryImpl.kt new file mode 100644 index 00000000..75506594 --- /dev/null +++ b/core/data/src/main/kotlin/com/awan/app/core/data/mcp/repository/McpRepositoryImpl.kt @@ -0,0 +1,145 @@ +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.database.dao.McpTokenDao +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.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.api.McpApiService +import com.awan.app.core.network.dto.mcp.CreateMcpTokenRequestDto +import com.awan.app.core.network.error.safeApiCall +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +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 { + if (!connectivityMonitor.isCurrentlyOnline()) { + emit(Result.Error(AppError.Network)) + return@flow + } + val result = safeApiCall(ioDispatcher) { + val dto = mcpApiService.getConnectionDetails() + McpConnectionDetails( + mcpUrl = dto.mcpUrl, + clientId = dto.clientId, + ) + } + emit(result) + }.flowOn(ioDispatcher) + + override fun getMcpTokens(): Flow>> = flow { + if (connectivityMonitor.isCurrentlyOnline()) { + try { + val dtos = mcpApiService.getTokens() + val entities = dtos.map { dto -> + McpTokenEntity( + id = dto.id, + name = dto.name, + maskedToken = dto.maskedToken, + createdAt = dto.createdAt, + lastUsedAt = dto.lastUsedAt, + ) + } + mcpTokenDao.clearAll() + mcpTokenDao.upsertMcpTokens(entities) + } catch (_: Exception) { + // If network sync fails, fallback to Room cached tokens + } + } + emitAll( + mcpTokenDao.getMcpTokens().map { entities -> + Result.Success( + entities.map { entity -> + McpToken( + id = entity.id, + name = entity.name, + maskedToken = entity.maskedToken, + createdAt = entity.createdAt, + lastUsedAt = entity.lastUsedAt, + ) + } + ) + } + ) + }.flowOn(ioDispatcher) + + override suspend fun createMcpToken(name: String): Result { + if (!connectivityMonitor.isCurrentlyOnline()) { + return Result.Error(AppError.Network) + } + return safeApiCall(ioDispatcher) { + val dto = mcpApiService.createToken(CreateMcpTokenRequestDto(name = name)) + val entity = McpTokenEntity( + id = dto.id, + name = dto.name, + maskedToken = dto.maskedToken, + createdAt = dto.createdAt, + lastUsedAt = null, + ) + mcpTokenDao.upsertMcpTokens(listOf(entity)) + CreatedMcpToken( + id = dto.id, + name = dto.name, + rawToken = dto.rawToken, + maskedToken = dto.maskedToken, + createdAt = dto.createdAt, + ) + } + } + + override suspend fun deleteMcpToken(id: String): Result { + if (!connectivityMonitor.isCurrentlyOnline()) { + return Result.Error(AppError.Network) + } + val result = safeApiCall(ioDispatcher) { + mcpApiService.deleteToken(id) + } + if (result is Result.Success) { + mcpTokenDao.deleteMcpToken(id) + } + return result + } + + override suspend fun regenerateMcpToken(id: String): Result { + if (!connectivityMonitor.isCurrentlyOnline()) { + return Result.Error(AppError.Network) + } + return safeApiCall(ioDispatcher) { + val dto = mcpApiService.regenerateToken(id) + val entity = McpTokenEntity( + id = dto.id, + name = dto.name, + maskedToken = dto.maskedToken, + createdAt = dto.createdAt, + lastUsedAt = null, + ) + mcpTokenDao.upsertMcpTokens(listOf(entity)) + CreatedMcpToken( + id = dto.id, + name = dto.name, + rawToken = dto.rawToken, + maskedToken = dto.maskedToken, + createdAt = dto.createdAt, + ) + } + } +} 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..4f6ccce1 --- /dev/null +++ b/core/data/src/test/java/com/awan/app/core/data/mcp/McpRepositoryImplTest.kt @@ -0,0 +1,277 @@ +package com.awan.app.core.data.mcp + +import com.awan.app.core.common.error.AppError +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.CreateMcpTokenRequestDto +import com.awan.app.core.network.dto.mcp.CreatedMcpTokenResponseDto +import com.awan.app.core.network.dto.mcp.McpConnectionDetailsDto +import com.awan.app.core.network.dto.mcp.McpTokenResponseDto +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 org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +private class FakeMcpApiService : McpApiService { + var connectionDetailsDto = McpConnectionDetailsDto( + mcpUrl = "https://mcp.awan.com", + clientId = "client-123", + ) + var tokensList = mutableListOf() + var createdTokenResponse = CreatedMcpTokenResponseDto( + id = "token-1", + name = "Default Token", + rawToken = "raw-secret-123", + maskedToken = "mcp_...123", + createdAt = "2026-08-11T00:00:00Z", + ) + var shouldFailWithException: Exception? = null + var lastCreatedName: String? = null + var lastDeletedId: String? = null + var lastRegeneratedId: String? = null + + override suspend fun getConnectionDetails(): McpConnectionDetailsDto { + shouldFailWithException?.let { throw it } + return connectionDetailsDto + } + + override suspend fun getTokens(): List { + shouldFailWithException?.let { throw it } + return tokensList + } + + override suspend fun createToken(request: CreateMcpTokenRequestDto): CreatedMcpTokenResponseDto { + shouldFailWithException?.let { throw it } + lastCreatedName = request.name + return createdTokenResponse.copy(name = request.name) + } + + override suspend fun deleteToken(id: String) { + shouldFailWithException?.let { throw it } + lastDeletedId = id + } + + override suspend fun regenerateToken(id: String): CreatedMcpTokenResponseDto { + shouldFailWithException?.let { throw it } + lastRegeneratedId = id + return createdTokenResponse + } +} + +private class FakeMcpTokenDao : McpTokenDao { + private val tokensState = MutableStateFlow>(emptyList()) + val storedTokens: List get() = tokensState.value + + override fun getMcpTokens(): Flow> = tokensState + + override suspend fun upsertMcpTokens(tokens: List) { + 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() + } +} + +@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 success when online`() = runTest(testDispatcher) { + val apiService = FakeMcpApiService() + val repository = buildRepository(apiService = apiService, monitor = onlineMonitor) + + val result = repository.getMcpConnectionDetails().first() + + assertTrue(result is Result.Success) + val details = (result as Result.Success).data + assertEquals("https://mcp.awan.com", details.mcpUrl) + assertEquals("client-123", details.clientId) + } + + @Test + fun `getMcpConnectionDetails returns network error when offline`() = runTest(testDispatcher) { + val repository = buildRepository(monitor = offlineMonitor) + + val result = repository.getMcpConnectionDetails().first() + + assertTrue(result is Result.Error) + assertTrue((result as Result.Error).error is AppError.Network) + } + + @Test + fun `getMcpTokens fetches remote and updates Room when online`() = runTest(testDispatcher) { + val apiService = FakeMcpApiService().apply { + tokensList.add( + McpTokenResponseDto( + id = "token-1", + name = "Claude Desktop", + maskedToken = "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) + } + + @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 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 deletes token from network and 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.lastDeletedId) + 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 updates token in network and Room`() = runTest(testDispatcher) { + val apiService = FakeMcpApiService().apply { + createdTokenResponse = CreatedMcpTokenResponseDto( + id = "token-1", + name = "Claude Desktop", + rawToken = "new-raw-secret", + maskedToken = "mcp_...new", + 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("token-1", apiService.lastRegeneratedId) + assertEquals("mcp_...new", tokenDao.storedTokens.first().maskedToken) + } +} 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..b531812c --- /dev/null +++ b/core/database/src/main/kotlin/com/awan/app/core/database/dao/McpTokenDao.kt @@ -0,0 +1,22 @@ +package com.awan.app.core.database.dao + +import androidx.room.Dao +import androidx.room.Query +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() +} 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/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..8034e347 --- /dev/null +++ b/core/network/src/main/kotlin/com/awan/app/core/network/api/McpApiService.kt @@ -0,0 +1,28 @@ +package com.awan.app.core.network.api + +import com.awan.app.core.network.dto.mcp.CreateMcpTokenRequestDto +import com.awan.app.core.network.dto.mcp.CreatedMcpTokenResponseDto +import com.awan.app.core.network.dto.mcp.McpConnectionDetailsDto +import com.awan.app.core.network.dto.mcp.McpTokenResponseDto +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path + +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 +} 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..2ea52448 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 @@ -176,6 +176,11 @@ object NetworkModule { fun providesGoalApiService(retrofit: Retrofit): GoalApiService = retrofit.create(GoalApiService::class.java) + @Provides + @Singleton + fun providesMcpApiService(retrofit: Retrofit): com.awan.app.core.network.api.McpApiService = + retrofit.create(com.awan.app.core.network.api.McpApiService::class.java) + @Provides @Singleton fun providesDeviceIdProvider( diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreateMcpTokenRequestDto.kt b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreateMcpTokenRequestDto.kt new file mode 100644 index 00000000..cace98e2 --- /dev/null +++ b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreateMcpTokenRequestDto.kt @@ -0,0 +1,9 @@ +package com.awan.app.core.network.dto.mcp + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class CreateMcpTokenRequestDto( + @SerialName("name") val name: String, +) diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreatedMcpTokenResponseDto.kt b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreatedMcpTokenResponseDto.kt new file mode 100644 index 00000000..37af60f8 --- /dev/null +++ b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreatedMcpTokenResponseDto.kt @@ -0,0 +1,13 @@ +package com.awan.app.core.network.dto.mcp + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@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, +) diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpConnectionDetailsDto.kt b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpConnectionDetailsDto.kt new file mode 100644 index 00000000..9ce2cec4 --- /dev/null +++ b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpConnectionDetailsDto.kt @@ -0,0 +1,10 @@ +package com.awan.app.core.network.dto.mcp + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class McpConnectionDetailsDto( + @SerialName("mcpUrl") val mcpUrl: String, + @SerialName("clientId") val clientId: String, +) diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpTokenResponseDto.kt b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpTokenResponseDto.kt new file mode 100644 index 00000000..3ef767e2 --- /dev/null +++ b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpTokenResponseDto.kt @@ -0,0 +1,13 @@ +package com.awan.app.core.network.dto.mcp + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@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, +) From e6214e31790684e4e75ea31a7bfe1b06e527c4dd Mon Sep 17 00:00:00 2001 From: "ZEIAD-LAPTOP\\zeiad" Date: Tue, 11 Aug 2026 04:09:37 +0300 Subject: [PATCH 02/13] AWAN-210: Add MCP domain models, use cases, UI screens, ViewModel, and navigation wiring --- app/src/main/java/com/awan/app/AwanApp.kt | 4 +- core/domain/build.gradle.kts | 1 + .../core/domain/mcp/model/CreatedMcpToken.kt | 9 + .../domain/mcp/model/McpConnectionDetails.kt | 6 + .../app/core/domain/mcp/model/McpToken.kt | 9 + .../domain/mcp/repository/McpRepository.kt | 15 + .../mcp/usecase/CreateMcpTokenUseCase.kt | 12 + .../mcp/usecase/DeleteMcpTokenUseCase.kt | 11 + .../usecase/GetMcpConnectionDetailsUseCase.kt | 13 + .../domain/mcp/usecase/GetMcpTokensUseCase.kt | 13 + .../mcp/usecase/RegenerateMcpTokenUseCase.kt | 12 + .../domain/mcp/usecase/McpUseCasesTest.kt | 147 +++++ .../mcp/2026-08-11-mcp-integration-plan.md | 520 ++++++++++++++++++ .../awan/feature/profile/api/McpInfoRoute.kt | 7 + .../feature/profile/api/McpSettingsRoute.kt | 7 + .../impl/navigation/McpInfoRouteScreen.kt | 13 + .../impl/navigation/McpSettingsRouteScreen.kt | 24 + .../impl/navigation/ProfileEntryProvider.kt | 22 +- .../impl/navigation/ProfileRouteScreen.kt | 7 +- .../impl/presentation/McpSettingsAction.kt | 10 + .../impl/presentation/McpSettingsEvent.kt | 11 + .../impl/presentation/McpSettingsState.kt | 16 + .../impl/presentation/McpSettingsViewModel.kt | 151 +++++ .../feature/profile/impl/ui/McpInfoScreen.kt | 228 ++++++++ .../profile/impl/ui/McpSettingsScreen.kt | 459 ++++++++++++++++ .../impl/ui/components/CreatedTokenModal.kt | 149 +++++ .../impl/ui/components/SettingsCard.kt | 7 + .../impl/src/main/res/values-ar/strings.xml | 24 + .../impl/src/main/res/values/strings.xml | 24 + .../presentation/McpSettingsViewModelTest.kt | 163 ++++++ 30 files changed, 2088 insertions(+), 6 deletions(-) create mode 100644 core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/model/CreatedMcpToken.kt create mode 100644 core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/model/McpConnectionDetails.kt create mode 100644 core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/model/McpToken.kt create mode 100644 core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/repository/McpRepository.kt create mode 100644 core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/usecase/CreateMcpTokenUseCase.kt create mode 100644 core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/usecase/DeleteMcpTokenUseCase.kt create mode 100644 core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/usecase/GetMcpConnectionDetailsUseCase.kt create mode 100644 core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/usecase/GetMcpTokensUseCase.kt create mode 100644 core/domain/src/main/kotlin/com/awan/app/core/domain/mcp/usecase/RegenerateMcpTokenUseCase.kt create mode 100644 core/domain/src/test/kotlin/com/awan/app/core/domain/mcp/usecase/McpUseCasesTest.kt create mode 100644 docs/feature/mcp/2026-08-11-mcp-integration-plan.md create mode 100644 feature/profile/api/src/main/java/com/awan/feature/profile/api/McpInfoRoute.kt create mode 100644 feature/profile/api/src/main/java/com/awan/feature/profile/api/McpSettingsRoute.kt create mode 100644 feature/profile/impl/src/main/java/com/awan/feature/profile/impl/navigation/McpInfoRouteScreen.kt create mode 100644 feature/profile/impl/src/main/java/com/awan/feature/profile/impl/navigation/McpSettingsRouteScreen.kt create mode 100644 feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsAction.kt create mode 100644 feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsEvent.kt create mode 100644 feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsState.kt create mode 100644 feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsViewModel.kt create mode 100644 feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/McpInfoScreen.kt create mode 100644 feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/McpSettingsScreen.kt create mode 100644 feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/components/CreatedTokenModal.kt create mode 100644 feature/profile/impl/src/test/java/com/awan/feature/profile/impl/presentation/McpSettingsViewModelTest.kt diff --git a/app/src/main/java/com/awan/app/AwanApp.kt b/app/src/main/java/com/awan/app/AwanApp.kt index 3c0de9e2..5ab1b5df 100644 --- a/app/src/main/java/com/awan/app/AwanApp.kt +++ b/app/src/main/java/com/awan/app/AwanApp.kt @@ -250,7 +250,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(com.awan.feature.profile.api.McpSettingsRoute) }, + onNavigateToMcpInfo = { navigator.navigate(com.awan.feature.profile.api.McpInfoRoute) }, ) goalPreviewEntry( onBack = { navigator.goBack() }, 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/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/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..8d4ae147 --- /dev/null +++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/navigation/McpInfoRouteScreen.kt @@ -0,0 +1,13 @@ +package com.awan.feature.profile.impl.navigation + +import androidx.compose.runtime.Composable +import com.awan.feature.profile.impl.ui.McpInfoScreen + +@Composable +fun McpInfoRouteScreen( + onBack: () -> Unit, +) { + McpInfoScreen( + 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..744ac759 --- /dev/null +++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsAction.kt @@ -0,0 +1,10 @@ +package com.awan.feature.profile.impl.presentation + +sealed interface 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..6816efbf --- /dev/null +++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsState.kt @@ -0,0 +1,16 @@ +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 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..f4a89290 --- /dev/null +++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsViewModel.kt @@ -0,0 +1,151 @@ +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.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() + + init { + loadData() + } + + fun onAction(action: McpSettingsAction) { + when (action) { + 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() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + getMcpConnectionDetailsUseCase().collect { result -> + when (result) { + is Result.Success -> { + _uiState.update { it.copy(connectionDetails = result.data, isLoading = false) } + } + is Result.Error -> { + val uiError = ProfileErrorMapper.mapToUiText(result.error) + _uiState.update { it.copy(error = uiError, isLoading = false) } + } + Result.Loading -> { + _uiState.update { it.copy(isLoading = true) } + } + } + } + } + + viewModelScope.launch { + getMcpTokensUseCase().collect { result -> + when (result) { + is Result.Success -> { + _uiState.update { it.copy(tokens = result.data) } + } + is Result.Error -> { + val uiError = ProfileErrorMapper.mapToUiText(result.error) + _uiState.update { it.copy(error = uiError) } + } + 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, + ) + } + _events.send(McpSettingsEvent.TokenCreated(created)) + } + is Result.Error -> { + val uiError = ProfileErrorMapper.mapToUiText(result.error) + _uiState.update { it.copy(isCreating = 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(tokens = state.tokens.filterNot { it.id == id }) + } + _events.send(McpSettingsEvent.TokenDeleted) + } + is Result.Error -> { + val uiError = ProfileErrorMapper.mapToUiText(result.error) + _uiState.update { it.copy(error = uiError) } + _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) + } + _events.send(McpSettingsEvent.TokenRegenerated(regenerated)) + } + is Result.Error -> { + val uiError = ProfileErrorMapper.mapToUiText(result.error) + _uiState.update { it.copy(error = uiError) } + _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..9bfecf81 --- /dev/null +++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/McpInfoScreen.kt @@ -0,0 +1,228 @@ +package com.awan.feature.profile.impl.ui + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +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.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.LocalContext +import androidx.compose.ui.res.stringResource +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 + +@Composable +fun McpInfoScreen( + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val copiedToastMessage = stringResource(ProfileR.string.profile_mcp_token_copied) + + val claudeSnippet = """ + { + "mcpServers": { + "awan": { + "command": "npx", + "args": [ + "-y", + "@awan/mcp-server", + "--url", "https://mcp.awan.app/v1", + "--token", "YOUR_API_TOKEN" + ] + } + } + } + """.trimIndent() + + val cursorSnippet = """ + { + "mcp": { + "servers": { + "awan": { + "url": "https://mcp.awan.app/v1", + "headers": { + "Authorization": "Bearer YOUR_API_TOKEN" + } + } + } + } + } + """.trimIndent() + + Scaffold( + topBar = { + Row( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + 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 = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Setup steps card + AwanCard( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(AwanTheme.spacing.md) + ) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + AwanText( + text = "Setup Instructions", + 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 + ) + AwanText( + text = stringResource(ProfileR.string.profile_mcp_info_step3), + style = AwanTheme.styles.bodyText + ) + } + } + + // Claude Desktop Guide + AwanCard( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(AwanTheme.spacing.md) + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + AwanText( + text = "Claude Desktop (claude_desktop_config.json)", + style = AwanTheme.styles.headingText + ) + IconButton( + onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("Claude Config", claudeSnippet)) + Toast.makeText(context, copiedToastMessage, Toast.LENGTH_SHORT).show() + }, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Copy Claude Snippet", + tint = AwanTheme.colors.sky, + modifier = Modifier.size(16.dp) + ) + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(AwanTheme.colors.disabledSurface) + .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(8.dp)) + .padding(12.dp) + ) { + 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(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + AwanText( + text = "Cursor IDE Setup", + style = AwanTheme.styles.headingText + ) + IconButton( + onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("Cursor Config", cursorSnippet)) + Toast.makeText(context, copiedToastMessage, Toast.LENGTH_SHORT).show() + }, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Copy Cursor Snippet", + tint = AwanTheme.colors.sky, + modifier = Modifier.size(16.dp) + ) + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(AwanTheme.colors.disabledSurface) + .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(8.dp)) + .padding(12.dp) + ) { + AwanText( + text = cursorSnippet, + 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..6ff1aaba --- /dev/null +++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/McpSettingsScreen.kt @@ -0,0 +1,459 @@ +package com.awan.feature.profile.impl.ui + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +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.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.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +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 + +@Composable +fun McpSettingsScreen( + uiState: McpSettingsState, + onAction: (McpSettingsAction) -> Unit, + onInfoClick: () -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val copiedToastMessage = stringResource(ProfileR.string.profile_mcp_token_copied) + var showAddTokenDialog by remember { mutableStateOf(false) } + var newTokenName by remember { mutableStateOf("") } + var deletingToken by remember { mutableStateOf(null) } + var regeneratingToken by remember { mutableStateOf(null) } + + if (uiState.createdToken != null) { + CreatedTokenModal( + createdToken = uiState.createdToken, + onDismiss = { onAction(McpSettingsAction.DismissCreatedModal) } + ) + } + + if (showAddTokenDialog) { + Dialog(onDismissRequest = { showAddTokenDialog = false }) { + AwanCard( + modifier = Modifier + .fillMaxWidth() + .padding(AwanTheme.spacing.md), + contentPadding = PaddingValues(AwanTheme.spacing.xl) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.md) + ) { + AwanText( + text = stringResource(ProfileR.string.profile_mcp_add_token), + style = AwanTheme.styles.titleText + ) + AwanTextField( + value = newTokenName, + onValueChange = { newTokenName = 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 = { + showAddTokenDialog = false + newTokenName = "" + }, + modifier = Modifier.weight(1f), + variant = AwanButtonVariant.Quiet + ) { + AwanText(stringResource(ProfileR.string.profile_cancel)) + } + AwanButton( + onClick = { + if (newTokenName.isNotBlank()) { + onAction(McpSettingsAction.CreateToken(newTokenName)) + showAddTokenDialog = false + newTokenName = "" + } + }, + modifier = Modifier.weight(1f), + enabled = newTokenName.isNotBlank() && !uiState.isCreating + ) { + if (uiState.isCreating) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), color = AwanTheme.colors.surface) + } else { + AwanText(stringResource(ProfileR.string.profile_mcp_add_token)) + } + } + } + } + } + } + } + + if (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(deletingToken!!.id)) + deletingToken = null + }, + secondaryLabel = stringResource(ProfileR.string.profile_cancel), + onSecondary = { deletingToken = null }, + onDismiss = { deletingToken = null } + ) + } + + if (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(regeneratingToken!!.id)) + regeneratingToken = null + }, + secondaryLabel = stringResource(ProfileR.string.profile_cancel), + onSecondary = { regeneratingToken = null }, + onDismiss = { regeneratingToken = null } + ) + } + + Scaffold( + topBar = { + Row( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = 16.dp, vertical = 12.dp), + 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 = "MCP Setup 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 = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Connection Details Card + AwanCard( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(AwanTheme.spacing.md) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + 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" + val clientId = details?.clientId ?: "awan-android-client" + + // MCP URL Row + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + AwanText( + text = stringResource(ProfileR.string.profile_mcp_url_label), + style = AwanTheme.styles.captionText + ) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(AwanTheme.colors.disabledSurface) + .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(8.dp)) + .padding(horizontal = 12.dp, vertical = 8.dp), + 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 = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("MCP URL", mcpUrl)) + Toast.makeText(context, copiedToastMessage, Toast.LENGTH_SHORT).show() + }, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Copy URL", + tint = AwanTheme.colors.textSecondary, + modifier = Modifier.size(16.dp) + ) + } + } + } + + // Client ID Row + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + AwanText( + text = stringResource(ProfileR.string.profile_mcp_client_id_label), + style = AwanTheme.styles.captionText + ) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(AwanTheme.colors.disabledSurface) + .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(8.dp)) + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + AwanText( + text = clientId, + style = AwanTheme.styles.bodyText.let { it.copy(textStyle = it.textStyle.copy(fontFamily = FontFamily.Monospace)) }, + modifier = Modifier.weight(1f) + ) + IconButton( + onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("Client ID", clientId)) + Toast.makeText(context, copiedToastMessage, Toast.LENGTH_SHORT).show() + }, + modifier = Modifier.size(28.dp) + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Copy Client ID", + tint = AwanTheme.colors.textSecondary, + modifier = Modifier.size(16.dp) + ) + } + } + } + } + } + + // Tokens Card + AwanCard( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(AwanTheme.spacing.md) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + 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 = { showAddTokenDialog = true }, + variant = AwanButtonVariant.Quiet + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + AwanText(stringResource(ProfileR.string.profile_mcp_add_token)) + } + } + } + + if (uiState.tokens.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center + ) { + AwanText( + text = "No tokens added yet", + style = AwanTheme.styles.bodySecondaryText + ) + } + } else { + uiState.tokens.forEach { token -> + TokenItemRow( + token = token, + onRegenerate = { regeneratingToken = token }, + onDelete = { deletingToken = token } + ) + } + } + + // Security notice + AwanText( + text = stringResource(ProfileR.string.profile_mcp_token_obscured_notice) + ". " + + stringResource(ProfileR.string.profile_mcp_token_copy_disabled), + style = AwanTheme.styles.captionText, + modifier = Modifier.padding(top = 4.dp) + ) + } + } + } + + if (uiState.error != null) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(20.dp), + 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(12.dp)) + .background(AwanTheme.colors.disabledSurface) + .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(12.dp)) + .padding(12.dp) + ) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + AwanText( + text = token.name, + style = AwanTheme.styles.bodyText + ) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + IconButton( + onClick = onRegenerate, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Regenerate Token", + tint = AwanTheme.colors.sky, + modifier = Modifier.size(18.dp) + ) + } + IconButton( + onClick = onDelete, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete Token", + 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 = 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..bff02c06 --- /dev/null +++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/components/CreatedTokenModal.kt @@ -0,0 +1,149 @@ +package com.awan.feature.profile.impl.ui.components + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +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.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +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.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 + +@Composable +fun CreatedTokenModal( + createdToken: CreatedMcpToken, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val copiedToastMessage = stringResource(ProfileR.string.profile_mcp_token_copied) + var copied by remember { mutableStateOf(false) } + + Dialog(onDismissRequest = onDismiss) { + AwanCard( + modifier = modifier + .fillMaxWidth() + .padding(AwanTheme.spacing.md), + contentPadding = PaddingValues(AwanTheme.spacing.xl) + ) { + Column( + 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(12.dp)) + .background(AwanTheme.colors.destructive.copy(alpha = 0.1f)) + .border(1.dp, AwanTheme.colors.destructive, RoundedCornerShape(12.dp)) + .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(20.dp) + ) + 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(12.dp)) + .background(AwanTheme.colors.disabledSurface) + .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(12.dp)) + .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 = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("MCP Token", createdToken.rawToken) + clipboard.setPrimaryClip(clip) + 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(16.dp) + ) + 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..7527c672 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,28 @@ ج س ح + + + تكامل MCP + ربط المساعدين الذكيين (Claude, Cursor) عبر بروتوكول MCP + تفاصيل الاتصال + رابط خادم MCP + معرف العميل (Client ID) + رموز الوصول (Tokens) + إضافة رمز جديد + اسم الرمز (مثال: Claude Desktop) + تم إخفاء المفتاح لأسباب أمنية + يمكن نسخ الرمز فقط عند إنشائه لأول مرة + تم إنشاء الرمز بنجاح! + احرص على نسخ رمز الوصول الخاص بك الآن. لن تتمكن من رؤيته مرة أخرى! + نسخ الرمز + تم نسخ الرمز إلى الحافظة + حذف الرمز؟ + هل أنت تأكد من حذف رمز MCP هذا؟ سيفقد المساعد الذكي الوصول فوراً. + إعادة إنشاء الرمز؟ + إعادة إنشاء الرمز ستلغي المفتاح الحالي. ستحتاج إلى تحديثه في المساعد الذكي. + كيفية ربط مساعدك الذكي + 1. انسخ رابط خادم MCP ومعرف العميل أعلاه. + 2. أنشئ رمز وصول واحفظ المفتاح فوراً. + 3. قم بتضمين الإعدادات في ملف التكوين (مثل claude_desktop_config.json). diff --git a/feature/profile/impl/src/main/res/values/strings.xml b/feature/profile/impl/src/main/res/values/strings.xml index 6f437789..9770cd00 100644 --- a/feature/profile/impl/src/main/res/values/strings.xml +++ b/feature/profile/impl/src/main/res/values/strings.xml @@ -184,4 +184,28 @@ Fri Sat Sun + + + 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). 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..261d7b6e --- /dev/null +++ b/feature/profile/impl/src/test/java/com/awan/feature/profile/impl/presentation/McpSettingsViewModelTest.kt @@ -0,0 +1,163 @@ +package com.awan.feature.profile.impl.presentation + +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 `CreateToken action creates token, sets createdToken, and emits TokenCreated event`() = runTest(testDispatcher) { + val events = mutableListOf() + val job = launch { viewModel.events.toList(events) } + + 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) + assertEquals(1, events.size) + assert(events.first() is McpSettingsEvent.TokenCreated) + + job.cancel() + } + + @Test + fun `DeleteToken action removes token from state and emits TokenDeleted event`() = runTest(testDispatcher) { + val events = mutableListOf() + val job = launch { viewModel.events.toList(events) } + + viewModel.onAction(McpSettingsAction.DeleteToken("token-1")) + + val state = viewModel.uiState.value + assertEquals(0, state.tokens.size) + assertEquals(1, events.size) + assert(events.first() is McpSettingsEvent.TokenDeleted) + + job.cancel() + } + + @Test + fun `RegenerateToken action sets new createdToken and emits TokenRegenerated event`() = runTest(testDispatcher) { + val events = mutableListOf() + val job = launch { viewModel.events.toList(events) } + + viewModel.onAction(McpSettingsAction.RegenerateToken("token-1")) + + val state = viewModel.uiState.value + assertNotNull(state.createdToken) + assertEquals("raw_regenerated_token-1", state.createdToken?.rawToken) + 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 { + 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 { + 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) + } + } +} From 08a01564be7e45e5b89583f6ebcb60c03db4602e Mon Sep 17 00:00:00 2001 From: "ZEIAD-LAPTOP\\zeiad" Date: Tue, 11 Aug 2026 07:57:14 +0300 Subject: [PATCH 03/13] AWAN-210: Remediate deep-review issues - atomic DB transaction, preserve raw token on DB write error, state hoisting, LocalClipboardManager, accessibility strings & design system spacing tokens --- .../app/core/data/mcp/mapper/McpMappers.kt | 30 + .../data/mcp/repository/McpRepositoryImpl.kt | 72 +- .../core/data/mcp/McpRepositoryImplTest.kt | 49 +- .../awan/app/core/database/dao/McpTokenDao.kt | 7 + db_module.txt | Bin 0 -> 7082 bytes diff.patch | Bin 0 -> 338000 bytes diff.txt | 4031 +++++++++++++++++ ...2026-08-11-deep-review-remediation-plan.md | 321 ++ domain_imports.txt | Bin 0 -> 144626 bytes .../impl/navigation/McpSettingsRouteScreen.kt | 16 + .../impl/presentation/McpSettingsAction.kt | 9 + .../impl/presentation/McpSettingsState.kt | 4 + .../impl/presentation/McpSettingsViewModel.kt | 23 +- .../feature/profile/impl/ui/McpInfoScreen.kt | 46 +- .../profile/impl/ui/McpSettingsScreen.kt | 134 +- .../impl/ui/components/CreatedTokenModal.kt | 22 +- .../impl/src/main/res/values-ar/strings.xml | 7 + .../impl/src/main/res/values/strings.xml | 7 + .../presentation/McpSettingsViewModelTest.kt | 49 +- mcp_diff.patch | 4031 +++++++++++++++++ nav_routes.txt | 0 vm_repo.txt | Bin 0 -> 2014 bytes 22 files changed, 8697 insertions(+), 161 deletions(-) create mode 100644 core/data/src/main/kotlin/com/awan/app/core/data/mcp/mapper/McpMappers.kt create mode 100644 db_module.txt create mode 100644 diff.patch create mode 100644 diff.txt create mode 100644 docs/feature/mcp/2026-08-11-deep-review-remediation-plan.md create mode 100644 domain_imports.txt create mode 100644 mcp_diff.patch create mode 100644 nav_routes.txt create mode 100644 vm_repo.txt 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..95546db7 --- /dev/null +++ b/core/data/src/main/kotlin/com/awan/app/core/data/mcp/mapper/McpMappers.kt @@ -0,0 +1,30 @@ +package com.awan.app.core.data.mcp.mapper + +import com.awan.app.core.database.model.McpTokenEntity +import com.awan.app.core.domain.mcp.model.McpToken +import com.awan.app.core.network.dto.mcp.CreatedMcpTokenResponseDto +import com.awan.app.core.network.dto.mcp.McpTokenResponseDto + +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, +) diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/mcp/repository/McpRepositoryImpl.kt b/core/data/src/main/kotlin/com/awan/app/core/data/mcp/repository/McpRepositoryImpl.kt index 75506594..1a6ff400 100644 --- a/core/data/src/main/kotlin/com/awan/app/core/data/mcp/repository/McpRepositoryImpl.kt +++ b/core/data/src/main/kotlin/com/awan/app/core/data/mcp/repository/McpRepositoryImpl.kt @@ -4,8 +4,9 @@ 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.database.model.McpTokenEntity 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 @@ -14,6 +15,7 @@ import com.awan.app.core.domain.network.NetworkConnectivityMonitor import com.awan.app.core.network.api.McpApiService import com.awan.app.core.network.dto.mcp.CreateMcpTokenRequestDto 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 @@ -50,34 +52,16 @@ class McpRepositoryImpl @Inject constructor( if (connectivityMonitor.isCurrentlyOnline()) { try { val dtos = mcpApiService.getTokens() - val entities = dtos.map { dto -> - McpTokenEntity( - id = dto.id, - name = dto.name, - maskedToken = dto.maskedToken, - createdAt = dto.createdAt, - lastUsedAt = dto.lastUsedAt, - ) - } - mcpTokenDao.clearAll() - mcpTokenDao.upsertMcpTokens(entities) - } catch (_: Exception) { + 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 { entity -> - McpToken( - id = entity.id, - name = entity.name, - maskedToken = entity.maskedToken, - createdAt = entity.createdAt, - lastUsedAt = entity.lastUsedAt, - ) - } - ) + Result.Success(entities.map { it.toDomain() }) } ) }.flowOn(ioDispatcher) @@ -88,21 +72,20 @@ class McpRepositoryImpl @Inject constructor( } return safeApiCall(ioDispatcher) { val dto = mcpApiService.createToken(CreateMcpTokenRequestDto(name = name)) - val entity = McpTokenEntity( - id = dto.id, - name = dto.name, - maskedToken = dto.maskedToken, - createdAt = dto.createdAt, - lastUsedAt = null, - ) - mcpTokenDao.upsertMcpTokens(listOf(entity)) - CreatedMcpToken( + val createdToken = CreatedMcpToken( id = dto.id, name = dto.name, rawToken = dto.rawToken, maskedToken = dto.maskedToken, createdAt = dto.createdAt, ) + 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 } } @@ -114,7 +97,11 @@ class McpRepositoryImpl @Inject constructor( mcpApiService.deleteToken(id) } if (result is Result.Success) { - mcpTokenDao.deleteMcpToken(id) + try { + mcpTokenDao.deleteMcpToken(id) + } catch (e: Exception) { + if (e is CancellationException) throw e + } } return result } @@ -125,21 +112,20 @@ class McpRepositoryImpl @Inject constructor( } return safeApiCall(ioDispatcher) { val dto = mcpApiService.regenerateToken(id) - val entity = McpTokenEntity( - id = dto.id, - name = dto.name, - maskedToken = dto.maskedToken, - createdAt = dto.createdAt, - lastUsedAt = null, - ) - mcpTokenDao.upsertMcpTokens(listOf(entity)) - CreatedMcpToken( + val createdToken = CreatedMcpToken( id = dto.id, name = dto.name, rawToken = dto.rawToken, maskedToken = dto.maskedToken, createdAt = dto.createdAt, ) + 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 } } } diff --git a/core/data/src/test/java/com/awan/app/core/data/mcp/McpRepositoryImplTest.kt b/core/data/src/test/java/com/awan/app/core/data/mcp/McpRepositoryImplTest.kt index 4f6ccce1..828a5557 100644 --- a/core/data/src/test/java/com/awan/app/core/data/mcp/McpRepositoryImplTest.kt +++ b/core/data/src/test/java/com/awan/app/core/data/mcp/McpRepositoryImplTest.kt @@ -71,10 +71,15 @@ private class FakeMcpApiService : McpApiService { 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 } @@ -90,6 +95,12 @@ private class FakeMcpTokenDao : McpTokenDao { override suspend fun clearAll() { tokensState.value = emptyList() } + + override suspend fun replaceMcpTokens(tokens: List) { + replaceCount++ + clearAll() + upsertMcpTokens(tokens) + } } @OptIn(ExperimentalCoroutinesApi::class) @@ -142,7 +153,7 @@ class McpRepositoryImplTest { } @Test - fun `getMcpTokens fetches remote and updates Room when online`() = runTest(testDispatcher) { + fun `getMcpTokens fetches remote and updates Room atomically when online`() = runTest(testDispatcher) { val apiService = FakeMcpApiService().apply { tokensList.add( McpTokenResponseDto( @@ -164,6 +175,7 @@ class McpRepositoryImplTest { assertEquals("Claude Desktop", tokens.first().name) assertEquals(1, tokenDao.storedTokens.size) assertEquals("Claude Desktop", tokenDao.storedTokens.first().name) + assertEquals(1, tokenDao.replaceCount) } @Test @@ -207,6 +219,20 @@ class McpRepositoryImplTest { 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) @@ -274,4 +300,25 @@ class McpRepositoryImplTest { assertEquals("token-1", apiService.lastRegeneratedId) assertEquals("mcp_...new", tokenDao.storedTokens.first().maskedToken) } + + @Test + fun `regenerateMcpToken preserves raw token success even if Room write fails`() = runTest(testDispatcher) { + val apiService = FakeMcpApiService().apply { + createdTokenResponse = CreatedMcpTokenResponseDto( + id = "token-1", + name = "Claude Desktop", + rawToken = "new-raw-secret", + maskedToken = "mcp_...new", + 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/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 index b531812c..e66ace8c 100644 --- 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 @@ -2,6 +2,7 @@ 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 @@ -19,4 +20,10 @@ interface McpTokenDao { @Query("DELETE FROM mcp_tokens") suspend fun clearAll() + + @Transaction + suspend fun replaceMcpTokens(tokens: List) { + clearAll() + upsertMcpTokens(tokens) + } } diff --git a/db_module.txt b/db_module.txt new file mode 100644 index 0000000000000000000000000000000000000000..83b6c1bdb70ee8565ff266f93c74b3bab34c227c GIT binary patch literal 7082 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

C2UyxPMZ%a&o_r;x+InZCshT6?k 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 literal 0 HcmV?d00001 diff --git a/diff.txt b/diff.txt new file mode 100644 index 00000000..36b08960 --- /dev/null +++ b/diff.txt @@ -0,0 +1,4031 @@ +diff --git a/app/src/main/java/com/awan/app/AwanApp.kt b/app/src/main/java/com/awan/app/AwanApp.kt +index 3c0de9e..5ab1b5d 100644 +--- a/app/src/main/java/com/awan/app/AwanApp.kt ++++ b/app/src/main/java/com/awan/app/AwanApp.kt +@@ -250,7 +250,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(com.awan.feature.profile.api.McpSettingsRoute) }, ++ onNavigateToMcpInfo = { navigator.navigate(com.awan.feature.profile.api.McpInfoRoute) }, + ) + goalPreviewEntry( + onBack = { navigator.goBack() }, +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 0000000..31521c1 +--- /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/repository/McpRepositoryImpl.kt b/core/data/src/main/kotlin/com/awan/app/core/data/mcp/repository/McpRepositoryImpl.kt +new file mode 100644 +index 0000000..7550659 +--- /dev/null ++++ b/core/data/src/main/kotlin/com/awan/app/core/data/mcp/repository/McpRepositoryImpl.kt +@@ -0,0 +1,145 @@ ++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.database.dao.McpTokenDao ++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.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.api.McpApiService ++import com.awan.app.core.network.dto.mcp.CreateMcpTokenRequestDto ++import com.awan.app.core.network.error.safeApiCall ++import kotlinx.coroutines.CoroutineDispatcher ++import kotlinx.coroutines.flow.Flow ++import kotlinx.coroutines.flow.emitAll ++import kotlinx.coroutines.flow.flow ++import kotlinx.coroutines.flow.flowOn ++import kotlinx.coroutines.flow.map ++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 { ++ if (!connectivityMonitor.isCurrentlyOnline()) { ++ emit(Result.Error(AppError.Network)) ++ return@flow ++ } ++ val result = safeApiCall(ioDispatcher) { ++ val dto = mcpApiService.getConnectionDetails() ++ McpConnectionDetails( ++ mcpUrl = dto.mcpUrl, ++ clientId = dto.clientId, ++ ) ++ } ++ emit(result) ++ }.flowOn(ioDispatcher) ++ ++ override fun getMcpTokens(): Flow>> = flow { ++ if (connectivityMonitor.isCurrentlyOnline()) { ++ try { ++ val dtos = mcpApiService.getTokens() ++ val entities = dtos.map { dto -> ++ McpTokenEntity( ++ id = dto.id, ++ name = dto.name, ++ maskedToken = dto.maskedToken, ++ createdAt = dto.createdAt, ++ lastUsedAt = dto.lastUsedAt, ++ ) ++ } ++ mcpTokenDao.clearAll() ++ mcpTokenDao.upsertMcpTokens(entities) ++ } catch (_: Exception) { ++ // If network sync fails, fallback to Room cached tokens ++ } ++ } ++ emitAll( ++ mcpTokenDao.getMcpTokens().map { entities -> ++ Result.Success( ++ entities.map { entity -> ++ McpToken( ++ id = entity.id, ++ name = entity.name, ++ maskedToken = entity.maskedToken, ++ createdAt = entity.createdAt, ++ lastUsedAt = entity.lastUsedAt, ++ ) ++ } ++ ) ++ } ++ ) ++ }.flowOn(ioDispatcher) ++ ++ override suspend fun createMcpToken(name: String): Result { ++ if (!connectivityMonitor.isCurrentlyOnline()) { ++ return Result.Error(AppError.Network) ++ } ++ return safeApiCall(ioDispatcher) { ++ val dto = mcpApiService.createToken(CreateMcpTokenRequestDto(name = name)) ++ val entity = McpTokenEntity( ++ id = dto.id, ++ name = dto.name, ++ maskedToken = dto.maskedToken, ++ createdAt = dto.createdAt, ++ lastUsedAt = null, ++ ) ++ mcpTokenDao.upsertMcpTokens(listOf(entity)) ++ CreatedMcpToken( ++ id = dto.id, ++ name = dto.name, ++ rawToken = dto.rawToken, ++ maskedToken = dto.maskedToken, ++ createdAt = dto.createdAt, ++ ) ++ } ++ } ++ ++ override suspend fun deleteMcpToken(id: String): Result { ++ if (!connectivityMonitor.isCurrentlyOnline()) { ++ return Result.Error(AppError.Network) ++ } ++ val result = safeApiCall(ioDispatcher) { ++ mcpApiService.deleteToken(id) ++ } ++ if (result is Result.Success) { ++ mcpTokenDao.deleteMcpToken(id) ++ } ++ return result ++ } ++ ++ override suspend fun regenerateMcpToken(id: String): Result { ++ if (!connectivityMonitor.isCurrentlyOnline()) { ++ return Result.Error(AppError.Network) ++ } ++ return safeApiCall(ioDispatcher) { ++ val dto = mcpApiService.regenerateToken(id) ++ val entity = McpTokenEntity( ++ id = dto.id, ++ name = dto.name, ++ maskedToken = dto.maskedToken, ++ createdAt = dto.createdAt, ++ lastUsedAt = null, ++ ) ++ mcpTokenDao.upsertMcpTokens(listOf(entity)) ++ CreatedMcpToken( ++ id = dto.id, ++ name = dto.name, ++ rawToken = dto.rawToken, ++ maskedToken = dto.maskedToken, ++ createdAt = dto.createdAt, ++ ) ++ } ++ } ++} +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 0000000..4f6ccce +--- /dev/null ++++ b/core/data/src/test/java/com/awan/app/core/data/mcp/McpRepositoryImplTest.kt +@@ -0,0 +1,277 @@ ++package com.awan.app.core.data.mcp ++ ++import com.awan.app.core.common.error.AppError ++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.CreateMcpTokenRequestDto ++import com.awan.app.core.network.dto.mcp.CreatedMcpTokenResponseDto ++import com.awan.app.core.network.dto.mcp.McpConnectionDetailsDto ++import com.awan.app.core.network.dto.mcp.McpTokenResponseDto ++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 org.junit.Assert.assertEquals ++import org.junit.Assert.assertTrue ++import org.junit.Test ++ ++private class FakeMcpApiService : McpApiService { ++ var connectionDetailsDto = McpConnectionDetailsDto( ++ mcpUrl = "https://mcp.awan.com", ++ clientId = "client-123", ++ ) ++ var tokensList = mutableListOf() ++ var createdTokenResponse = CreatedMcpTokenResponseDto( ++ id = "token-1", ++ name = "Default Token", ++ rawToken = "raw-secret-123", ++ maskedToken = "mcp_...123", ++ createdAt = "2026-08-11T00:00:00Z", ++ ) ++ var shouldFailWithException: Exception? = null ++ var lastCreatedName: String? = null ++ var lastDeletedId: String? = null ++ var lastRegeneratedId: String? = null ++ ++ override suspend fun getConnectionDetails(): McpConnectionDetailsDto { ++ shouldFailWithException?.let { throw it } ++ return connectionDetailsDto ++ } ++ ++ override suspend fun getTokens(): List { ++ shouldFailWithException?.let { throw it } ++ return tokensList ++ } ++ ++ override suspend fun createToken(request: CreateMcpTokenRequestDto): CreatedMcpTokenResponseDto { ++ shouldFailWithException?.let { throw it } ++ lastCreatedName = request.name ++ return createdTokenResponse.copy(name = request.name) ++ } ++ ++ override suspend fun deleteToken(id: String) { ++ shouldFailWithException?.let { throw it } ++ lastDeletedId = id ++ } ++ ++ override suspend fun regenerateToken(id: String): CreatedMcpTokenResponseDto { ++ shouldFailWithException?.let { throw it } ++ lastRegeneratedId = id ++ return createdTokenResponse ++ } ++} ++ ++private class FakeMcpTokenDao : McpTokenDao { ++ private val tokensState = MutableStateFlow>(emptyList()) ++ val storedTokens: List get() = tokensState.value ++ ++ override fun getMcpTokens(): Flow> = tokensState ++ ++ override suspend fun upsertMcpTokens(tokens: List) { ++ 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() ++ } ++} ++ ++@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 success when online`() = runTest(testDispatcher) { ++ val apiService = FakeMcpApiService() ++ val repository = buildRepository(apiService = apiService, monitor = onlineMonitor) ++ ++ val result = repository.getMcpConnectionDetails().first() ++ ++ assertTrue(result is Result.Success) ++ val details = (result as Result.Success).data ++ assertEquals("https://mcp.awan.com", details.mcpUrl) ++ assertEquals("client-123", details.clientId) ++ } ++ ++ @Test ++ fun `getMcpConnectionDetails returns network error when offline`() = runTest(testDispatcher) { ++ val repository = buildRepository(monitor = offlineMonitor) ++ ++ val result = repository.getMcpConnectionDetails().first() ++ ++ assertTrue(result is Result.Error) ++ assertTrue((result as Result.Error).error is AppError.Network) ++ } ++ ++ @Test ++ fun `getMcpTokens fetches remote and updates Room when online`() = runTest(testDispatcher) { ++ val apiService = FakeMcpApiService().apply { ++ tokensList.add( ++ McpTokenResponseDto( ++ id = "token-1", ++ name = "Claude Desktop", ++ maskedToken = "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) ++ } ++ ++ @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 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 deletes token from network and 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.lastDeletedId) ++ 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 updates token in network and Room`() = runTest(testDispatcher) { ++ val apiService = FakeMcpApiService().apply { ++ createdTokenResponse = CreatedMcpTokenResponseDto( ++ id = "token-1", ++ name = "Claude Desktop", ++ rawToken = "new-raw-secret", ++ maskedToken = "mcp_...new", ++ 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("token-1", apiService.lastRegeneratedId) ++ assertEquals("mcp_...new", tokenDao.storedTokens.first().maskedToken) ++ } ++} +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 0000000..5142afa +--- /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 0695d6b..0a37454 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 0000000..b531812 +--- /dev/null ++++ b/core/database/src/main/kotlin/com/awan/app/core/database/dao/McpTokenDao.kt +@@ -0,0 +1,22 @@ ++package com.awan.app.core.database.dao ++ ++import androidx.room.Dao ++import androidx.room.Query ++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() ++} +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 2788919..4828f5c 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 0000000..9752977 +--- /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/domain/build.gradle.kts b/core/domain/build.gradle.kts +index 4e6c9ba..7251014 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 0000000..98aad99 +--- /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 0000000..e65b3cd +--- /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 0000000..d36fa0e +--- /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 0000000..bee60bd +--- /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 0000000..70f0ce6 +--- /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 0000000..a86f125 +--- /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 0000000..a6d3a9c +--- /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 0000000..d095671 +--- /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 0000000..670ab11 +--- /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 0000000..10f7823 +--- /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/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 0000000..8034e34 +--- /dev/null ++++ b/core/network/src/main/kotlin/com/awan/app/core/network/api/McpApiService.kt +@@ -0,0 +1,28 @@ ++package com.awan.app.core.network.api ++ ++import com.awan.app.core.network.dto.mcp.CreateMcpTokenRequestDto ++import com.awan.app.core.network.dto.mcp.CreatedMcpTokenResponseDto ++import com.awan.app.core.network.dto.mcp.McpConnectionDetailsDto ++import com.awan.app.core.network.dto.mcp.McpTokenResponseDto ++import retrofit2.http.Body ++import retrofit2.http.DELETE ++import retrofit2.http.GET ++import retrofit2.http.POST ++import retrofit2.http.Path ++ ++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 ++} +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 c9a0011..2ea5244 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 +@@ -176,6 +176,11 @@ object NetworkModule { + fun providesGoalApiService(retrofit: Retrofit): GoalApiService = + retrofit.create(GoalApiService::class.java) + ++ @Provides ++ @Singleton ++ fun providesMcpApiService(retrofit: Retrofit): com.awan.app.core.network.api.McpApiService = ++ retrofit.create(com.awan.app.core.network.api.McpApiService::class.java) ++ + @Provides + @Singleton + fun providesDeviceIdProvider( +diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreateMcpTokenRequestDto.kt b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreateMcpTokenRequestDto.kt +new file mode 100644 +index 0000000..cace98e +--- /dev/null ++++ b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreateMcpTokenRequestDto.kt +@@ -0,0 +1,9 @@ ++package com.awan.app.core.network.dto.mcp ++ ++import kotlinx.serialization.SerialName ++import kotlinx.serialization.Serializable ++ ++@Serializable ++data class CreateMcpTokenRequestDto( ++ @SerialName("name") val name: String, ++) +diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreatedMcpTokenResponseDto.kt b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreatedMcpTokenResponseDto.kt +new file mode 100644 +index 0000000..37af60f +--- /dev/null ++++ b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreatedMcpTokenResponseDto.kt +@@ -0,0 +1,13 @@ ++package com.awan.app.core.network.dto.mcp ++ ++import kotlinx.serialization.SerialName ++import kotlinx.serialization.Serializable ++ ++@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, ++) +diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpConnectionDetailsDto.kt b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpConnectionDetailsDto.kt +new file mode 100644 +index 0000000..9ce2cec +--- /dev/null ++++ b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpConnectionDetailsDto.kt +@@ -0,0 +1,10 @@ ++package com.awan.app.core.network.dto.mcp ++ ++import kotlinx.serialization.SerialName ++import kotlinx.serialization.Serializable ++ ++@Serializable ++data class McpConnectionDetailsDto( ++ @SerialName("mcpUrl") val mcpUrl: String, ++ @SerialName("clientId") val clientId: String, ++) +diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpTokenResponseDto.kt b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpTokenResponseDto.kt +new file mode 100644 +index 0000000..3ef767e +--- /dev/null ++++ b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpTokenResponseDto.kt +@@ -0,0 +1,13 @@ ++package com.awan.app.core.network.dto.mcp ++ ++import kotlinx.serialization.SerialName ++import kotlinx.serialization.Serializable ++ ++@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, ++) +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 0000000..a1493fe +--- /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/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 0000000..adecf9a +--- /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 0000000..e978685 +--- /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 0000000..8d4ae14 +--- /dev/null ++++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/navigation/McpInfoRouteScreen.kt +@@ -0,0 +1,13 @@ ++package com.awan.feature.profile.impl.navigation ++ ++import androidx.compose.runtime.Composable ++import com.awan.feature.profile.impl.ui.McpInfoScreen ++ ++@Composable ++fun McpInfoRouteScreen( ++ onBack: () -> Unit, ++) { ++ McpInfoScreen( ++ 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 0000000..0a9adbc +--- /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 cda412e..33029b9 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 3e0db73..03fa4aa 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 0000000..744ac75 +--- /dev/null ++++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsAction.kt +@@ -0,0 +1,10 @@ ++package com.awan.feature.profile.impl.presentation ++ ++sealed interface 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 0000000..cdc461a +--- /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 0000000..6816efb +--- /dev/null ++++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsState.kt +@@ -0,0 +1,16 @@ ++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 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 0000000..f4a8929 +--- /dev/null ++++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsViewModel.kt +@@ -0,0 +1,151 @@ ++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.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() ++ ++ init { ++ loadData() ++ } ++ ++ fun onAction(action: McpSettingsAction) { ++ when (action) { ++ 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() { ++ viewModelScope.launch { ++ _uiState.update { it.copy(isLoading = true, error = null) } ++ getMcpConnectionDetailsUseCase().collect { result -> ++ when (result) { ++ is Result.Success -> { ++ _uiState.update { it.copy(connectionDetails = result.data, isLoading = false) } ++ } ++ is Result.Error -> { ++ val uiError = ProfileErrorMapper.mapToUiText(result.error) ++ _uiState.update { it.copy(error = uiError, isLoading = false) } ++ } ++ Result.Loading -> { ++ _uiState.update { it.copy(isLoading = true) } ++ } ++ } ++ } ++ } ++ ++ viewModelScope.launch { ++ getMcpTokensUseCase().collect { result -> ++ when (result) { ++ is Result.Success -> { ++ _uiState.update { it.copy(tokens = result.data) } ++ } ++ is Result.Error -> { ++ val uiError = ProfileErrorMapper.mapToUiText(result.error) ++ _uiState.update { it.copy(error = uiError) } ++ } ++ 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, ++ ) ++ } ++ _events.send(McpSettingsEvent.TokenCreated(created)) ++ } ++ is Result.Error -> { ++ val uiError = ProfileErrorMapper.mapToUiText(result.error) ++ _uiState.update { it.copy(isCreating = 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(tokens = state.tokens.filterNot { it.id == id }) ++ } ++ _events.send(McpSettingsEvent.TokenDeleted) ++ } ++ is Result.Error -> { ++ val uiError = ProfileErrorMapper.mapToUiText(result.error) ++ _uiState.update { it.copy(error = uiError) } ++ _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) ++ } ++ _events.send(McpSettingsEvent.TokenRegenerated(regenerated)) ++ } ++ is Result.Error -> { ++ val uiError = ProfileErrorMapper.mapToUiText(result.error) ++ _uiState.update { it.copy(error = uiError) } ++ _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 0000000..9bfecf8 +--- /dev/null ++++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/McpInfoScreen.kt +@@ -0,0 +1,228 @@ ++package com.awan.feature.profile.impl.ui ++ ++import android.content.ClipData ++import android.content.ClipboardManager ++import android.content.Context ++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.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.LocalContext ++import androidx.compose.ui.res.stringResource ++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 ++ ++@Composable ++fun McpInfoScreen( ++ onBackClick: () -> Unit, ++ modifier: Modifier = Modifier, ++) { ++ val context = LocalContext.current ++ val copiedToastMessage = stringResource(ProfileR.string.profile_mcp_token_copied) ++ ++ val claudeSnippet = """ ++ { ++ "mcpServers": { ++ "awan": { ++ "command": "npx", ++ "args": [ ++ "-y", ++ "@awan/mcp-server", ++ "--url", "https://mcp.awan.app/v1", ++ "--token", "YOUR_API_TOKEN" ++ ] ++ } ++ } ++ } ++ """.trimIndent() ++ ++ val cursorSnippet = """ ++ { ++ "mcp": { ++ "servers": { ++ "awan": { ++ "url": "https://mcp.awan.app/v1", ++ "headers": { ++ "Authorization": "Bearer YOUR_API_TOKEN" ++ } ++ } ++ } ++ } ++ } ++ """.trimIndent() ++ ++ Scaffold( ++ topBar = { ++ Row( ++ modifier = Modifier ++ .fillMaxWidth() ++ .statusBarsPadding() ++ .padding(horizontal = 16.dp, vertical = 12.dp), ++ horizontalArrangement = Arrangement.spacedBy(16.dp), ++ 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 = 20.dp, vertical = 16.dp), ++ verticalArrangement = Arrangement.spacedBy(16.dp) ++ ) { ++ // Setup steps card ++ AwanCard( ++ modifier = Modifier.fillMaxWidth(), ++ contentPadding = PaddingValues(AwanTheme.spacing.md) ++ ) { ++ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { ++ AwanText( ++ text = "Setup Instructions", ++ 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 ++ ) ++ AwanText( ++ text = stringResource(ProfileR.string.profile_mcp_info_step3), ++ style = AwanTheme.styles.bodyText ++ ) ++ } ++ } ++ ++ // Claude Desktop Guide ++ AwanCard( ++ modifier = Modifier.fillMaxWidth(), ++ contentPadding = PaddingValues(AwanTheme.spacing.md) ++ ) { ++ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { ++ Row( ++ modifier = Modifier.fillMaxWidth(), ++ horizontalArrangement = Arrangement.SpaceBetween, ++ verticalAlignment = Alignment.CenterVertically ++ ) { ++ AwanText( ++ text = "Claude Desktop (claude_desktop_config.json)", ++ style = AwanTheme.styles.headingText ++ ) ++ IconButton( ++ onClick = { ++ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager ++ clipboard.setPrimaryClip(ClipData.newPlainText("Claude Config", claudeSnippet)) ++ Toast.makeText(context, copiedToastMessage, Toast.LENGTH_SHORT).show() ++ }, ++ modifier = Modifier.size(28.dp) ++ ) { ++ Icon( ++ imageVector = Icons.Default.ContentCopy, ++ contentDescription = "Copy Claude Snippet", ++ tint = AwanTheme.colors.sky, ++ modifier = Modifier.size(16.dp) ++ ) ++ } ++ } ++ Box( ++ modifier = Modifier ++ .fillMaxWidth() ++ .clip(RoundedCornerShape(8.dp)) ++ .background(AwanTheme.colors.disabledSurface) ++ .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(8.dp)) ++ .padding(12.dp) ++ ) { ++ 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(8.dp)) { ++ Row( ++ modifier = Modifier.fillMaxWidth(), ++ horizontalArrangement = Arrangement.SpaceBetween, ++ verticalAlignment = Alignment.CenterVertically ++ ) { ++ AwanText( ++ text = "Cursor IDE Setup", ++ style = AwanTheme.styles.headingText ++ ) ++ IconButton( ++ onClick = { ++ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager ++ clipboard.setPrimaryClip(ClipData.newPlainText("Cursor Config", cursorSnippet)) ++ Toast.makeText(context, copiedToastMessage, Toast.LENGTH_SHORT).show() ++ }, ++ modifier = Modifier.size(28.dp) ++ ) { ++ Icon( ++ imageVector = Icons.Default.ContentCopy, ++ contentDescription = "Copy Cursor Snippet", ++ tint = AwanTheme.colors.sky, ++ modifier = Modifier.size(16.dp) ++ ) ++ } ++ } ++ Box( ++ modifier = Modifier ++ .fillMaxWidth() ++ .clip(RoundedCornerShape(8.dp)) ++ .background(AwanTheme.colors.disabledSurface) ++ .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(8.dp)) ++ .padding(12.dp) ++ ) { ++ AwanText( ++ text = cursorSnippet, ++ 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 0000000..6ff1aab +--- /dev/null ++++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/McpSettingsScreen.kt +@@ -0,0 +1,459 @@ ++package com.awan.feature.profile.impl.ui ++ ++import android.content.ClipData ++import android.content.ClipboardManager ++import android.content.Context ++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.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.LocalContext ++import androidx.compose.ui.res.stringResource ++import androidx.compose.ui.text.font.FontFamily ++import androidx.compose.ui.unit.dp ++import androidx.compose.ui.window.Dialog ++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 ++ ++@Composable ++fun McpSettingsScreen( ++ uiState: McpSettingsState, ++ onAction: (McpSettingsAction) -> Unit, ++ onInfoClick: () -> Unit, ++ onBackClick: () -> Unit, ++ modifier: Modifier = Modifier, ++) { ++ val context = LocalContext.current ++ val copiedToastMessage = stringResource(ProfileR.string.profile_mcp_token_copied) ++ var showAddTokenDialog by remember { mutableStateOf(false) } ++ var newTokenName by remember { mutableStateOf("") } ++ var deletingToken by remember { mutableStateOf(null) } ++ var regeneratingToken by remember { mutableStateOf(null) } ++ ++ if (uiState.createdToken != null) { ++ CreatedTokenModal( ++ createdToken = uiState.createdToken, ++ onDismiss = { onAction(McpSettingsAction.DismissCreatedModal) } ++ ) ++ } ++ ++ if (showAddTokenDialog) { ++ Dialog(onDismissRequest = { showAddTokenDialog = false }) { ++ AwanCard( ++ modifier = Modifier ++ .fillMaxWidth() ++ .padding(AwanTheme.spacing.md), ++ contentPadding = PaddingValues(AwanTheme.spacing.xl) ++ ) { ++ Column( ++ verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.md) ++ ) { ++ AwanText( ++ text = stringResource(ProfileR.string.profile_mcp_add_token), ++ style = AwanTheme.styles.titleText ++ ) ++ AwanTextField( ++ value = newTokenName, ++ onValueChange = { newTokenName = 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 = { ++ showAddTokenDialog = false ++ newTokenName = "" ++ }, ++ modifier = Modifier.weight(1f), ++ variant = AwanButtonVariant.Quiet ++ ) { ++ AwanText(stringResource(ProfileR.string.profile_cancel)) ++ } ++ AwanButton( ++ onClick = { ++ if (newTokenName.isNotBlank()) { ++ onAction(McpSettingsAction.CreateToken(newTokenName)) ++ showAddTokenDialog = false ++ newTokenName = "" ++ } ++ }, ++ modifier = Modifier.weight(1f), ++ enabled = newTokenName.isNotBlank() && !uiState.isCreating ++ ) { ++ if (uiState.isCreating) { ++ CircularProgressIndicator(modifier = Modifier.size(16.dp), color = AwanTheme.colors.surface) ++ } else { ++ AwanText(stringResource(ProfileR.string.profile_mcp_add_token)) ++ } ++ } ++ } ++ } ++ } ++ } ++ } ++ ++ if (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(deletingToken!!.id)) ++ deletingToken = null ++ }, ++ secondaryLabel = stringResource(ProfileR.string.profile_cancel), ++ onSecondary = { deletingToken = null }, ++ onDismiss = { deletingToken = null } ++ ) ++ } ++ ++ if (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(regeneratingToken!!.id)) ++ regeneratingToken = null ++ }, ++ secondaryLabel = stringResource(ProfileR.string.profile_cancel), ++ onSecondary = { regeneratingToken = null }, ++ onDismiss = { regeneratingToken = null } ++ ) ++ } ++ ++ Scaffold( ++ topBar = { ++ Row( ++ modifier = Modifier ++ .fillMaxWidth() ++ .statusBarsPadding() ++ .padding(horizontal = 16.dp, vertical = 12.dp), ++ 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 = "MCP Setup 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 = 20.dp, vertical = 16.dp), ++ verticalArrangement = Arrangement.spacedBy(16.dp) ++ ) { ++ // Connection Details Card ++ AwanCard( ++ modifier = Modifier.fillMaxWidth(), ++ contentPadding = PaddingValues(AwanTheme.spacing.md) ++ ) { ++ Column( ++ verticalArrangement = Arrangement.spacedBy(12.dp) ++ ) { ++ 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" ++ val clientId = details?.clientId ?: "awan-android-client" ++ ++ // MCP URL Row ++ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { ++ AwanText( ++ text = stringResource(ProfileR.string.profile_mcp_url_label), ++ style = AwanTheme.styles.captionText ++ ) ++ Row( ++ modifier = Modifier ++ .fillMaxWidth() ++ .clip(RoundedCornerShape(8.dp)) ++ .background(AwanTheme.colors.disabledSurface) ++ .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(8.dp)) ++ .padding(horizontal = 12.dp, vertical = 8.dp), ++ 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 = { ++ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager ++ clipboard.setPrimaryClip(ClipData.newPlainText("MCP URL", mcpUrl)) ++ Toast.makeText(context, copiedToastMessage, Toast.LENGTH_SHORT).show() ++ }, ++ modifier = Modifier.size(28.dp) ++ ) { ++ Icon( ++ imageVector = Icons.Default.ContentCopy, ++ contentDescription = "Copy URL", ++ tint = AwanTheme.colors.textSecondary, ++ modifier = Modifier.size(16.dp) ++ ) ++ } ++ } ++ } ++ ++ // Client ID Row ++ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { ++ AwanText( ++ text = stringResource(ProfileR.string.profile_mcp_client_id_label), ++ style = AwanTheme.styles.captionText ++ ) ++ Row( ++ modifier = Modifier ++ .fillMaxWidth() ++ .clip(RoundedCornerShape(8.dp)) ++ .background(AwanTheme.colors.disabledSurface) ++ .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(8.dp)) ++ .padding(horizontal = 12.dp, vertical = 8.dp), ++ horizontalArrangement = Arrangement.SpaceBetween, ++ verticalAlignment = Alignment.CenterVertically ++ ) { ++ AwanText( ++ text = clientId, ++ style = AwanTheme.styles.bodyText.let { it.copy(textStyle = it.textStyle.copy(fontFamily = FontFamily.Monospace)) }, ++ modifier = Modifier.weight(1f) ++ ) ++ IconButton( ++ onClick = { ++ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager ++ clipboard.setPrimaryClip(ClipData.newPlainText("Client ID", clientId)) ++ Toast.makeText(context, copiedToastMessage, Toast.LENGTH_SHORT).show() ++ }, ++ modifier = Modifier.size(28.dp) ++ ) { ++ Icon( ++ imageVector = Icons.Default.ContentCopy, ++ contentDescription = "Copy Client ID", ++ tint = AwanTheme.colors.textSecondary, ++ modifier = Modifier.size(16.dp) ++ ) ++ } ++ } ++ } ++ } ++ } ++ ++ // Tokens Card ++ AwanCard( ++ modifier = Modifier.fillMaxWidth(), ++ contentPadding = PaddingValues(AwanTheme.spacing.md) ++ ) { ++ Column( ++ verticalArrangement = Arrangement.spacedBy(12.dp) ++ ) { ++ 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 = { showAddTokenDialog = true }, ++ variant = AwanButtonVariant.Quiet ++ ) { ++ Row( ++ horizontalArrangement = Arrangement.spacedBy(4.dp), ++ verticalAlignment = Alignment.CenterVertically ++ ) { ++ Icon( ++ imageVector = Icons.Default.Add, ++ contentDescription = null, ++ modifier = Modifier.size(16.dp) ++ ) ++ AwanText(stringResource(ProfileR.string.profile_mcp_add_token)) ++ } ++ } ++ } ++ ++ if (uiState.tokens.isEmpty()) { ++ Box( ++ modifier = Modifier ++ .fillMaxWidth() ++ .padding(vertical = 16.dp), ++ contentAlignment = Alignment.Center ++ ) { ++ AwanText( ++ text = "No tokens added yet", ++ style = AwanTheme.styles.bodySecondaryText ++ ) ++ } ++ } else { ++ uiState.tokens.forEach { token -> ++ TokenItemRow( ++ token = token, ++ onRegenerate = { regeneratingToken = token }, ++ onDelete = { deletingToken = token } ++ ) ++ } ++ } ++ ++ // Security notice ++ AwanText( ++ text = stringResource(ProfileR.string.profile_mcp_token_obscured_notice) + ". " + ++ stringResource(ProfileR.string.profile_mcp_token_copy_disabled), ++ style = AwanTheme.styles.captionText, ++ modifier = Modifier.padding(top = 4.dp) ++ ) ++ } ++ } ++ } ++ ++ if (uiState.error != null) { ++ Box( ++ modifier = Modifier ++ .fillMaxSize() ++ .padding(20.dp), ++ 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(12.dp)) ++ .background(AwanTheme.colors.disabledSurface) ++ .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(12.dp)) ++ .padding(12.dp) ++ ) { ++ Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { ++ Row( ++ modifier = Modifier.fillMaxWidth(), ++ horizontalArrangement = Arrangement.SpaceBetween, ++ verticalAlignment = Alignment.CenterVertically ++ ) { ++ AwanText( ++ text = token.name, ++ style = AwanTheme.styles.bodyText ++ ) ++ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { ++ IconButton( ++ onClick = onRegenerate, ++ modifier = Modifier.size(32.dp) ++ ) { ++ Icon( ++ imageVector = Icons.Default.Refresh, ++ contentDescription = "Regenerate Token", ++ tint = AwanTheme.colors.sky, ++ modifier = Modifier.size(18.dp) ++ ) ++ } ++ IconButton( ++ onClick = onDelete, ++ modifier = Modifier.size(32.dp) ++ ) { ++ Icon( ++ imageVector = Icons.Default.Delete, ++ contentDescription = "Delete Token", ++ 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 = 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 0000000..bff02c0 +--- /dev/null ++++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/components/CreatedTokenModal.kt +@@ -0,0 +1,149 @@ ++package com.awan.feature.profile.impl.ui.components ++ ++import android.content.ClipData ++import android.content.ClipboardManager ++import android.content.Context ++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.LocalContext ++import androidx.compose.ui.res.stringResource ++import androidx.compose.ui.text.font.FontFamily ++import androidx.compose.ui.unit.dp ++import androidx.compose.ui.window.Dialog ++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.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 ++ ++@Composable ++fun CreatedTokenModal( ++ createdToken: CreatedMcpToken, ++ onDismiss: () -> Unit, ++ modifier: Modifier = Modifier, ++) { ++ val context = LocalContext.current ++ val copiedToastMessage = stringResource(ProfileR.string.profile_mcp_token_copied) ++ var copied by remember { mutableStateOf(false) } ++ ++ Dialog(onDismissRequest = onDismiss) { ++ AwanCard( ++ modifier = modifier ++ .fillMaxWidth() ++ .padding(AwanTheme.spacing.md), ++ contentPadding = PaddingValues(AwanTheme.spacing.xl) ++ ) { ++ Column( ++ 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(12.dp)) ++ .background(AwanTheme.colors.destructive.copy(alpha = 0.1f)) ++ .border(1.dp, AwanTheme.colors.destructive, RoundedCornerShape(12.dp)) ++ .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(20.dp) ++ ) ++ 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(12.dp)) ++ .background(AwanTheme.colors.disabledSurface) ++ .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(12.dp)) ++ .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 = { ++ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager ++ val clip = ClipData.newPlainText("MCP Token", createdToken.rawToken) ++ clipboard.setPrimaryClip(clip) ++ 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(16.dp) ++ ) ++ 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 a37d896..ae33358 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 8a385cb..7527c67 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,28 @@ + ج + س + ح ++ ++ ++ تكامل MCP ++ ربط المساعدين الذكيين (Claude, Cursor) عبر بروتوكول MCP ++ تفاصيل الاتصال ++ رابط خادم MCP ++ معرف العميل (Client ID) ++ رموز الوصول (Tokens) ++ إضافة رمز جديد ++ اسم الرمز (مثال: Claude Desktop) ++ تم إخفاء المفتاح لأسباب أمنية ++ يمكن نسخ الرمز فقط عند إنشائه لأول مرة ++ تم إنشاء الرمز بنجاح! ++ احرص على نسخ رمز الوصول الخاص بك الآن. لن تتمكن من رؤيته مرة أخرى! ++ نسخ الرمز ++ تم نسخ الرمز إلى الحافظة ++ حذف الرمز؟ ++ هل أنت تأكد من حذف رمز MCP هذا؟ سيفقد المساعد الذكي الوصول فوراً. ++ إعادة إنشاء الرمز؟ ++ إعادة إنشاء الرمز ستلغي المفتاح الحالي. ستحتاج إلى تحديثه في المساعد الذكي. ++ كيفية ربط مساعدك الذكي ++ 1. انسخ رابط خادم MCP ومعرف العميل أعلاه. ++ 2. أنشئ رمز وصول واحفظ المفتاح فوراً. ++ 3. قم بتضمين الإعدادات في ملف التكوين (مثل claude_desktop_config.json). + +diff --git a/feature/profile/impl/src/main/res/values/strings.xml b/feature/profile/impl/src/main/res/values/strings.xml +index 6f43778..9770cd0 100644 +--- a/feature/profile/impl/src/main/res/values/strings.xml ++++ b/feature/profile/impl/src/main/res/values/strings.xml +@@ -184,4 +184,28 @@ + Fri + Sat + Sun ++ ++ ++ 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). + +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 0000000..261d7b6 +--- /dev/null ++++ b/feature/profile/impl/src/test/java/com/awan/feature/profile/impl/presentation/McpSettingsViewModelTest.kt +@@ -0,0 +1,163 @@ ++package com.awan.feature.profile.impl.presentation ++ ++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 `CreateToken action creates token, sets createdToken, and emits TokenCreated event`() = runTest(testDispatcher) { ++ val events = mutableListOf() ++ val job = launch { viewModel.events.toList(events) } ++ ++ 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) ++ assertEquals(1, events.size) ++ assert(events.first() is McpSettingsEvent.TokenCreated) ++ ++ job.cancel() ++ } ++ ++ @Test ++ fun `DeleteToken action removes token from state and emits TokenDeleted event`() = runTest(testDispatcher) { ++ val events = mutableListOf() ++ val job = launch { viewModel.events.toList(events) } ++ ++ viewModel.onAction(McpSettingsAction.DeleteToken("token-1")) ++ ++ val state = viewModel.uiState.value ++ assertEquals(0, state.tokens.size) ++ assertEquals(1, events.size) ++ assert(events.first() is McpSettingsEvent.TokenDeleted) ++ ++ job.cancel() ++ } ++ ++ @Test ++ fun `RegenerateToken action sets new createdToken and emits TokenRegenerated event`() = runTest(testDispatcher) { ++ val events = mutableListOf() ++ val job = launch { viewModel.events.toList(events) } ++ ++ viewModel.onAction(McpSettingsAction.RegenerateToken("token-1")) ++ ++ val state = viewModel.uiState.value ++ assertNotNull(state.createdToken) ++ assertEquals("raw_regenerated_token-1", state.createdToken?.rawToken) ++ 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 { ++ 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 { ++ 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/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/domain_imports.txt b/domain_imports.txt new file mode 100644 index 0000000000000000000000000000000000000000..aed8d59b74d7fd65a8df7d8b27f220d657c76d36 GIT binary patch literal 144626 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 I?2kDA4}`>!4*&oF literal 0 HcmV?d00001 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 index 0a9adbc9..67517009 100644 --- 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 @@ -1,9 +1,13 @@ package com.awan.feature.profile.impl.navigation +import android.widget.Toast import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.awan.app.core.designsystem.ObserveAsEvents +import com.awan.feature.profile.impl.presentation.McpSettingsEvent import com.awan.feature.profile.impl.presentation.McpSettingsViewModel import com.awan.feature.profile.impl.ui.McpSettingsScreen @@ -14,6 +18,18 @@ fun McpSettingsRouteScreen( onBack: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + ObserveAsEvents(viewModel.events) { event -> + when (event) { + is McpSettingsEvent.Error -> { + Toast.makeText(context, event.message.asString(context), Toast.LENGTH_SHORT).show() + } + is McpSettingsEvent.TokenCreated -> {} + McpSettingsEvent.TokenDeleted -> {} + is McpSettingsEvent.TokenRegenerated -> {} + } + } McpSettingsScreen( uiState = uiState, 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 index 744ac759..1c07545a 100644 --- 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 @@ -1,6 +1,15 @@ 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 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 index 6816efbf..c6cf9f13 100644 --- 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 @@ -9,6 +9,10 @@ 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, 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 index f4a89290..6e43b4e7 100644 --- 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 @@ -41,6 +41,13 @@ class McpSettingsViewModel @Inject constructor( 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) @@ -96,6 +103,8 @@ class McpSettingsViewModel @Inject constructor( state.copy( isCreating = false, createdToken = created, + showAddTokenDialog = false, + newTokenName = "", ) } _events.send(McpSettingsEvent.TokenCreated(created)) @@ -115,13 +124,16 @@ class McpSettingsViewModel @Inject constructor( when (val result = deleteMcpTokenUseCase(id)) { is Result.Success -> { _uiState.update { state -> - state.copy(tokens = state.tokens.filterNot { it.id == id }) + state.copy( + tokens = state.tokens.filterNot { it.id == id }, + deletingToken = null, + ) } _events.send(McpSettingsEvent.TokenDeleted) } is Result.Error -> { val uiError = ProfileErrorMapper.mapToUiText(result.error) - _uiState.update { it.copy(error = uiError) } + _uiState.update { it.copy(error = uiError, deletingToken = null) } _events.send(McpSettingsEvent.Error(uiError)) } Result.Loading -> Unit @@ -135,13 +147,16 @@ class McpSettingsViewModel @Inject constructor( is Result.Success -> { val regenerated = result.data _uiState.update { state -> - state.copy(createdToken = regenerated) + 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) } + _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 index 9bfecf81..e9d0b795 100644 --- 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 @@ -1,8 +1,5 @@ package com.awan.feature.profile.impl.ui -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -28,8 +25,10 @@ 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 @@ -44,6 +43,7 @@ fun McpInfoScreen( modifier: Modifier = Modifier, ) { val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current val copiedToastMessage = stringResource(ProfileR.string.profile_mcp_token_copied) val claudeSnippet = """ @@ -83,8 +83,8 @@ fun McpInfoScreen( modifier = Modifier .fillMaxWidth() .statusBarsPadding() - .padding(horizontal = 16.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), + .padding(horizontal = AwanTheme.spacing.md, vertical = AwanTheme.spacing.sm), + horizontalArrangement = Arrangement.spacedBy(AwanTheme.spacing.md), verticalAlignment = Alignment.CenterVertically ) { AwanBackButton(onClick = onBackClick) @@ -102,15 +102,15 @@ fun McpInfoScreen( .fillMaxSize() .padding(paddingValues) .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + .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(12.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.sm)) { AwanText( text = "Setup Instructions", style = AwanTheme.styles.headingText @@ -135,7 +135,7 @@ fun McpInfoScreen( modifier = Modifier.fillMaxWidth(), contentPadding = PaddingValues(AwanTheme.spacing.md) ) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.xs)) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -147,27 +147,26 @@ fun McpInfoScreen( ) IconButton( onClick = { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(ClipData.newPlainText("Claude Config", claudeSnippet)) + clipboardManager.setText(AnnotatedString(claudeSnippet)) Toast.makeText(context, copiedToastMessage, Toast.LENGTH_SHORT).show() }, modifier = Modifier.size(28.dp) ) { Icon( imageVector = Icons.Default.ContentCopy, - contentDescription = "Copy Claude Snippet", + contentDescription = stringResource(ProfileR.string.profile_mcp_cd_copy_snippet), tint = AwanTheme.colors.sky, - modifier = Modifier.size(16.dp) + modifier = Modifier.size(AwanTheme.spacing.md) ) } } Box( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) + .clip(RoundedCornerShape(AwanTheme.spacing.xs)) .background(AwanTheme.colors.disabledSurface) - .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(8.dp)) - .padding(12.dp) + .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(AwanTheme.spacing.xs)) + .padding(AwanTheme.spacing.sm) ) { AwanText( text = claudeSnippet, @@ -182,7 +181,7 @@ fun McpInfoScreen( modifier = Modifier.fillMaxWidth(), contentPadding = PaddingValues(AwanTheme.spacing.md) ) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.xs)) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -194,27 +193,26 @@ fun McpInfoScreen( ) IconButton( onClick = { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(ClipData.newPlainText("Cursor Config", cursorSnippet)) + clipboardManager.setText(AnnotatedString(cursorSnippet)) Toast.makeText(context, copiedToastMessage, Toast.LENGTH_SHORT).show() }, modifier = Modifier.size(28.dp) ) { Icon( imageVector = Icons.Default.ContentCopy, - contentDescription = "Copy Cursor Snippet", + contentDescription = stringResource(ProfileR.string.profile_mcp_cd_copy_snippet), tint = AwanTheme.colors.sky, - modifier = Modifier.size(16.dp) + modifier = Modifier.size(AwanTheme.spacing.md) ) } } Box( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) + .clip(RoundedCornerShape(AwanTheme.spacing.xs)) .background(AwanTheme.colors.disabledSurface) - .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(8.dp)) - .padding(12.dp) + .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(AwanTheme.spacing.xs)) + .padding(AwanTheme.spacing.sm) ) { AwanText( text = cursorSnippet, 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 index 6ff1aaba..09c044f3 100644 --- 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 @@ -1,8 +1,5 @@ package com.awan.feature.profile.impl.ui -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -30,15 +27,13 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold 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.ui.window.Dialog @@ -66,11 +61,8 @@ fun McpSettingsScreen( modifier: Modifier = Modifier, ) { val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current val copiedToastMessage = stringResource(ProfileR.string.profile_mcp_token_copied) - var showAddTokenDialog by remember { mutableStateOf(false) } - var newTokenName by remember { mutableStateOf("") } - var deletingToken by remember { mutableStateOf(null) } - var regeneratingToken by remember { mutableStateOf(null) } if (uiState.createdToken != null) { CreatedTokenModal( @@ -79,8 +71,8 @@ fun McpSettingsScreen( ) } - if (showAddTokenDialog) { - Dialog(onDismissRequest = { showAddTokenDialog = false }) { + if (uiState.showAddTokenDialog) { + Dialog(onDismissRequest = { onAction(McpSettingsAction.HideAddTokenDialog) }) { AwanCard( modifier = Modifier .fillMaxWidth() @@ -95,8 +87,8 @@ fun McpSettingsScreen( style = AwanTheme.styles.titleText ) AwanTextField( - value = newTokenName, - onValueChange = { newTokenName = it }, + value = uiState.newTokenName, + onValueChange = { onAction(McpSettingsAction.UpdateNewTokenName(it)) }, placeholder = stringResource(ProfileR.string.profile_mcp_token_name_hint), modifier = Modifier.fillMaxWidth() ) @@ -105,10 +97,7 @@ fun McpSettingsScreen( horizontalArrangement = Arrangement.spacedBy(AwanTheme.spacing.sm) ) { AwanButton( - onClick = { - showAddTokenDialog = false - newTokenName = "" - }, + onClick = { onAction(McpSettingsAction.HideAddTokenDialog) }, modifier = Modifier.weight(1f), variant = AwanButtonVariant.Quiet ) { @@ -116,17 +105,15 @@ fun McpSettingsScreen( } AwanButton( onClick = { - if (newTokenName.isNotBlank()) { - onAction(McpSettingsAction.CreateToken(newTokenName)) - showAddTokenDialog = false - newTokenName = "" + if (uiState.newTokenName.isNotBlank()) { + onAction(McpSettingsAction.CreateToken(uiState.newTokenName)) } }, modifier = Modifier.weight(1f), - enabled = newTokenName.isNotBlank() && !uiState.isCreating + enabled = uiState.newTokenName.isNotBlank() && !uiState.isCreating ) { if (uiState.isCreating) { - CircularProgressIndicator(modifier = Modifier.size(16.dp), color = AwanTheme.colors.surface) + CircularProgressIndicator(modifier = Modifier.size(AwanTheme.spacing.md), color = AwanTheme.colors.surface) } else { AwanText(stringResource(ProfileR.string.profile_mcp_add_token)) } @@ -137,35 +124,33 @@ fun McpSettingsScreen( } } - if (deletingToken != null) { + 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(deletingToken!!.id)) - deletingToken = null + onAction(McpSettingsAction.DeleteToken(uiState.deletingToken.id)) }, secondaryLabel = stringResource(ProfileR.string.profile_cancel), - onSecondary = { deletingToken = null }, - onDismiss = { deletingToken = null } + onSecondary = { onAction(McpSettingsAction.HideDeleteDialog) }, + onDismiss = { onAction(McpSettingsAction.HideDeleteDialog) } ) } - if (regeneratingToken != null) { + 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(regeneratingToken!!.id)) - regeneratingToken = null + onAction(McpSettingsAction.RegenerateToken(uiState.regeneratingToken.id)) }, secondaryLabel = stringResource(ProfileR.string.profile_cancel), - onSecondary = { regeneratingToken = null }, - onDismiss = { regeneratingToken = null } + onSecondary = { onAction(McpSettingsAction.HideRegenerateDialog) }, + onDismiss = { onAction(McpSettingsAction.HideRegenerateDialog) } ) } @@ -175,7 +160,7 @@ fun McpSettingsScreen( modifier = Modifier .fillMaxWidth() .statusBarsPadding() - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding(horizontal = AwanTheme.spacing.md, vertical = AwanTheme.spacing.sm), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -187,7 +172,7 @@ fun McpSettingsScreen( IconButton(onClick = onInfoClick) { Icon( imageVector = Icons.Default.Info, - contentDescription = "MCP Setup Info", + contentDescription = stringResource(ProfileR.string.profile_mcp_cd_info), tint = AwanTheme.colors.sky ) } @@ -205,8 +190,8 @@ fun McpSettingsScreen( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + .padding(horizontal = AwanTheme.spacing.lg, vertical = AwanTheme.spacing.md), + verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.md) ) { // Connection Details Card AwanCard( @@ -214,7 +199,7 @@ fun McpSettingsScreen( contentPadding = PaddingValues(AwanTheme.spacing.md) ) { Column( - verticalArrangement = Arrangement.spacedBy(12.dp) + verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.sm) ) { AwanText( text = stringResource(ProfileR.string.profile_mcp_connection_title), @@ -226,7 +211,7 @@ fun McpSettingsScreen( val clientId = details?.clientId ?: "awan-android-client" // MCP URL Row - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.xxs)) { AwanText( text = stringResource(ProfileR.string.profile_mcp_url_label), style = AwanTheme.styles.captionText @@ -234,10 +219,10 @@ fun McpSettingsScreen( Row( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) + .clip(RoundedCornerShape(AwanTheme.spacing.xs)) .background(AwanTheme.colors.disabledSurface) - .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(8.dp)) - .padding(horizontal = 12.dp, vertical = 8.dp), + .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 ) { @@ -248,24 +233,23 @@ fun McpSettingsScreen( ) IconButton( onClick = { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(ClipData.newPlainText("MCP URL", mcpUrl)) + clipboardManager.setText(AnnotatedString(mcpUrl)) Toast.makeText(context, copiedToastMessage, Toast.LENGTH_SHORT).show() }, modifier = Modifier.size(28.dp) ) { Icon( imageVector = Icons.Default.ContentCopy, - contentDescription = "Copy URL", + contentDescription = stringResource(ProfileR.string.profile_mcp_cd_copy_url), tint = AwanTheme.colors.textSecondary, - modifier = Modifier.size(16.dp) + modifier = Modifier.size(AwanTheme.spacing.md) ) } } } // Client ID Row - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.xxs)) { AwanText( text = stringResource(ProfileR.string.profile_mcp_client_id_label), style = AwanTheme.styles.captionText @@ -273,10 +257,10 @@ fun McpSettingsScreen( Row( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) + .clip(RoundedCornerShape(AwanTheme.spacing.xs)) .background(AwanTheme.colors.disabledSurface) - .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(8.dp)) - .padding(horizontal = 12.dp, vertical = 8.dp), + .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 ) { @@ -287,17 +271,16 @@ fun McpSettingsScreen( ) IconButton( onClick = { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(ClipData.newPlainText("Client ID", clientId)) + clipboardManager.setText(AnnotatedString(clientId)) Toast.makeText(context, copiedToastMessage, Toast.LENGTH_SHORT).show() }, modifier = Modifier.size(28.dp) ) { Icon( imageVector = Icons.Default.ContentCopy, - contentDescription = "Copy Client ID", + contentDescription = stringResource(ProfileR.string.profile_mcp_cd_copy_client_id), tint = AwanTheme.colors.textSecondary, - modifier = Modifier.size(16.dp) + modifier = Modifier.size(AwanTheme.spacing.md) ) } } @@ -311,7 +294,7 @@ fun McpSettingsScreen( contentPadding = PaddingValues(AwanTheme.spacing.md) ) { Column( - verticalArrangement = Arrangement.spacedBy(12.dp) + verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.sm) ) { Row( modifier = Modifier.fillMaxWidth(), @@ -323,17 +306,17 @@ fun McpSettingsScreen( style = AwanTheme.styles.headingText ) AwanButton( - onClick = { showAddTokenDialog = true }, + onClick = { onAction(McpSettingsAction.ShowAddTokenDialog) }, variant = AwanButtonVariant.Quiet ) { Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(AwanTheme.spacing.xxs), verticalAlignment = Alignment.CenterVertically ) { Icon( imageVector = Icons.Default.Add, contentDescription = null, - modifier = Modifier.size(16.dp) + modifier = Modifier.size(AwanTheme.spacing.md) ) AwanText(stringResource(ProfileR.string.profile_mcp_add_token)) } @@ -344,7 +327,7 @@ fun McpSettingsScreen( Box( modifier = Modifier .fillMaxWidth() - .padding(vertical = 16.dp), + .padding(vertical = AwanTheme.spacing.md), contentAlignment = Alignment.Center ) { AwanText( @@ -356,18 +339,21 @@ fun McpSettingsScreen( uiState.tokens.forEach { token -> TokenItemRow( token = token, - onRegenerate = { regeneratingToken = token }, - onDelete = { deletingToken = token } + onRegenerate = { onAction(McpSettingsAction.ShowRegenerateDialog(token)) }, + onDelete = { onAction(McpSettingsAction.ShowDeleteDialog(token)) } ) } } // Security notice AwanText( - text = stringResource(ProfileR.string.profile_mcp_token_obscured_notice) + ". " + - stringResource(ProfileR.string.profile_mcp_token_copy_disabled), + 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 = 4.dp) + modifier = Modifier.padding(top = AwanTheme.spacing.xxs) ) } } @@ -377,7 +363,7 @@ fun McpSettingsScreen( Box( modifier = Modifier .fillMaxSize() - .padding(20.dp), + .padding(AwanTheme.spacing.lg), contentAlignment = Alignment.BottomCenter ) { AwanErrorSnackbar( @@ -400,12 +386,12 @@ private fun TokenItemRow( Box( modifier = modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) + .clip(RoundedCornerShape(AwanTheme.spacing.sm)) .background(AwanTheme.colors.disabledSurface) - .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(12.dp)) - .padding(12.dp) + .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(AwanTheme.spacing.sm)) + .padding(AwanTheme.spacing.sm) ) { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.xs)) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -415,14 +401,14 @@ private fun TokenItemRow( text = token.name, style = AwanTheme.styles.bodyText ) - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(AwanTheme.spacing.xxs)) { IconButton( onClick = onRegenerate, modifier = Modifier.size(32.dp) ) { Icon( imageVector = Icons.Default.Refresh, - contentDescription = "Regenerate Token", + contentDescription = stringResource(ProfileR.string.profile_mcp_cd_regenerate_token, token.name), tint = AwanTheme.colors.sky, modifier = Modifier.size(18.dp) ) @@ -433,7 +419,7 @@ private fun TokenItemRow( ) { Icon( imageVector = Icons.Default.Delete, - contentDescription = "Delete Token", + contentDescription = stringResource(ProfileR.string.profile_mcp_cd_delete_token, token.name), tint = AwanTheme.colors.destructive, modifier = Modifier.size(18.dp) ) 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 index bff02c06..5be944a6 100644 --- 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 @@ -1,8 +1,5 @@ package com.awan.feature.profile.impl.ui.components -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -27,8 +24,10 @@ 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.ui.window.Dialog @@ -47,6 +46,7 @@ fun CreatedTokenModal( 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) } @@ -69,9 +69,9 @@ fun CreatedTokenModal( Box( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) + .clip(RoundedCornerShape(AwanTheme.spacing.sm)) .background(AwanTheme.colors.destructive.copy(alpha = 0.1f)) - .border(1.dp, AwanTheme.colors.destructive, RoundedCornerShape(12.dp)) + .border(1.dp, AwanTheme.colors.destructive, RoundedCornerShape(AwanTheme.spacing.sm)) .padding(AwanTheme.spacing.md) ) { Row( @@ -82,7 +82,7 @@ fun CreatedTokenModal( imageVector = Icons.Default.Warning, contentDescription = null, tint = AwanTheme.colors.destructive, - modifier = Modifier.size(20.dp) + modifier = Modifier.size(AwanTheme.spacing.lg) ) AwanText( text = stringResource(ProfileR.string.profile_mcp_token_created_banner_warning), @@ -94,9 +94,9 @@ fun CreatedTokenModal( Box( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) + .clip(RoundedCornerShape(AwanTheme.spacing.sm)) .background(AwanTheme.colors.disabledSurface) - .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(12.dp)) + .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(AwanTheme.spacing.sm)) .padding(AwanTheme.spacing.md), contentAlignment = Alignment.Center ) { @@ -108,9 +108,7 @@ fun CreatedTokenModal( AwanButton( onClick = { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("MCP Token", createdToken.rawToken) - clipboard.setPrimaryClip(clip) + clipboardManager.setText(AnnotatedString(createdToken.rawToken)) copied = true Toast.makeText(context, copiedToastMessage, Toast.LENGTH_SHORT).show() }, @@ -124,7 +122,7 @@ fun CreatedTokenModal( Icon( imageVector = Icons.Default.ContentCopy, contentDescription = null, - modifier = Modifier.size(16.dp) + modifier = Modifier.size(AwanTheme.spacing.md) ) AwanText( text = if (copied) { 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 7527c672..0fb19032 100644 --- a/feature/profile/impl/src/main/res/values-ar/strings.xml +++ b/feature/profile/impl/src/main/res/values-ar/strings.xml @@ -196,6 +196,7 @@ اسم الرمز (مثال: Claude Desktop) تم إخفاء المفتاح لأسباب أمنية يمكن نسخ الرمز فقط عند إنشائه لأول مرة + %1$s. %2$s تم إنشاء الرمز بنجاح! احرص على نسخ رمز الوصول الخاص بك الآن. لن تتمكن من رؤيته مرة أخرى! نسخ الرمز @@ -208,4 +209,10 @@ 1. انسخ رابط خادم MCP ومعرف العميل أعلاه. 2. أنشئ رمز وصول واحفظ المفتاح فوراً. 3. قم بتضمين الإعدادات في ملف التكوين (مثل claude_desktop_config.json). + معلومات إعداد 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 9770cd00..55339008 100644 --- a/feature/profile/impl/src/main/res/values/strings.xml +++ b/feature/profile/impl/src/main/res/values/strings.xml @@ -196,6 +196,7 @@ 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 @@ -208,4 +209,10 @@ 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). + MCP Setup Info + Copy MCP Server URL + Copy OAuth Client ID + 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 index 261d7b6e..7543fb8f 100644 --- 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 @@ -62,16 +62,53 @@ class McpSettingsViewModelTest { } @Test - fun `CreateToken action creates token, sets createdToken, and emits TokenCreated event`() = runTest(testDispatcher) { + 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) @@ -79,14 +116,17 @@ class McpSettingsViewModelTest { } @Test - fun `DeleteToken action removes token from state and emits TokenDeleted event`() = runTest(testDispatcher) { + 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) @@ -94,15 +134,18 @@ class McpSettingsViewModelTest { } @Test - fun `RegenerateToken action sets new createdToken and emits TokenRegenerated event`() = runTest(testDispatcher) { + 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) diff --git a/mcp_diff.patch b/mcp_diff.patch new file mode 100644 index 00000000..541e4be1 --- /dev/null +++ b/mcp_diff.patch @@ -0,0 +1,4031 @@ +diff --git a/app/src/main/java/com/awan/app/AwanApp.kt b/app/src/main/java/com/awan/app/AwanApp.kt +index 3c0de9e..5ab1b5d 100644 +--- a/app/src/main/java/com/awan/app/AwanApp.kt ++++ b/app/src/main/java/com/awan/app/AwanApp.kt +@@ -250,7 +250,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(com.awan.feature.profile.api.McpSettingsRoute) }, ++ onNavigateToMcpInfo = { navigator.navigate(com.awan.feature.profile.api.McpInfoRoute) }, + ) + goalPreviewEntry( + onBack = { navigator.goBack() }, +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 0000000..31521c1 +--- /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/repository/McpRepositoryImpl.kt b/core/data/src/main/kotlin/com/awan/app/core/data/mcp/repository/McpRepositoryImpl.kt +new file mode 100644 +index 0000000..7550659 +--- /dev/null ++++ b/core/data/src/main/kotlin/com/awan/app/core/data/mcp/repository/McpRepositoryImpl.kt +@@ -0,0 +1,145 @@ ++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.database.dao.McpTokenDao ++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.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.api.McpApiService ++import com.awan.app.core.network.dto.mcp.CreateMcpTokenRequestDto ++import com.awan.app.core.network.error.safeApiCall ++import kotlinx.coroutines.CoroutineDispatcher ++import kotlinx.coroutines.flow.Flow ++import kotlinx.coroutines.flow.emitAll ++import kotlinx.coroutines.flow.flow ++import kotlinx.coroutines.flow.flowOn ++import kotlinx.coroutines.flow.map ++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 { ++ if (!connectivityMonitor.isCurrentlyOnline()) { ++ emit(Result.Error(AppError.Network)) ++ return@flow ++ } ++ val result = safeApiCall(ioDispatcher) { ++ val dto = mcpApiService.getConnectionDetails() ++ McpConnectionDetails( ++ mcpUrl = dto.mcpUrl, ++ clientId = dto.clientId, ++ ) ++ } ++ emit(result) ++ }.flowOn(ioDispatcher) ++ ++ override fun getMcpTokens(): Flow>> = flow { ++ if (connectivityMonitor.isCurrentlyOnline()) { ++ try { ++ val dtos = mcpApiService.getTokens() ++ val entities = dtos.map { dto -> ++ McpTokenEntity( ++ id = dto.id, ++ name = dto.name, ++ maskedToken = dto.maskedToken, ++ createdAt = dto.createdAt, ++ lastUsedAt = dto.lastUsedAt, ++ ) ++ } ++ mcpTokenDao.clearAll() ++ mcpTokenDao.upsertMcpTokens(entities) ++ } catch (_: Exception) { ++ // If network sync fails, fallback to Room cached tokens ++ } ++ } ++ emitAll( ++ mcpTokenDao.getMcpTokens().map { entities -> ++ Result.Success( ++ entities.map { entity -> ++ McpToken( ++ id = entity.id, ++ name = entity.name, ++ maskedToken = entity.maskedToken, ++ createdAt = entity.createdAt, ++ lastUsedAt = entity.lastUsedAt, ++ ) ++ } ++ ) ++ } ++ ) ++ }.flowOn(ioDispatcher) ++ ++ override suspend fun createMcpToken(name: String): Result { ++ if (!connectivityMonitor.isCurrentlyOnline()) { ++ return Result.Error(AppError.Network) ++ } ++ return safeApiCall(ioDispatcher) { ++ val dto = mcpApiService.createToken(CreateMcpTokenRequestDto(name = name)) ++ val entity = McpTokenEntity( ++ id = dto.id, ++ name = dto.name, ++ maskedToken = dto.maskedToken, ++ createdAt = dto.createdAt, ++ lastUsedAt = null, ++ ) ++ mcpTokenDao.upsertMcpTokens(listOf(entity)) ++ CreatedMcpToken( ++ id = dto.id, ++ name = dto.name, ++ rawToken = dto.rawToken, ++ maskedToken = dto.maskedToken, ++ createdAt = dto.createdAt, ++ ) ++ } ++ } ++ ++ override suspend fun deleteMcpToken(id: String): Result { ++ if (!connectivityMonitor.isCurrentlyOnline()) { ++ return Result.Error(AppError.Network) ++ } ++ val result = safeApiCall(ioDispatcher) { ++ mcpApiService.deleteToken(id) ++ } ++ if (result is Result.Success) { ++ mcpTokenDao.deleteMcpToken(id) ++ } ++ return result ++ } ++ ++ override suspend fun regenerateMcpToken(id: String): Result { ++ if (!connectivityMonitor.isCurrentlyOnline()) { ++ return Result.Error(AppError.Network) ++ } ++ return safeApiCall(ioDispatcher) { ++ val dto = mcpApiService.regenerateToken(id) ++ val entity = McpTokenEntity( ++ id = dto.id, ++ name = dto.name, ++ maskedToken = dto.maskedToken, ++ createdAt = dto.createdAt, ++ lastUsedAt = null, ++ ) ++ mcpTokenDao.upsertMcpTokens(listOf(entity)) ++ CreatedMcpToken( ++ id = dto.id, ++ name = dto.name, ++ rawToken = dto.rawToken, ++ maskedToken = dto.maskedToken, ++ createdAt = dto.createdAt, ++ ) ++ } ++ } ++} +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 0000000..4f6ccce +--- /dev/null ++++ b/core/data/src/test/java/com/awan/app/core/data/mcp/McpRepositoryImplTest.kt +@@ -0,0 +1,277 @@ ++package com.awan.app.core.data.mcp ++ ++import com.awan.app.core.common.error.AppError ++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.CreateMcpTokenRequestDto ++import com.awan.app.core.network.dto.mcp.CreatedMcpTokenResponseDto ++import com.awan.app.core.network.dto.mcp.McpConnectionDetailsDto ++import com.awan.app.core.network.dto.mcp.McpTokenResponseDto ++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 org.junit.Assert.assertEquals ++import org.junit.Assert.assertTrue ++import org.junit.Test ++ ++private class FakeMcpApiService : McpApiService { ++ var connectionDetailsDto = McpConnectionDetailsDto( ++ mcpUrl = "https://mcp.awan.com", ++ clientId = "client-123", ++ ) ++ var tokensList = mutableListOf() ++ var createdTokenResponse = CreatedMcpTokenResponseDto( ++ id = "token-1", ++ name = "Default Token", ++ rawToken = "raw-secret-123", ++ maskedToken = "mcp_...123", ++ createdAt = "2026-08-11T00:00:00Z", ++ ) ++ var shouldFailWithException: Exception? = null ++ var lastCreatedName: String? = null ++ var lastDeletedId: String? = null ++ var lastRegeneratedId: String? = null ++ ++ override suspend fun getConnectionDetails(): McpConnectionDetailsDto { ++ shouldFailWithException?.let { throw it } ++ return connectionDetailsDto ++ } ++ ++ override suspend fun getTokens(): List { ++ shouldFailWithException?.let { throw it } ++ return tokensList ++ } ++ ++ override suspend fun createToken(request: CreateMcpTokenRequestDto): CreatedMcpTokenResponseDto { ++ shouldFailWithException?.let { throw it } ++ lastCreatedName = request.name ++ return createdTokenResponse.copy(name = request.name) ++ } ++ ++ override suspend fun deleteToken(id: String) { ++ shouldFailWithException?.let { throw it } ++ lastDeletedId = id ++ } ++ ++ override suspend fun regenerateToken(id: String): CreatedMcpTokenResponseDto { ++ shouldFailWithException?.let { throw it } ++ lastRegeneratedId = id ++ return createdTokenResponse ++ } ++} ++ ++private class FakeMcpTokenDao : McpTokenDao { ++ private val tokensState = MutableStateFlow>(emptyList()) ++ val storedTokens: List get() = tokensState.value ++ ++ override fun getMcpTokens(): Flow> = tokensState ++ ++ override suspend fun upsertMcpTokens(tokens: List) { ++ 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() ++ } ++} ++ ++@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 success when online`() = runTest(testDispatcher) { ++ val apiService = FakeMcpApiService() ++ val repository = buildRepository(apiService = apiService, monitor = onlineMonitor) ++ ++ val result = repository.getMcpConnectionDetails().first() ++ ++ assertTrue(result is Result.Success) ++ val details = (result as Result.Success).data ++ assertEquals("https://mcp.awan.com", details.mcpUrl) ++ assertEquals("client-123", details.clientId) ++ } ++ ++ @Test ++ fun `getMcpConnectionDetails returns network error when offline`() = runTest(testDispatcher) { ++ val repository = buildRepository(monitor = offlineMonitor) ++ ++ val result = repository.getMcpConnectionDetails().first() ++ ++ assertTrue(result is Result.Error) ++ assertTrue((result as Result.Error).error is AppError.Network) ++ } ++ ++ @Test ++ fun `getMcpTokens fetches remote and updates Room when online`() = runTest(testDispatcher) { ++ val apiService = FakeMcpApiService().apply { ++ tokensList.add( ++ McpTokenResponseDto( ++ id = "token-1", ++ name = "Claude Desktop", ++ maskedToken = "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) ++ } ++ ++ @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 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 deletes token from network and 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.lastDeletedId) ++ 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 updates token in network and Room`() = runTest(testDispatcher) { ++ val apiService = FakeMcpApiService().apply { ++ createdTokenResponse = CreatedMcpTokenResponseDto( ++ id = "token-1", ++ name = "Claude Desktop", ++ rawToken = "new-raw-secret", ++ maskedToken = "mcp_...new", ++ 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("token-1", apiService.lastRegeneratedId) ++ assertEquals("mcp_...new", tokenDao.storedTokens.first().maskedToken) ++ } ++} +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 0000000..5142afa +--- /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 0695d6b..0a37454 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 0000000..b531812 +--- /dev/null ++++ b/core/database/src/main/kotlin/com/awan/app/core/database/dao/McpTokenDao.kt +@@ -0,0 +1,22 @@ ++package com.awan.app.core.database.dao ++ ++import androidx.room.Dao ++import androidx.room.Query ++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() ++} +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 2788919..4828f5c 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 0000000..9752977 +--- /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/domain/build.gradle.kts b/core/domain/build.gradle.kts +index 4e6c9ba..7251014 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 0000000..98aad99 +--- /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 0000000..e65b3cd +--- /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 0000000..d36fa0e +--- /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 0000000..bee60bd +--- /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 0000000..70f0ce6 +--- /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 0000000..a86f125 +--- /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 0000000..a6d3a9c +--- /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 0000000..d095671 +--- /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 0000000..670ab11 +--- /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 0000000..10f7823 +--- /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/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 0000000..8034e34 +--- /dev/null ++++ b/core/network/src/main/kotlin/com/awan/app/core/network/api/McpApiService.kt +@@ -0,0 +1,28 @@ ++package com.awan.app.core.network.api ++ ++import com.awan.app.core.network.dto.mcp.CreateMcpTokenRequestDto ++import com.awan.app.core.network.dto.mcp.CreatedMcpTokenResponseDto ++import com.awan.app.core.network.dto.mcp.McpConnectionDetailsDto ++import com.awan.app.core.network.dto.mcp.McpTokenResponseDto ++import retrofit2.http.Body ++import retrofit2.http.DELETE ++import retrofit2.http.GET ++import retrofit2.http.POST ++import retrofit2.http.Path ++ ++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 ++} +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 c9a0011..2ea5244 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 +@@ -176,6 +176,11 @@ object NetworkModule { + fun providesGoalApiService(retrofit: Retrofit): GoalApiService = + retrofit.create(GoalApiService::class.java) + ++ @Provides ++ @Singleton ++ fun providesMcpApiService(retrofit: Retrofit): com.awan.app.core.network.api.McpApiService = ++ retrofit.create(com.awan.app.core.network.api.McpApiService::class.java) ++ + @Provides + @Singleton + fun providesDeviceIdProvider( +diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreateMcpTokenRequestDto.kt b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreateMcpTokenRequestDto.kt +new file mode 100644 +index 0000000..cace98e +--- /dev/null ++++ b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreateMcpTokenRequestDto.kt +@@ -0,0 +1,9 @@ ++package com.awan.app.core.network.dto.mcp ++ ++import kotlinx.serialization.SerialName ++import kotlinx.serialization.Serializable ++ ++@Serializable ++data class CreateMcpTokenRequestDto( ++ @SerialName("name") val name: String, ++) +diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreatedMcpTokenResponseDto.kt b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreatedMcpTokenResponseDto.kt +new file mode 100644 +index 0000000..37af60f +--- /dev/null ++++ b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreatedMcpTokenResponseDto.kt +@@ -0,0 +1,13 @@ ++package com.awan.app.core.network.dto.mcp ++ ++import kotlinx.serialization.SerialName ++import kotlinx.serialization.Serializable ++ ++@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, ++) +diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpConnectionDetailsDto.kt b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpConnectionDetailsDto.kt +new file mode 100644 +index 0000000..9ce2cec +--- /dev/null ++++ b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpConnectionDetailsDto.kt +@@ -0,0 +1,10 @@ ++package com.awan.app.core.network.dto.mcp ++ ++import kotlinx.serialization.SerialName ++import kotlinx.serialization.Serializable ++ ++@Serializable ++data class McpConnectionDetailsDto( ++ @SerialName("mcpUrl") val mcpUrl: String, ++ @SerialName("clientId") val clientId: String, ++) +diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpTokenResponseDto.kt b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpTokenResponseDto.kt +new file mode 100644 +index 0000000..3ef767e +--- /dev/null ++++ b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpTokenResponseDto.kt +@@ -0,0 +1,13 @@ ++package com.awan.app.core.network.dto.mcp ++ ++import kotlinx.serialization.SerialName ++import kotlinx.serialization.Serializable ++ ++@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, ++) +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 0000000..a1493fe +--- /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/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 0000000..adecf9a +--- /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 0000000..e978685 +--- /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 0000000..8d4ae14 +--- /dev/null ++++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/navigation/McpInfoRouteScreen.kt +@@ -0,0 +1,13 @@ ++package com.awan.feature.profile.impl.navigation ++ ++import androidx.compose.runtime.Composable ++import com.awan.feature.profile.impl.ui.McpInfoScreen ++ ++@Composable ++fun McpInfoRouteScreen( ++ onBack: () -> Unit, ++) { ++ McpInfoScreen( ++ 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 0000000..0a9adbc +--- /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 cda412e..33029b9 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 3e0db73..03fa4aa 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 0000000..744ac75 +--- /dev/null ++++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsAction.kt +@@ -0,0 +1,10 @@ ++package com.awan.feature.profile.impl.presentation ++ ++sealed interface 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 0000000..cdc461a +--- /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 0000000..6816efb +--- /dev/null ++++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsState.kt +@@ -0,0 +1,16 @@ ++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 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 0000000..f4a8929 +--- /dev/null ++++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsViewModel.kt +@@ -0,0 +1,151 @@ ++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.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() ++ ++ init { ++ loadData() ++ } ++ ++ fun onAction(action: McpSettingsAction) { ++ when (action) { ++ 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() { ++ viewModelScope.launch { ++ _uiState.update { it.copy(isLoading = true, error = null) } ++ getMcpConnectionDetailsUseCase().collect { result -> ++ when (result) { ++ is Result.Success -> { ++ _uiState.update { it.copy(connectionDetails = result.data, isLoading = false) } ++ } ++ is Result.Error -> { ++ val uiError = ProfileErrorMapper.mapToUiText(result.error) ++ _uiState.update { it.copy(error = uiError, isLoading = false) } ++ } ++ Result.Loading -> { ++ _uiState.update { it.copy(isLoading = true) } ++ } ++ } ++ } ++ } ++ ++ viewModelScope.launch { ++ getMcpTokensUseCase().collect { result -> ++ when (result) { ++ is Result.Success -> { ++ _uiState.update { it.copy(tokens = result.data) } ++ } ++ is Result.Error -> { ++ val uiError = ProfileErrorMapper.mapToUiText(result.error) ++ _uiState.update { it.copy(error = uiError) } ++ } ++ 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, ++ ) ++ } ++ _events.send(McpSettingsEvent.TokenCreated(created)) ++ } ++ is Result.Error -> { ++ val uiError = ProfileErrorMapper.mapToUiText(result.error) ++ _uiState.update { it.copy(isCreating = 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(tokens = state.tokens.filterNot { it.id == id }) ++ } ++ _events.send(McpSettingsEvent.TokenDeleted) ++ } ++ is Result.Error -> { ++ val uiError = ProfileErrorMapper.mapToUiText(result.error) ++ _uiState.update { it.copy(error = uiError) } ++ _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) ++ } ++ _events.send(McpSettingsEvent.TokenRegenerated(regenerated)) ++ } ++ is Result.Error -> { ++ val uiError = ProfileErrorMapper.mapToUiText(result.error) ++ _uiState.update { it.copy(error = uiError) } ++ _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 0000000..9bfecf8 +--- /dev/null ++++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/McpInfoScreen.kt +@@ -0,0 +1,228 @@ ++package com.awan.feature.profile.impl.ui ++ ++import android.content.ClipData ++import android.content.ClipboardManager ++import android.content.Context ++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.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.LocalContext ++import androidx.compose.ui.res.stringResource ++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 ++ ++@Composable ++fun McpInfoScreen( ++ onBackClick: () -> Unit, ++ modifier: Modifier = Modifier, ++) { ++ val context = LocalContext.current ++ val copiedToastMessage = stringResource(ProfileR.string.profile_mcp_token_copied) ++ ++ val claudeSnippet = """ ++ { ++ "mcpServers": { ++ "awan": { ++ "command": "npx", ++ "args": [ ++ "-y", ++ "@awan/mcp-server", ++ "--url", "https://mcp.awan.app/v1", ++ "--token", "YOUR_API_TOKEN" ++ ] ++ } ++ } ++ } ++ """.trimIndent() ++ ++ val cursorSnippet = """ ++ { ++ "mcp": { ++ "servers": { ++ "awan": { ++ "url": "https://mcp.awan.app/v1", ++ "headers": { ++ "Authorization": "Bearer YOUR_API_TOKEN" ++ } ++ } ++ } ++ } ++ } ++ """.trimIndent() ++ ++ Scaffold( ++ topBar = { ++ Row( ++ modifier = Modifier ++ .fillMaxWidth() ++ .statusBarsPadding() ++ .padding(horizontal = 16.dp, vertical = 12.dp), ++ horizontalArrangement = Arrangement.spacedBy(16.dp), ++ 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 = 20.dp, vertical = 16.dp), ++ verticalArrangement = Arrangement.spacedBy(16.dp) ++ ) { ++ // Setup steps card ++ AwanCard( ++ modifier = Modifier.fillMaxWidth(), ++ contentPadding = PaddingValues(AwanTheme.spacing.md) ++ ) { ++ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { ++ AwanText( ++ text = "Setup Instructions", ++ 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 ++ ) ++ AwanText( ++ text = stringResource(ProfileR.string.profile_mcp_info_step3), ++ style = AwanTheme.styles.bodyText ++ ) ++ } ++ } ++ ++ // Claude Desktop Guide ++ AwanCard( ++ modifier = Modifier.fillMaxWidth(), ++ contentPadding = PaddingValues(AwanTheme.spacing.md) ++ ) { ++ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { ++ Row( ++ modifier = Modifier.fillMaxWidth(), ++ horizontalArrangement = Arrangement.SpaceBetween, ++ verticalAlignment = Alignment.CenterVertically ++ ) { ++ AwanText( ++ text = "Claude Desktop (claude_desktop_config.json)", ++ style = AwanTheme.styles.headingText ++ ) ++ IconButton( ++ onClick = { ++ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager ++ clipboard.setPrimaryClip(ClipData.newPlainText("Claude Config", claudeSnippet)) ++ Toast.makeText(context, copiedToastMessage, Toast.LENGTH_SHORT).show() ++ }, ++ modifier = Modifier.size(28.dp) ++ ) { ++ Icon( ++ imageVector = Icons.Default.ContentCopy, ++ contentDescription = "Copy Claude Snippet", ++ tint = AwanTheme.colors.sky, ++ modifier = Modifier.size(16.dp) ++ ) ++ } ++ } ++ Box( ++ modifier = Modifier ++ .fillMaxWidth() ++ .clip(RoundedCornerShape(8.dp)) ++ .background(AwanTheme.colors.disabledSurface) ++ .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(8.dp)) ++ .padding(12.dp) ++ ) { ++ 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(8.dp)) { ++ Row( ++ modifier = Modifier.fillMaxWidth(), ++ horizontalArrangement = Arrangement.SpaceBetween, ++ verticalAlignment = Alignment.CenterVertically ++ ) { ++ AwanText( ++ text = "Cursor IDE Setup", ++ style = AwanTheme.styles.headingText ++ ) ++ IconButton( ++ onClick = { ++ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager ++ clipboard.setPrimaryClip(ClipData.newPlainText("Cursor Config", cursorSnippet)) ++ Toast.makeText(context, copiedToastMessage, Toast.LENGTH_SHORT).show() ++ }, ++ modifier = Modifier.size(28.dp) ++ ) { ++ Icon( ++ imageVector = Icons.Default.ContentCopy, ++ contentDescription = "Copy Cursor Snippet", ++ tint = AwanTheme.colors.sky, ++ modifier = Modifier.size(16.dp) ++ ) ++ } ++ } ++ Box( ++ modifier = Modifier ++ .fillMaxWidth() ++ .clip(RoundedCornerShape(8.dp)) ++ .background(AwanTheme.colors.disabledSurface) ++ .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(8.dp)) ++ .padding(12.dp) ++ ) { ++ AwanText( ++ text = cursorSnippet, ++ 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 0000000..6ff1aab +--- /dev/null ++++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/McpSettingsScreen.kt +@@ -0,0 +1,459 @@ ++package com.awan.feature.profile.impl.ui ++ ++import android.content.ClipData ++import android.content.ClipboardManager ++import android.content.Context ++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.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.LocalContext ++import androidx.compose.ui.res.stringResource ++import androidx.compose.ui.text.font.FontFamily ++import androidx.compose.ui.unit.dp ++import androidx.compose.ui.window.Dialog ++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 ++ ++@Composable ++fun McpSettingsScreen( ++ uiState: McpSettingsState, ++ onAction: (McpSettingsAction) -> Unit, ++ onInfoClick: () -> Unit, ++ onBackClick: () -> Unit, ++ modifier: Modifier = Modifier, ++) { ++ val context = LocalContext.current ++ val copiedToastMessage = stringResource(ProfileR.string.profile_mcp_token_copied) ++ var showAddTokenDialog by remember { mutableStateOf(false) } ++ var newTokenName by remember { mutableStateOf("") } ++ var deletingToken by remember { mutableStateOf(null) } ++ var regeneratingToken by remember { mutableStateOf(null) } ++ ++ if (uiState.createdToken != null) { ++ CreatedTokenModal( ++ createdToken = uiState.createdToken, ++ onDismiss = { onAction(McpSettingsAction.DismissCreatedModal) } ++ ) ++ } ++ ++ if (showAddTokenDialog) { ++ Dialog(onDismissRequest = { showAddTokenDialog = false }) { ++ AwanCard( ++ modifier = Modifier ++ .fillMaxWidth() ++ .padding(AwanTheme.spacing.md), ++ contentPadding = PaddingValues(AwanTheme.spacing.xl) ++ ) { ++ Column( ++ verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.md) ++ ) { ++ AwanText( ++ text = stringResource(ProfileR.string.profile_mcp_add_token), ++ style = AwanTheme.styles.titleText ++ ) ++ AwanTextField( ++ value = newTokenName, ++ onValueChange = { newTokenName = 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 = { ++ showAddTokenDialog = false ++ newTokenName = "" ++ }, ++ modifier = Modifier.weight(1f), ++ variant = AwanButtonVariant.Quiet ++ ) { ++ AwanText(stringResource(ProfileR.string.profile_cancel)) ++ } ++ AwanButton( ++ onClick = { ++ if (newTokenName.isNotBlank()) { ++ onAction(McpSettingsAction.CreateToken(newTokenName)) ++ showAddTokenDialog = false ++ newTokenName = "" ++ } ++ }, ++ modifier = Modifier.weight(1f), ++ enabled = newTokenName.isNotBlank() && !uiState.isCreating ++ ) { ++ if (uiState.isCreating) { ++ CircularProgressIndicator(modifier = Modifier.size(16.dp), color = AwanTheme.colors.surface) ++ } else { ++ AwanText(stringResource(ProfileR.string.profile_mcp_add_token)) ++ } ++ } ++ } ++ } ++ } ++ } ++ } ++ ++ if (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(deletingToken!!.id)) ++ deletingToken = null ++ }, ++ secondaryLabel = stringResource(ProfileR.string.profile_cancel), ++ onSecondary = { deletingToken = null }, ++ onDismiss = { deletingToken = null } ++ ) ++ } ++ ++ if (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(regeneratingToken!!.id)) ++ regeneratingToken = null ++ }, ++ secondaryLabel = stringResource(ProfileR.string.profile_cancel), ++ onSecondary = { regeneratingToken = null }, ++ onDismiss = { regeneratingToken = null } ++ ) ++ } ++ ++ Scaffold( ++ topBar = { ++ Row( ++ modifier = Modifier ++ .fillMaxWidth() ++ .statusBarsPadding() ++ .padding(horizontal = 16.dp, vertical = 12.dp), ++ 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 = "MCP Setup 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 = 20.dp, vertical = 16.dp), ++ verticalArrangement = Arrangement.spacedBy(16.dp) ++ ) { ++ // Connection Details Card ++ AwanCard( ++ modifier = Modifier.fillMaxWidth(), ++ contentPadding = PaddingValues(AwanTheme.spacing.md) ++ ) { ++ Column( ++ verticalArrangement = Arrangement.spacedBy(12.dp) ++ ) { ++ 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" ++ val clientId = details?.clientId ?: "awan-android-client" ++ ++ // MCP URL Row ++ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { ++ AwanText( ++ text = stringResource(ProfileR.string.profile_mcp_url_label), ++ style = AwanTheme.styles.captionText ++ ) ++ Row( ++ modifier = Modifier ++ .fillMaxWidth() ++ .clip(RoundedCornerShape(8.dp)) ++ .background(AwanTheme.colors.disabledSurface) ++ .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(8.dp)) ++ .padding(horizontal = 12.dp, vertical = 8.dp), ++ 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 = { ++ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager ++ clipboard.setPrimaryClip(ClipData.newPlainText("MCP URL", mcpUrl)) ++ Toast.makeText(context, copiedToastMessage, Toast.LENGTH_SHORT).show() ++ }, ++ modifier = Modifier.size(28.dp) ++ ) { ++ Icon( ++ imageVector = Icons.Default.ContentCopy, ++ contentDescription = "Copy URL", ++ tint = AwanTheme.colors.textSecondary, ++ modifier = Modifier.size(16.dp) ++ ) ++ } ++ } ++ } ++ ++ // Client ID Row ++ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { ++ AwanText( ++ text = stringResource(ProfileR.string.profile_mcp_client_id_label), ++ style = AwanTheme.styles.captionText ++ ) ++ Row( ++ modifier = Modifier ++ .fillMaxWidth() ++ .clip(RoundedCornerShape(8.dp)) ++ .background(AwanTheme.colors.disabledSurface) ++ .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(8.dp)) ++ .padding(horizontal = 12.dp, vertical = 8.dp), ++ horizontalArrangement = Arrangement.SpaceBetween, ++ verticalAlignment = Alignment.CenterVertically ++ ) { ++ AwanText( ++ text = clientId, ++ style = AwanTheme.styles.bodyText.let { it.copy(textStyle = it.textStyle.copy(fontFamily = FontFamily.Monospace)) }, ++ modifier = Modifier.weight(1f) ++ ) ++ IconButton( ++ onClick = { ++ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager ++ clipboard.setPrimaryClip(ClipData.newPlainText("Client ID", clientId)) ++ Toast.makeText(context, copiedToastMessage, Toast.LENGTH_SHORT).show() ++ }, ++ modifier = Modifier.size(28.dp) ++ ) { ++ Icon( ++ imageVector = Icons.Default.ContentCopy, ++ contentDescription = "Copy Client ID", ++ tint = AwanTheme.colors.textSecondary, ++ modifier = Modifier.size(16.dp) ++ ) ++ } ++ } ++ } ++ } ++ } ++ ++ // Tokens Card ++ AwanCard( ++ modifier = Modifier.fillMaxWidth(), ++ contentPadding = PaddingValues(AwanTheme.spacing.md) ++ ) { ++ Column( ++ verticalArrangement = Arrangement.spacedBy(12.dp) ++ ) { ++ 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 = { showAddTokenDialog = true }, ++ variant = AwanButtonVariant.Quiet ++ ) { ++ Row( ++ horizontalArrangement = Arrangement.spacedBy(4.dp), ++ verticalAlignment = Alignment.CenterVertically ++ ) { ++ Icon( ++ imageVector = Icons.Default.Add, ++ contentDescription = null, ++ modifier = Modifier.size(16.dp) ++ ) ++ AwanText(stringResource(ProfileR.string.profile_mcp_add_token)) ++ } ++ } ++ } ++ ++ if (uiState.tokens.isEmpty()) { ++ Box( ++ modifier = Modifier ++ .fillMaxWidth() ++ .padding(vertical = 16.dp), ++ contentAlignment = Alignment.Center ++ ) { ++ AwanText( ++ text = "No tokens added yet", ++ style = AwanTheme.styles.bodySecondaryText ++ ) ++ } ++ } else { ++ uiState.tokens.forEach { token -> ++ TokenItemRow( ++ token = token, ++ onRegenerate = { regeneratingToken = token }, ++ onDelete = { deletingToken = token } ++ ) ++ } ++ } ++ ++ // Security notice ++ AwanText( ++ text = stringResource(ProfileR.string.profile_mcp_token_obscured_notice) + ". " + ++ stringResource(ProfileR.string.profile_mcp_token_copy_disabled), ++ style = AwanTheme.styles.captionText, ++ modifier = Modifier.padding(top = 4.dp) ++ ) ++ } ++ } ++ } ++ ++ if (uiState.error != null) { ++ Box( ++ modifier = Modifier ++ .fillMaxSize() ++ .padding(20.dp), ++ 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(12.dp)) ++ .background(AwanTheme.colors.disabledSurface) ++ .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(12.dp)) ++ .padding(12.dp) ++ ) { ++ Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { ++ Row( ++ modifier = Modifier.fillMaxWidth(), ++ horizontalArrangement = Arrangement.SpaceBetween, ++ verticalAlignment = Alignment.CenterVertically ++ ) { ++ AwanText( ++ text = token.name, ++ style = AwanTheme.styles.bodyText ++ ) ++ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { ++ IconButton( ++ onClick = onRegenerate, ++ modifier = Modifier.size(32.dp) ++ ) { ++ Icon( ++ imageVector = Icons.Default.Refresh, ++ contentDescription = "Regenerate Token", ++ tint = AwanTheme.colors.sky, ++ modifier = Modifier.size(18.dp) ++ ) ++ } ++ IconButton( ++ onClick = onDelete, ++ modifier = Modifier.size(32.dp) ++ ) { ++ Icon( ++ imageVector = Icons.Default.Delete, ++ contentDescription = "Delete Token", ++ 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 = 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 0000000..bff02c0 +--- /dev/null ++++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/components/CreatedTokenModal.kt +@@ -0,0 +1,149 @@ ++package com.awan.feature.profile.impl.ui.components ++ ++import android.content.ClipData ++import android.content.ClipboardManager ++import android.content.Context ++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.LocalContext ++import androidx.compose.ui.res.stringResource ++import androidx.compose.ui.text.font.FontFamily ++import androidx.compose.ui.unit.dp ++import androidx.compose.ui.window.Dialog ++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.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 ++ ++@Composable ++fun CreatedTokenModal( ++ createdToken: CreatedMcpToken, ++ onDismiss: () -> Unit, ++ modifier: Modifier = Modifier, ++) { ++ val context = LocalContext.current ++ val copiedToastMessage = stringResource(ProfileR.string.profile_mcp_token_copied) ++ var copied by remember { mutableStateOf(false) } ++ ++ Dialog(onDismissRequest = onDismiss) { ++ AwanCard( ++ modifier = modifier ++ .fillMaxWidth() ++ .padding(AwanTheme.spacing.md), ++ contentPadding = PaddingValues(AwanTheme.spacing.xl) ++ ) { ++ Column( ++ 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(12.dp)) ++ .background(AwanTheme.colors.destructive.copy(alpha = 0.1f)) ++ .border(1.dp, AwanTheme.colors.destructive, RoundedCornerShape(12.dp)) ++ .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(20.dp) ++ ) ++ 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(12.dp)) ++ .background(AwanTheme.colors.disabledSurface) ++ .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(12.dp)) ++ .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 = { ++ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager ++ val clip = ClipData.newPlainText("MCP Token", createdToken.rawToken) ++ clipboard.setPrimaryClip(clip) ++ 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(16.dp) ++ ) ++ 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 a37d896..ae33358 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 8a385cb..7527c67 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,28 @@ + ╪¼ + ╪│ + ╪¡ ++ ++ ++ ╪¬┘â╪º┘à┘ä MCP ++ ╪▒╪¿╪╖ ╪º┘ä┘à╪│╪º╪╣╪»┘è┘å ╪º┘ä╪░┘â┘è┘è┘å (Claude, Cursor) ╪╣╪¿╪▒ ╪¿╪▒┘ê╪¬┘ê┘â┘ê┘ä MCP ++ ╪¬┘ü╪º╪╡┘è┘ä ╪º┘ä╪º╪¬╪╡╪º┘ä ++ ╪▒╪º╪¿╪╖ ╪«╪º╪»┘à MCP ++ ┘à╪╣╪▒┘ü ╪º┘ä╪╣┘à┘è┘ä (Client ID) ++ ╪▒┘à┘ê╪▓ ╪º┘ä┘ê╪╡┘ê┘ä (Tokens) ++ ╪Ñ╪╢╪º┘ü╪⌐ ╪▒┘à╪▓ ╪¼╪»┘è╪» ++ ╪º╪│┘à ╪º┘ä╪▒┘à╪▓ (┘à╪½╪º┘ä: Claude Desktop) ++ ╪¬┘à ╪Ñ╪«┘ü╪º╪í ╪º┘ä┘à┘ü╪¬╪º╪¡ ┘ä╪ú╪│╪¿╪º╪¿ ╪ú┘à┘å┘è╪⌐ ++ ┘è┘à┘â┘å ┘å╪│╪« ╪º┘ä╪▒┘à╪▓ ┘ü┘é╪╖ ╪╣┘å╪» ╪Ñ┘å╪┤╪º╪ª┘ç ┘ä╪ú┘ê┘ä ┘à╪▒╪⌐ ++ ╪¬┘à ╪Ñ┘å╪┤╪º╪í ╪º┘ä╪▒┘à╪▓ ╪¿┘å╪¼╪º╪¡! ++ ╪º╪¡╪▒╪╡ ╪╣┘ä┘ë ┘å╪│╪« ╪▒┘à╪▓ ╪º┘ä┘ê╪╡┘ê┘ä ╪º┘ä╪«╪º╪╡ ╪¿┘â ╪º┘ä╪ó┘å. ┘ä┘å ╪¬╪¬┘à┘â┘å ┘à┘å ╪▒╪ñ┘è╪¬┘ç ┘à╪▒╪⌐ ╪ú╪«╪▒┘ë! ++ ┘å╪│╪« ╪º┘ä╪▒┘à╪▓ ++ ╪¬┘à ┘å╪│╪« ╪º┘ä╪▒┘à╪▓ ╪Ñ┘ä┘ë ╪º┘ä╪¡╪º┘ü╪╕╪⌐ ++ ╪¡╪░┘ü ╪º┘ä╪▒┘à╪▓╪ƒ ++ ┘ç┘ä ╪ú┘å╪¬ ╪¬╪ú┘â╪» ┘à┘å ╪¡╪░┘ü ╪▒┘à╪▓ MCP ┘ç╪░╪º╪ƒ ╪│┘è┘ü┘é╪» ╪º┘ä┘à╪│╪º╪╣╪» ╪º┘ä╪░┘â┘è ╪º┘ä┘ê╪╡┘ê┘ä ┘ü┘ê╪▒╪º┘ï. ++ ╪Ñ╪╣╪º╪»╪⌐ ╪Ñ┘å╪┤╪º╪í ╪º┘ä╪▒┘à╪▓╪ƒ ++ ╪Ñ╪╣╪º╪»╪⌐ ╪Ñ┘å╪┤╪º╪í ╪º┘ä╪▒┘à╪▓ ╪│╪¬┘ä╪║┘è ╪º┘ä┘à┘ü╪¬╪º╪¡ ╪º┘ä╪¡╪º┘ä┘è. ╪│╪¬╪¡╪¬╪º╪¼ ╪Ñ┘ä┘ë ╪¬╪¡╪»┘è╪½┘ç ┘ü┘è ╪º┘ä┘à╪│╪º╪╣╪» ╪º┘ä╪░┘â┘è. ++ ┘â┘è┘ü┘è╪⌐ ╪▒╪¿╪╖ ┘à╪│╪º╪╣╪»┘â ╪º┘ä╪░┘â┘è ++ 1. ╪º┘å╪│╪« ╪▒╪º╪¿╪╖ ╪«╪º╪»┘à MCP ┘ê┘à╪╣╪▒┘ü ╪º┘ä╪╣┘à┘è┘ä ╪ú╪╣┘ä╪º┘ç. ++ 2. ╪ú┘å╪┤╪ª ╪▒┘à╪▓ ┘ê╪╡┘ê┘ä ┘ê╪º╪¡┘ü╪╕ ╪º┘ä┘à┘ü╪¬╪º╪¡ ┘ü┘ê╪▒╪º┘ï. ++ 3. ┘é┘à ╪¿╪¬╪╢┘à┘è┘å ╪º┘ä╪Ñ╪╣╪»╪º╪»╪º╪¬ ┘ü┘è ┘à┘ä┘ü ╪º┘ä╪¬┘â┘ê┘è┘å (┘à╪½┘ä claude_desktop_config.json). + +diff --git a/feature/profile/impl/src/main/res/values/strings.xml b/feature/profile/impl/src/main/res/values/strings.xml +index 6f43778..9770cd0 100644 +--- a/feature/profile/impl/src/main/res/values/strings.xml ++++ b/feature/profile/impl/src/main/res/values/strings.xml +@@ -184,4 +184,28 @@ + Fri + Sat + Sun ++ ++ ++ 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). + +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 0000000..261d7b6 +--- /dev/null ++++ b/feature/profile/impl/src/test/java/com/awan/feature/profile/impl/presentation/McpSettingsViewModelTest.kt +@@ -0,0 +1,163 @@ ++package com.awan.feature.profile.impl.presentation ++ ++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 `CreateToken action creates token, sets createdToken, and emits TokenCreated event`() = runTest(testDispatcher) { ++ val events = mutableListOf() ++ val job = launch { viewModel.events.toList(events) } ++ ++ 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) ++ assertEquals(1, events.size) ++ assert(events.first() is McpSettingsEvent.TokenCreated) ++ ++ job.cancel() ++ } ++ ++ @Test ++ fun `DeleteToken action removes token from state and emits TokenDeleted event`() = runTest(testDispatcher) { ++ val events = mutableListOf() ++ val job = launch { viewModel.events.toList(events) } ++ ++ viewModel.onAction(McpSettingsAction.DeleteToken("token-1")) ++ ++ val state = viewModel.uiState.value ++ assertEquals(0, state.tokens.size) ++ assertEquals(1, events.size) ++ assert(events.first() is McpSettingsEvent.TokenDeleted) ++ ++ job.cancel() ++ } ++ ++ @Test ++ fun `RegenerateToken action sets new createdToken and emits TokenRegenerated event`() = runTest(testDispatcher) { ++ val events = mutableListOf() ++ val job = launch { viewModel.events.toList(events) } ++ ++ viewModel.onAction(McpSettingsAction.RegenerateToken("token-1")) ++ ++ val state = viewModel.uiState.value ++ assertNotNull(state.createdToken) ++ assertEquals("raw_regenerated_token-1", state.createdToken?.rawToken) ++ 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 { ++ 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 { ++ 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/nav_routes.txt b/nav_routes.txt new file mode 100644 index 00000000..e69de29b diff --git a/vm_repo.txt b/vm_repo.txt new file mode 100644 index 0000000000000000000000000000000000000000..aa2d45c0eb9bbd8baee0fcc470e89e2e8472f1e4 GIT binary patch literal 2014 zcmd^h9Mfn#Y85p3|P#dGK+@+a3DTpiMMJU+PKc=nCYDoqnc^*y(kweP*4r?e%iA+#-C` zxtUZjqF}Muk%oAgpihZwdduQ7UKBF?94S^}&T(#0YS^?eXk4Q~i z+jIHrMElfFp$k}ghE>!Us#iz}71-zNhe(+&18q;e)s=g=0=AP7yNPR^*|OWSu1t4z z097{|)@jnvvFvs`{y}}u?H^eualuxyj<8tgS;orz%{$ZoBf6)Z1)_7ZZM>XLIuYB_ hw$`-{dr<2waQj+`gHVeVeWFWkx!|d}_MBb<$#=VSd(Z#? literal 0 HcmV?d00001 From 004ee8ccf632b26b97866c88133869860ef3c66f Mon Sep 17 00:00:00 2001 From: "ZEIAD-LAPTOP\\zeiad" Date: Tue, 11 Aug 2026 07:57:20 +0300 Subject: [PATCH 04/13] AWAN-210: Clean up temporary diff files --- db_module.txt | Bin 7082 -> 0 bytes diff.patch | Bin 338000 -> 0 bytes diff.txt | 4031 -------------------------------------------- domain_imports.txt | Bin 144626 -> 0 bytes mcp_diff.patch | 4031 -------------------------------------------- nav_routes.txt | 0 6 files changed, 8062 deletions(-) delete mode 100644 db_module.txt delete mode 100644 diff.patch delete mode 100644 diff.txt delete mode 100644 domain_imports.txt delete mode 100644 mcp_diff.patch delete mode 100644 nav_routes.txt diff --git a/db_module.txt b/db_module.txt deleted file mode 100644 index 83b6c1bdb70ee8565ff266f93c74b3bab34c227c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7082 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

C2UyxPMZ%a&o_r;x+InZCshT6?k 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 diff --git a/diff.txt b/diff.txt deleted file mode 100644 index 36b08960..00000000 --- a/diff.txt +++ /dev/null @@ -1,4031 +0,0 @@ -diff --git a/app/src/main/java/com/awan/app/AwanApp.kt b/app/src/main/java/com/awan/app/AwanApp.kt -index 3c0de9e..5ab1b5d 100644 ---- a/app/src/main/java/com/awan/app/AwanApp.kt -+++ b/app/src/main/java/com/awan/app/AwanApp.kt -@@ -250,7 +250,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(com.awan.feature.profile.api.McpSettingsRoute) }, -+ onNavigateToMcpInfo = { navigator.navigate(com.awan.feature.profile.api.McpInfoRoute) }, - ) - goalPreviewEntry( - onBack = { navigator.goBack() }, -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 0000000..31521c1 ---- /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/repository/McpRepositoryImpl.kt b/core/data/src/main/kotlin/com/awan/app/core/data/mcp/repository/McpRepositoryImpl.kt -new file mode 100644 -index 0000000..7550659 ---- /dev/null -+++ b/core/data/src/main/kotlin/com/awan/app/core/data/mcp/repository/McpRepositoryImpl.kt -@@ -0,0 +1,145 @@ -+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.database.dao.McpTokenDao -+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.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.api.McpApiService -+import com.awan.app.core.network.dto.mcp.CreateMcpTokenRequestDto -+import com.awan.app.core.network.error.safeApiCall -+import kotlinx.coroutines.CoroutineDispatcher -+import kotlinx.coroutines.flow.Flow -+import kotlinx.coroutines.flow.emitAll -+import kotlinx.coroutines.flow.flow -+import kotlinx.coroutines.flow.flowOn -+import kotlinx.coroutines.flow.map -+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 { -+ if (!connectivityMonitor.isCurrentlyOnline()) { -+ emit(Result.Error(AppError.Network)) -+ return@flow -+ } -+ val result = safeApiCall(ioDispatcher) { -+ val dto = mcpApiService.getConnectionDetails() -+ McpConnectionDetails( -+ mcpUrl = dto.mcpUrl, -+ clientId = dto.clientId, -+ ) -+ } -+ emit(result) -+ }.flowOn(ioDispatcher) -+ -+ override fun getMcpTokens(): Flow>> = flow { -+ if (connectivityMonitor.isCurrentlyOnline()) { -+ try { -+ val dtos = mcpApiService.getTokens() -+ val entities = dtos.map { dto -> -+ McpTokenEntity( -+ id = dto.id, -+ name = dto.name, -+ maskedToken = dto.maskedToken, -+ createdAt = dto.createdAt, -+ lastUsedAt = dto.lastUsedAt, -+ ) -+ } -+ mcpTokenDao.clearAll() -+ mcpTokenDao.upsertMcpTokens(entities) -+ } catch (_: Exception) { -+ // If network sync fails, fallback to Room cached tokens -+ } -+ } -+ emitAll( -+ mcpTokenDao.getMcpTokens().map { entities -> -+ Result.Success( -+ entities.map { entity -> -+ McpToken( -+ id = entity.id, -+ name = entity.name, -+ maskedToken = entity.maskedToken, -+ createdAt = entity.createdAt, -+ lastUsedAt = entity.lastUsedAt, -+ ) -+ } -+ ) -+ } -+ ) -+ }.flowOn(ioDispatcher) -+ -+ override suspend fun createMcpToken(name: String): Result { -+ if (!connectivityMonitor.isCurrentlyOnline()) { -+ return Result.Error(AppError.Network) -+ } -+ return safeApiCall(ioDispatcher) { -+ val dto = mcpApiService.createToken(CreateMcpTokenRequestDto(name = name)) -+ val entity = McpTokenEntity( -+ id = dto.id, -+ name = dto.name, -+ maskedToken = dto.maskedToken, -+ createdAt = dto.createdAt, -+ lastUsedAt = null, -+ ) -+ mcpTokenDao.upsertMcpTokens(listOf(entity)) -+ CreatedMcpToken( -+ id = dto.id, -+ name = dto.name, -+ rawToken = dto.rawToken, -+ maskedToken = dto.maskedToken, -+ createdAt = dto.createdAt, -+ ) -+ } -+ } -+ -+ override suspend fun deleteMcpToken(id: String): Result { -+ if (!connectivityMonitor.isCurrentlyOnline()) { -+ return Result.Error(AppError.Network) -+ } -+ val result = safeApiCall(ioDispatcher) { -+ mcpApiService.deleteToken(id) -+ } -+ if (result is Result.Success) { -+ mcpTokenDao.deleteMcpToken(id) -+ } -+ return result -+ } -+ -+ override suspend fun regenerateMcpToken(id: String): Result { -+ if (!connectivityMonitor.isCurrentlyOnline()) { -+ return Result.Error(AppError.Network) -+ } -+ return safeApiCall(ioDispatcher) { -+ val dto = mcpApiService.regenerateToken(id) -+ val entity = McpTokenEntity( -+ id = dto.id, -+ name = dto.name, -+ maskedToken = dto.maskedToken, -+ createdAt = dto.createdAt, -+ lastUsedAt = null, -+ ) -+ mcpTokenDao.upsertMcpTokens(listOf(entity)) -+ CreatedMcpToken( -+ id = dto.id, -+ name = dto.name, -+ rawToken = dto.rawToken, -+ maskedToken = dto.maskedToken, -+ createdAt = dto.createdAt, -+ ) -+ } -+ } -+} -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 0000000..4f6ccce ---- /dev/null -+++ b/core/data/src/test/java/com/awan/app/core/data/mcp/McpRepositoryImplTest.kt -@@ -0,0 +1,277 @@ -+package com.awan.app.core.data.mcp -+ -+import com.awan.app.core.common.error.AppError -+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.CreateMcpTokenRequestDto -+import com.awan.app.core.network.dto.mcp.CreatedMcpTokenResponseDto -+import com.awan.app.core.network.dto.mcp.McpConnectionDetailsDto -+import com.awan.app.core.network.dto.mcp.McpTokenResponseDto -+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 org.junit.Assert.assertEquals -+import org.junit.Assert.assertTrue -+import org.junit.Test -+ -+private class FakeMcpApiService : McpApiService { -+ var connectionDetailsDto = McpConnectionDetailsDto( -+ mcpUrl = "https://mcp.awan.com", -+ clientId = "client-123", -+ ) -+ var tokensList = mutableListOf() -+ var createdTokenResponse = CreatedMcpTokenResponseDto( -+ id = "token-1", -+ name = "Default Token", -+ rawToken = "raw-secret-123", -+ maskedToken = "mcp_...123", -+ createdAt = "2026-08-11T00:00:00Z", -+ ) -+ var shouldFailWithException: Exception? = null -+ var lastCreatedName: String? = null -+ var lastDeletedId: String? = null -+ var lastRegeneratedId: String? = null -+ -+ override suspend fun getConnectionDetails(): McpConnectionDetailsDto { -+ shouldFailWithException?.let { throw it } -+ return connectionDetailsDto -+ } -+ -+ override suspend fun getTokens(): List { -+ shouldFailWithException?.let { throw it } -+ return tokensList -+ } -+ -+ override suspend fun createToken(request: CreateMcpTokenRequestDto): CreatedMcpTokenResponseDto { -+ shouldFailWithException?.let { throw it } -+ lastCreatedName = request.name -+ return createdTokenResponse.copy(name = request.name) -+ } -+ -+ override suspend fun deleteToken(id: String) { -+ shouldFailWithException?.let { throw it } -+ lastDeletedId = id -+ } -+ -+ override suspend fun regenerateToken(id: String): CreatedMcpTokenResponseDto { -+ shouldFailWithException?.let { throw it } -+ lastRegeneratedId = id -+ return createdTokenResponse -+ } -+} -+ -+private class FakeMcpTokenDao : McpTokenDao { -+ private val tokensState = MutableStateFlow>(emptyList()) -+ val storedTokens: List get() = tokensState.value -+ -+ override fun getMcpTokens(): Flow> = tokensState -+ -+ override suspend fun upsertMcpTokens(tokens: List) { -+ 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() -+ } -+} -+ -+@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 success when online`() = runTest(testDispatcher) { -+ val apiService = FakeMcpApiService() -+ val repository = buildRepository(apiService = apiService, monitor = onlineMonitor) -+ -+ val result = repository.getMcpConnectionDetails().first() -+ -+ assertTrue(result is Result.Success) -+ val details = (result as Result.Success).data -+ assertEquals("https://mcp.awan.com", details.mcpUrl) -+ assertEquals("client-123", details.clientId) -+ } -+ -+ @Test -+ fun `getMcpConnectionDetails returns network error when offline`() = runTest(testDispatcher) { -+ val repository = buildRepository(monitor = offlineMonitor) -+ -+ val result = repository.getMcpConnectionDetails().first() -+ -+ assertTrue(result is Result.Error) -+ assertTrue((result as Result.Error).error is AppError.Network) -+ } -+ -+ @Test -+ fun `getMcpTokens fetches remote and updates Room when online`() = runTest(testDispatcher) { -+ val apiService = FakeMcpApiService().apply { -+ tokensList.add( -+ McpTokenResponseDto( -+ id = "token-1", -+ name = "Claude Desktop", -+ maskedToken = "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) -+ } -+ -+ @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 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 deletes token from network and 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.lastDeletedId) -+ 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 updates token in network and Room`() = runTest(testDispatcher) { -+ val apiService = FakeMcpApiService().apply { -+ createdTokenResponse = CreatedMcpTokenResponseDto( -+ id = "token-1", -+ name = "Claude Desktop", -+ rawToken = "new-raw-secret", -+ maskedToken = "mcp_...new", -+ 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("token-1", apiService.lastRegeneratedId) -+ assertEquals("mcp_...new", tokenDao.storedTokens.first().maskedToken) -+ } -+} -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 0000000..5142afa ---- /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 0695d6b..0a37454 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 0000000..b531812 ---- /dev/null -+++ b/core/database/src/main/kotlin/com/awan/app/core/database/dao/McpTokenDao.kt -@@ -0,0 +1,22 @@ -+package com.awan.app.core.database.dao -+ -+import androidx.room.Dao -+import androidx.room.Query -+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() -+} -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 2788919..4828f5c 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 0000000..9752977 ---- /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/domain/build.gradle.kts b/core/domain/build.gradle.kts -index 4e6c9ba..7251014 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 0000000..98aad99 ---- /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 0000000..e65b3cd ---- /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 0000000..d36fa0e ---- /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 0000000..bee60bd ---- /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 0000000..70f0ce6 ---- /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 0000000..a86f125 ---- /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 0000000..a6d3a9c ---- /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 0000000..d095671 ---- /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 0000000..670ab11 ---- /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 0000000..10f7823 ---- /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/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 0000000..8034e34 ---- /dev/null -+++ b/core/network/src/main/kotlin/com/awan/app/core/network/api/McpApiService.kt -@@ -0,0 +1,28 @@ -+package com.awan.app.core.network.api -+ -+import com.awan.app.core.network.dto.mcp.CreateMcpTokenRequestDto -+import com.awan.app.core.network.dto.mcp.CreatedMcpTokenResponseDto -+import com.awan.app.core.network.dto.mcp.McpConnectionDetailsDto -+import com.awan.app.core.network.dto.mcp.McpTokenResponseDto -+import retrofit2.http.Body -+import retrofit2.http.DELETE -+import retrofit2.http.GET -+import retrofit2.http.POST -+import retrofit2.http.Path -+ -+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 -+} -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 c9a0011..2ea5244 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 -@@ -176,6 +176,11 @@ object NetworkModule { - fun providesGoalApiService(retrofit: Retrofit): GoalApiService = - retrofit.create(GoalApiService::class.java) - -+ @Provides -+ @Singleton -+ fun providesMcpApiService(retrofit: Retrofit): com.awan.app.core.network.api.McpApiService = -+ retrofit.create(com.awan.app.core.network.api.McpApiService::class.java) -+ - @Provides - @Singleton - fun providesDeviceIdProvider( -diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreateMcpTokenRequestDto.kt b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreateMcpTokenRequestDto.kt -new file mode 100644 -index 0000000..cace98e ---- /dev/null -+++ b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreateMcpTokenRequestDto.kt -@@ -0,0 +1,9 @@ -+package com.awan.app.core.network.dto.mcp -+ -+import kotlinx.serialization.SerialName -+import kotlinx.serialization.Serializable -+ -+@Serializable -+data class CreateMcpTokenRequestDto( -+ @SerialName("name") val name: String, -+) -diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreatedMcpTokenResponseDto.kt b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreatedMcpTokenResponseDto.kt -new file mode 100644 -index 0000000..37af60f ---- /dev/null -+++ b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreatedMcpTokenResponseDto.kt -@@ -0,0 +1,13 @@ -+package com.awan.app.core.network.dto.mcp -+ -+import kotlinx.serialization.SerialName -+import kotlinx.serialization.Serializable -+ -+@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, -+) -diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpConnectionDetailsDto.kt b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpConnectionDetailsDto.kt -new file mode 100644 -index 0000000..9ce2cec ---- /dev/null -+++ b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpConnectionDetailsDto.kt -@@ -0,0 +1,10 @@ -+package com.awan.app.core.network.dto.mcp -+ -+import kotlinx.serialization.SerialName -+import kotlinx.serialization.Serializable -+ -+@Serializable -+data class McpConnectionDetailsDto( -+ @SerialName("mcpUrl") val mcpUrl: String, -+ @SerialName("clientId") val clientId: String, -+) -diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpTokenResponseDto.kt b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpTokenResponseDto.kt -new file mode 100644 -index 0000000..3ef767e ---- /dev/null -+++ b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpTokenResponseDto.kt -@@ -0,0 +1,13 @@ -+package com.awan.app.core.network.dto.mcp -+ -+import kotlinx.serialization.SerialName -+import kotlinx.serialization.Serializable -+ -+@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, -+) -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 0000000..a1493fe ---- /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/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 0000000..adecf9a ---- /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 0000000..e978685 ---- /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 0000000..8d4ae14 ---- /dev/null -+++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/navigation/McpInfoRouteScreen.kt -@@ -0,0 +1,13 @@ -+package com.awan.feature.profile.impl.navigation -+ -+import androidx.compose.runtime.Composable -+import com.awan.feature.profile.impl.ui.McpInfoScreen -+ -+@Composable -+fun McpInfoRouteScreen( -+ onBack: () -> Unit, -+) { -+ McpInfoScreen( -+ 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 0000000..0a9adbc ---- /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 cda412e..33029b9 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 3e0db73..03fa4aa 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 0000000..744ac75 ---- /dev/null -+++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsAction.kt -@@ -0,0 +1,10 @@ -+package com.awan.feature.profile.impl.presentation -+ -+sealed interface 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 0000000..cdc461a ---- /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 0000000..6816efb ---- /dev/null -+++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsState.kt -@@ -0,0 +1,16 @@ -+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 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 0000000..f4a8929 ---- /dev/null -+++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsViewModel.kt -@@ -0,0 +1,151 @@ -+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.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() -+ -+ init { -+ loadData() -+ } -+ -+ fun onAction(action: McpSettingsAction) { -+ when (action) { -+ 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() { -+ viewModelScope.launch { -+ _uiState.update { it.copy(isLoading = true, error = null) } -+ getMcpConnectionDetailsUseCase().collect { result -> -+ when (result) { -+ is Result.Success -> { -+ _uiState.update { it.copy(connectionDetails = result.data, isLoading = false) } -+ } -+ is Result.Error -> { -+ val uiError = ProfileErrorMapper.mapToUiText(result.error) -+ _uiState.update { it.copy(error = uiError, isLoading = false) } -+ } -+ Result.Loading -> { -+ _uiState.update { it.copy(isLoading = true) } -+ } -+ } -+ } -+ } -+ -+ viewModelScope.launch { -+ getMcpTokensUseCase().collect { result -> -+ when (result) { -+ is Result.Success -> { -+ _uiState.update { it.copy(tokens = result.data) } -+ } -+ is Result.Error -> { -+ val uiError = ProfileErrorMapper.mapToUiText(result.error) -+ _uiState.update { it.copy(error = uiError) } -+ } -+ 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, -+ ) -+ } -+ _events.send(McpSettingsEvent.TokenCreated(created)) -+ } -+ is Result.Error -> { -+ val uiError = ProfileErrorMapper.mapToUiText(result.error) -+ _uiState.update { it.copy(isCreating = 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(tokens = state.tokens.filterNot { it.id == id }) -+ } -+ _events.send(McpSettingsEvent.TokenDeleted) -+ } -+ is Result.Error -> { -+ val uiError = ProfileErrorMapper.mapToUiText(result.error) -+ _uiState.update { it.copy(error = uiError) } -+ _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) -+ } -+ _events.send(McpSettingsEvent.TokenRegenerated(regenerated)) -+ } -+ is Result.Error -> { -+ val uiError = ProfileErrorMapper.mapToUiText(result.error) -+ _uiState.update { it.copy(error = uiError) } -+ _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 0000000..9bfecf8 ---- /dev/null -+++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/McpInfoScreen.kt -@@ -0,0 +1,228 @@ -+package com.awan.feature.profile.impl.ui -+ -+import android.content.ClipData -+import android.content.ClipboardManager -+import android.content.Context -+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.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.LocalContext -+import androidx.compose.ui.res.stringResource -+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 -+ -+@Composable -+fun McpInfoScreen( -+ onBackClick: () -> Unit, -+ modifier: Modifier = Modifier, -+) { -+ val context = LocalContext.current -+ val copiedToastMessage = stringResource(ProfileR.string.profile_mcp_token_copied) -+ -+ val claudeSnippet = """ -+ { -+ "mcpServers": { -+ "awan": { -+ "command": "npx", -+ "args": [ -+ "-y", -+ "@awan/mcp-server", -+ "--url", "https://mcp.awan.app/v1", -+ "--token", "YOUR_API_TOKEN" -+ ] -+ } -+ } -+ } -+ """.trimIndent() -+ -+ val cursorSnippet = """ -+ { -+ "mcp": { -+ "servers": { -+ "awan": { -+ "url": "https://mcp.awan.app/v1", -+ "headers": { -+ "Authorization": "Bearer YOUR_API_TOKEN" -+ } -+ } -+ } -+ } -+ } -+ """.trimIndent() -+ -+ Scaffold( -+ topBar = { -+ Row( -+ modifier = Modifier -+ .fillMaxWidth() -+ .statusBarsPadding() -+ .padding(horizontal = 16.dp, vertical = 12.dp), -+ horizontalArrangement = Arrangement.spacedBy(16.dp), -+ 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 = 20.dp, vertical = 16.dp), -+ verticalArrangement = Arrangement.spacedBy(16.dp) -+ ) { -+ // Setup steps card -+ AwanCard( -+ modifier = Modifier.fillMaxWidth(), -+ contentPadding = PaddingValues(AwanTheme.spacing.md) -+ ) { -+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { -+ AwanText( -+ text = "Setup Instructions", -+ 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 -+ ) -+ AwanText( -+ text = stringResource(ProfileR.string.profile_mcp_info_step3), -+ style = AwanTheme.styles.bodyText -+ ) -+ } -+ } -+ -+ // Claude Desktop Guide -+ AwanCard( -+ modifier = Modifier.fillMaxWidth(), -+ contentPadding = PaddingValues(AwanTheme.spacing.md) -+ ) { -+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { -+ Row( -+ modifier = Modifier.fillMaxWidth(), -+ horizontalArrangement = Arrangement.SpaceBetween, -+ verticalAlignment = Alignment.CenterVertically -+ ) { -+ AwanText( -+ text = "Claude Desktop (claude_desktop_config.json)", -+ style = AwanTheme.styles.headingText -+ ) -+ IconButton( -+ onClick = { -+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager -+ clipboard.setPrimaryClip(ClipData.newPlainText("Claude Config", claudeSnippet)) -+ Toast.makeText(context, copiedToastMessage, Toast.LENGTH_SHORT).show() -+ }, -+ modifier = Modifier.size(28.dp) -+ ) { -+ Icon( -+ imageVector = Icons.Default.ContentCopy, -+ contentDescription = "Copy Claude Snippet", -+ tint = AwanTheme.colors.sky, -+ modifier = Modifier.size(16.dp) -+ ) -+ } -+ } -+ Box( -+ modifier = Modifier -+ .fillMaxWidth() -+ .clip(RoundedCornerShape(8.dp)) -+ .background(AwanTheme.colors.disabledSurface) -+ .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(8.dp)) -+ .padding(12.dp) -+ ) { -+ 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(8.dp)) { -+ Row( -+ modifier = Modifier.fillMaxWidth(), -+ horizontalArrangement = Arrangement.SpaceBetween, -+ verticalAlignment = Alignment.CenterVertically -+ ) { -+ AwanText( -+ text = "Cursor IDE Setup", -+ style = AwanTheme.styles.headingText -+ ) -+ IconButton( -+ onClick = { -+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager -+ clipboard.setPrimaryClip(ClipData.newPlainText("Cursor Config", cursorSnippet)) -+ Toast.makeText(context, copiedToastMessage, Toast.LENGTH_SHORT).show() -+ }, -+ modifier = Modifier.size(28.dp) -+ ) { -+ Icon( -+ imageVector = Icons.Default.ContentCopy, -+ contentDescription = "Copy Cursor Snippet", -+ tint = AwanTheme.colors.sky, -+ modifier = Modifier.size(16.dp) -+ ) -+ } -+ } -+ Box( -+ modifier = Modifier -+ .fillMaxWidth() -+ .clip(RoundedCornerShape(8.dp)) -+ .background(AwanTheme.colors.disabledSurface) -+ .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(8.dp)) -+ .padding(12.dp) -+ ) { -+ AwanText( -+ text = cursorSnippet, -+ 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 0000000..6ff1aab ---- /dev/null -+++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/McpSettingsScreen.kt -@@ -0,0 +1,459 @@ -+package com.awan.feature.profile.impl.ui -+ -+import android.content.ClipData -+import android.content.ClipboardManager -+import android.content.Context -+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.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.LocalContext -+import androidx.compose.ui.res.stringResource -+import androidx.compose.ui.text.font.FontFamily -+import androidx.compose.ui.unit.dp -+import androidx.compose.ui.window.Dialog -+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 -+ -+@Composable -+fun McpSettingsScreen( -+ uiState: McpSettingsState, -+ onAction: (McpSettingsAction) -> Unit, -+ onInfoClick: () -> Unit, -+ onBackClick: () -> Unit, -+ modifier: Modifier = Modifier, -+) { -+ val context = LocalContext.current -+ val copiedToastMessage = stringResource(ProfileR.string.profile_mcp_token_copied) -+ var showAddTokenDialog by remember { mutableStateOf(false) } -+ var newTokenName by remember { mutableStateOf("") } -+ var deletingToken by remember { mutableStateOf(null) } -+ var regeneratingToken by remember { mutableStateOf(null) } -+ -+ if (uiState.createdToken != null) { -+ CreatedTokenModal( -+ createdToken = uiState.createdToken, -+ onDismiss = { onAction(McpSettingsAction.DismissCreatedModal) } -+ ) -+ } -+ -+ if (showAddTokenDialog) { -+ Dialog(onDismissRequest = { showAddTokenDialog = false }) { -+ AwanCard( -+ modifier = Modifier -+ .fillMaxWidth() -+ .padding(AwanTheme.spacing.md), -+ contentPadding = PaddingValues(AwanTheme.spacing.xl) -+ ) { -+ Column( -+ verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.md) -+ ) { -+ AwanText( -+ text = stringResource(ProfileR.string.profile_mcp_add_token), -+ style = AwanTheme.styles.titleText -+ ) -+ AwanTextField( -+ value = newTokenName, -+ onValueChange = { newTokenName = 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 = { -+ showAddTokenDialog = false -+ newTokenName = "" -+ }, -+ modifier = Modifier.weight(1f), -+ variant = AwanButtonVariant.Quiet -+ ) { -+ AwanText(stringResource(ProfileR.string.profile_cancel)) -+ } -+ AwanButton( -+ onClick = { -+ if (newTokenName.isNotBlank()) { -+ onAction(McpSettingsAction.CreateToken(newTokenName)) -+ showAddTokenDialog = false -+ newTokenName = "" -+ } -+ }, -+ modifier = Modifier.weight(1f), -+ enabled = newTokenName.isNotBlank() && !uiState.isCreating -+ ) { -+ if (uiState.isCreating) { -+ CircularProgressIndicator(modifier = Modifier.size(16.dp), color = AwanTheme.colors.surface) -+ } else { -+ AwanText(stringResource(ProfileR.string.profile_mcp_add_token)) -+ } -+ } -+ } -+ } -+ } -+ } -+ } -+ -+ if (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(deletingToken!!.id)) -+ deletingToken = null -+ }, -+ secondaryLabel = stringResource(ProfileR.string.profile_cancel), -+ onSecondary = { deletingToken = null }, -+ onDismiss = { deletingToken = null } -+ ) -+ } -+ -+ if (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(regeneratingToken!!.id)) -+ regeneratingToken = null -+ }, -+ secondaryLabel = stringResource(ProfileR.string.profile_cancel), -+ onSecondary = { regeneratingToken = null }, -+ onDismiss = { regeneratingToken = null } -+ ) -+ } -+ -+ Scaffold( -+ topBar = { -+ Row( -+ modifier = Modifier -+ .fillMaxWidth() -+ .statusBarsPadding() -+ .padding(horizontal = 16.dp, vertical = 12.dp), -+ 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 = "MCP Setup 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 = 20.dp, vertical = 16.dp), -+ verticalArrangement = Arrangement.spacedBy(16.dp) -+ ) { -+ // Connection Details Card -+ AwanCard( -+ modifier = Modifier.fillMaxWidth(), -+ contentPadding = PaddingValues(AwanTheme.spacing.md) -+ ) { -+ Column( -+ verticalArrangement = Arrangement.spacedBy(12.dp) -+ ) { -+ 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" -+ val clientId = details?.clientId ?: "awan-android-client" -+ -+ // MCP URL Row -+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { -+ AwanText( -+ text = stringResource(ProfileR.string.profile_mcp_url_label), -+ style = AwanTheme.styles.captionText -+ ) -+ Row( -+ modifier = Modifier -+ .fillMaxWidth() -+ .clip(RoundedCornerShape(8.dp)) -+ .background(AwanTheme.colors.disabledSurface) -+ .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(8.dp)) -+ .padding(horizontal = 12.dp, vertical = 8.dp), -+ 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 = { -+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager -+ clipboard.setPrimaryClip(ClipData.newPlainText("MCP URL", mcpUrl)) -+ Toast.makeText(context, copiedToastMessage, Toast.LENGTH_SHORT).show() -+ }, -+ modifier = Modifier.size(28.dp) -+ ) { -+ Icon( -+ imageVector = Icons.Default.ContentCopy, -+ contentDescription = "Copy URL", -+ tint = AwanTheme.colors.textSecondary, -+ modifier = Modifier.size(16.dp) -+ ) -+ } -+ } -+ } -+ -+ // Client ID Row -+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { -+ AwanText( -+ text = stringResource(ProfileR.string.profile_mcp_client_id_label), -+ style = AwanTheme.styles.captionText -+ ) -+ Row( -+ modifier = Modifier -+ .fillMaxWidth() -+ .clip(RoundedCornerShape(8.dp)) -+ .background(AwanTheme.colors.disabledSurface) -+ .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(8.dp)) -+ .padding(horizontal = 12.dp, vertical = 8.dp), -+ horizontalArrangement = Arrangement.SpaceBetween, -+ verticalAlignment = Alignment.CenterVertically -+ ) { -+ AwanText( -+ text = clientId, -+ style = AwanTheme.styles.bodyText.let { it.copy(textStyle = it.textStyle.copy(fontFamily = FontFamily.Monospace)) }, -+ modifier = Modifier.weight(1f) -+ ) -+ IconButton( -+ onClick = { -+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager -+ clipboard.setPrimaryClip(ClipData.newPlainText("Client ID", clientId)) -+ Toast.makeText(context, copiedToastMessage, Toast.LENGTH_SHORT).show() -+ }, -+ modifier = Modifier.size(28.dp) -+ ) { -+ Icon( -+ imageVector = Icons.Default.ContentCopy, -+ contentDescription = "Copy Client ID", -+ tint = AwanTheme.colors.textSecondary, -+ modifier = Modifier.size(16.dp) -+ ) -+ } -+ } -+ } -+ } -+ } -+ -+ // Tokens Card -+ AwanCard( -+ modifier = Modifier.fillMaxWidth(), -+ contentPadding = PaddingValues(AwanTheme.spacing.md) -+ ) { -+ Column( -+ verticalArrangement = Arrangement.spacedBy(12.dp) -+ ) { -+ 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 = { showAddTokenDialog = true }, -+ variant = AwanButtonVariant.Quiet -+ ) { -+ Row( -+ horizontalArrangement = Arrangement.spacedBy(4.dp), -+ verticalAlignment = Alignment.CenterVertically -+ ) { -+ Icon( -+ imageVector = Icons.Default.Add, -+ contentDescription = null, -+ modifier = Modifier.size(16.dp) -+ ) -+ AwanText(stringResource(ProfileR.string.profile_mcp_add_token)) -+ } -+ } -+ } -+ -+ if (uiState.tokens.isEmpty()) { -+ Box( -+ modifier = Modifier -+ .fillMaxWidth() -+ .padding(vertical = 16.dp), -+ contentAlignment = Alignment.Center -+ ) { -+ AwanText( -+ text = "No tokens added yet", -+ style = AwanTheme.styles.bodySecondaryText -+ ) -+ } -+ } else { -+ uiState.tokens.forEach { token -> -+ TokenItemRow( -+ token = token, -+ onRegenerate = { regeneratingToken = token }, -+ onDelete = { deletingToken = token } -+ ) -+ } -+ } -+ -+ // Security notice -+ AwanText( -+ text = stringResource(ProfileR.string.profile_mcp_token_obscured_notice) + ". " + -+ stringResource(ProfileR.string.profile_mcp_token_copy_disabled), -+ style = AwanTheme.styles.captionText, -+ modifier = Modifier.padding(top = 4.dp) -+ ) -+ } -+ } -+ } -+ -+ if (uiState.error != null) { -+ Box( -+ modifier = Modifier -+ .fillMaxSize() -+ .padding(20.dp), -+ 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(12.dp)) -+ .background(AwanTheme.colors.disabledSurface) -+ .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(12.dp)) -+ .padding(12.dp) -+ ) { -+ Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { -+ Row( -+ modifier = Modifier.fillMaxWidth(), -+ horizontalArrangement = Arrangement.SpaceBetween, -+ verticalAlignment = Alignment.CenterVertically -+ ) { -+ AwanText( -+ text = token.name, -+ style = AwanTheme.styles.bodyText -+ ) -+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { -+ IconButton( -+ onClick = onRegenerate, -+ modifier = Modifier.size(32.dp) -+ ) { -+ Icon( -+ imageVector = Icons.Default.Refresh, -+ contentDescription = "Regenerate Token", -+ tint = AwanTheme.colors.sky, -+ modifier = Modifier.size(18.dp) -+ ) -+ } -+ IconButton( -+ onClick = onDelete, -+ modifier = Modifier.size(32.dp) -+ ) { -+ Icon( -+ imageVector = Icons.Default.Delete, -+ contentDescription = "Delete Token", -+ 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 = 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 0000000..bff02c0 ---- /dev/null -+++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/components/CreatedTokenModal.kt -@@ -0,0 +1,149 @@ -+package com.awan.feature.profile.impl.ui.components -+ -+import android.content.ClipData -+import android.content.ClipboardManager -+import android.content.Context -+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.LocalContext -+import androidx.compose.ui.res.stringResource -+import androidx.compose.ui.text.font.FontFamily -+import androidx.compose.ui.unit.dp -+import androidx.compose.ui.window.Dialog -+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.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 -+ -+@Composable -+fun CreatedTokenModal( -+ createdToken: CreatedMcpToken, -+ onDismiss: () -> Unit, -+ modifier: Modifier = Modifier, -+) { -+ val context = LocalContext.current -+ val copiedToastMessage = stringResource(ProfileR.string.profile_mcp_token_copied) -+ var copied by remember { mutableStateOf(false) } -+ -+ Dialog(onDismissRequest = onDismiss) { -+ AwanCard( -+ modifier = modifier -+ .fillMaxWidth() -+ .padding(AwanTheme.spacing.md), -+ contentPadding = PaddingValues(AwanTheme.spacing.xl) -+ ) { -+ Column( -+ 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(12.dp)) -+ .background(AwanTheme.colors.destructive.copy(alpha = 0.1f)) -+ .border(1.dp, AwanTheme.colors.destructive, RoundedCornerShape(12.dp)) -+ .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(20.dp) -+ ) -+ 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(12.dp)) -+ .background(AwanTheme.colors.disabledSurface) -+ .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(12.dp)) -+ .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 = { -+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager -+ val clip = ClipData.newPlainText("MCP Token", createdToken.rawToken) -+ clipboard.setPrimaryClip(clip) -+ 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(16.dp) -+ ) -+ 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 a37d896..ae33358 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 8a385cb..7527c67 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,28 @@ - ج - س - ح -+ -+ -+ تكامل MCP -+ ربط المساعدين الذكيين (Claude, Cursor) عبر بروتوكول MCP -+ تفاصيل الاتصال -+ رابط خادم MCP -+ معرف العميل (Client ID) -+ رموز الوصول (Tokens) -+ إضافة رمز جديد -+ اسم الرمز (مثال: Claude Desktop) -+ تم إخفاء المفتاح لأسباب أمنية -+ يمكن نسخ الرمز فقط عند إنشائه لأول مرة -+ تم إنشاء الرمز بنجاح! -+ احرص على نسخ رمز الوصول الخاص بك الآن. لن تتمكن من رؤيته مرة أخرى! -+ نسخ الرمز -+ تم نسخ الرمز إلى الحافظة -+ حذف الرمز؟ -+ هل أنت تأكد من حذف رمز MCP هذا؟ سيفقد المساعد الذكي الوصول فوراً. -+ إعادة إنشاء الرمز؟ -+ إعادة إنشاء الرمز ستلغي المفتاح الحالي. ستحتاج إلى تحديثه في المساعد الذكي. -+ كيفية ربط مساعدك الذكي -+ 1. انسخ رابط خادم MCP ومعرف العميل أعلاه. -+ 2. أنشئ رمز وصول واحفظ المفتاح فوراً. -+ 3. قم بتضمين الإعدادات في ملف التكوين (مثل claude_desktop_config.json). - -diff --git a/feature/profile/impl/src/main/res/values/strings.xml b/feature/profile/impl/src/main/res/values/strings.xml -index 6f43778..9770cd0 100644 ---- a/feature/profile/impl/src/main/res/values/strings.xml -+++ b/feature/profile/impl/src/main/res/values/strings.xml -@@ -184,4 +184,28 @@ - Fri - Sat - Sun -+ -+ -+ 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). - -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 0000000..261d7b6 ---- /dev/null -+++ b/feature/profile/impl/src/test/java/com/awan/feature/profile/impl/presentation/McpSettingsViewModelTest.kt -@@ -0,0 +1,163 @@ -+package com.awan.feature.profile.impl.presentation -+ -+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 `CreateToken action creates token, sets createdToken, and emits TokenCreated event`() = runTest(testDispatcher) { -+ val events = mutableListOf() -+ val job = launch { viewModel.events.toList(events) } -+ -+ 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) -+ assertEquals(1, events.size) -+ assert(events.first() is McpSettingsEvent.TokenCreated) -+ -+ job.cancel() -+ } -+ -+ @Test -+ fun `DeleteToken action removes token from state and emits TokenDeleted event`() = runTest(testDispatcher) { -+ val events = mutableListOf() -+ val job = launch { viewModel.events.toList(events) } -+ -+ viewModel.onAction(McpSettingsAction.DeleteToken("token-1")) -+ -+ val state = viewModel.uiState.value -+ assertEquals(0, state.tokens.size) -+ assertEquals(1, events.size) -+ assert(events.first() is McpSettingsEvent.TokenDeleted) -+ -+ job.cancel() -+ } -+ -+ @Test -+ fun `RegenerateToken action sets new createdToken and emits TokenRegenerated event`() = runTest(testDispatcher) { -+ val events = mutableListOf() -+ val job = launch { viewModel.events.toList(events) } -+ -+ viewModel.onAction(McpSettingsAction.RegenerateToken("token-1")) -+ -+ val state = viewModel.uiState.value -+ assertNotNull(state.createdToken) -+ assertEquals("raw_regenerated_token-1", state.createdToken?.rawToken) -+ 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 { -+ 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 { -+ 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/domain_imports.txt b/domain_imports.txt deleted file mode 100644 index aed8d59b74d7fd65a8df7d8b27f220d657c76d36..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 144626 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 I?2kDA4}`>!4*&oF diff --git a/mcp_diff.patch b/mcp_diff.patch deleted file mode 100644 index 541e4be1..00000000 --- a/mcp_diff.patch +++ /dev/null @@ -1,4031 +0,0 @@ -diff --git a/app/src/main/java/com/awan/app/AwanApp.kt b/app/src/main/java/com/awan/app/AwanApp.kt -index 3c0de9e..5ab1b5d 100644 ---- a/app/src/main/java/com/awan/app/AwanApp.kt -+++ b/app/src/main/java/com/awan/app/AwanApp.kt -@@ -250,7 +250,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(com.awan.feature.profile.api.McpSettingsRoute) }, -+ onNavigateToMcpInfo = { navigator.navigate(com.awan.feature.profile.api.McpInfoRoute) }, - ) - goalPreviewEntry( - onBack = { navigator.goBack() }, -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 0000000..31521c1 ---- /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/repository/McpRepositoryImpl.kt b/core/data/src/main/kotlin/com/awan/app/core/data/mcp/repository/McpRepositoryImpl.kt -new file mode 100644 -index 0000000..7550659 ---- /dev/null -+++ b/core/data/src/main/kotlin/com/awan/app/core/data/mcp/repository/McpRepositoryImpl.kt -@@ -0,0 +1,145 @@ -+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.database.dao.McpTokenDao -+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.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.api.McpApiService -+import com.awan.app.core.network.dto.mcp.CreateMcpTokenRequestDto -+import com.awan.app.core.network.error.safeApiCall -+import kotlinx.coroutines.CoroutineDispatcher -+import kotlinx.coroutines.flow.Flow -+import kotlinx.coroutines.flow.emitAll -+import kotlinx.coroutines.flow.flow -+import kotlinx.coroutines.flow.flowOn -+import kotlinx.coroutines.flow.map -+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 { -+ if (!connectivityMonitor.isCurrentlyOnline()) { -+ emit(Result.Error(AppError.Network)) -+ return@flow -+ } -+ val result = safeApiCall(ioDispatcher) { -+ val dto = mcpApiService.getConnectionDetails() -+ McpConnectionDetails( -+ mcpUrl = dto.mcpUrl, -+ clientId = dto.clientId, -+ ) -+ } -+ emit(result) -+ }.flowOn(ioDispatcher) -+ -+ override fun getMcpTokens(): Flow>> = flow { -+ if (connectivityMonitor.isCurrentlyOnline()) { -+ try { -+ val dtos = mcpApiService.getTokens() -+ val entities = dtos.map { dto -> -+ McpTokenEntity( -+ id = dto.id, -+ name = dto.name, -+ maskedToken = dto.maskedToken, -+ createdAt = dto.createdAt, -+ lastUsedAt = dto.lastUsedAt, -+ ) -+ } -+ mcpTokenDao.clearAll() -+ mcpTokenDao.upsertMcpTokens(entities) -+ } catch (_: Exception) { -+ // If network sync fails, fallback to Room cached tokens -+ } -+ } -+ emitAll( -+ mcpTokenDao.getMcpTokens().map { entities -> -+ Result.Success( -+ entities.map { entity -> -+ McpToken( -+ id = entity.id, -+ name = entity.name, -+ maskedToken = entity.maskedToken, -+ createdAt = entity.createdAt, -+ lastUsedAt = entity.lastUsedAt, -+ ) -+ } -+ ) -+ } -+ ) -+ }.flowOn(ioDispatcher) -+ -+ override suspend fun createMcpToken(name: String): Result { -+ if (!connectivityMonitor.isCurrentlyOnline()) { -+ return Result.Error(AppError.Network) -+ } -+ return safeApiCall(ioDispatcher) { -+ val dto = mcpApiService.createToken(CreateMcpTokenRequestDto(name = name)) -+ val entity = McpTokenEntity( -+ id = dto.id, -+ name = dto.name, -+ maskedToken = dto.maskedToken, -+ createdAt = dto.createdAt, -+ lastUsedAt = null, -+ ) -+ mcpTokenDao.upsertMcpTokens(listOf(entity)) -+ CreatedMcpToken( -+ id = dto.id, -+ name = dto.name, -+ rawToken = dto.rawToken, -+ maskedToken = dto.maskedToken, -+ createdAt = dto.createdAt, -+ ) -+ } -+ } -+ -+ override suspend fun deleteMcpToken(id: String): Result { -+ if (!connectivityMonitor.isCurrentlyOnline()) { -+ return Result.Error(AppError.Network) -+ } -+ val result = safeApiCall(ioDispatcher) { -+ mcpApiService.deleteToken(id) -+ } -+ if (result is Result.Success) { -+ mcpTokenDao.deleteMcpToken(id) -+ } -+ return result -+ } -+ -+ override suspend fun regenerateMcpToken(id: String): Result { -+ if (!connectivityMonitor.isCurrentlyOnline()) { -+ return Result.Error(AppError.Network) -+ } -+ return safeApiCall(ioDispatcher) { -+ val dto = mcpApiService.regenerateToken(id) -+ val entity = McpTokenEntity( -+ id = dto.id, -+ name = dto.name, -+ maskedToken = dto.maskedToken, -+ createdAt = dto.createdAt, -+ lastUsedAt = null, -+ ) -+ mcpTokenDao.upsertMcpTokens(listOf(entity)) -+ CreatedMcpToken( -+ id = dto.id, -+ name = dto.name, -+ rawToken = dto.rawToken, -+ maskedToken = dto.maskedToken, -+ createdAt = dto.createdAt, -+ ) -+ } -+ } -+} -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 0000000..4f6ccce ---- /dev/null -+++ b/core/data/src/test/java/com/awan/app/core/data/mcp/McpRepositoryImplTest.kt -@@ -0,0 +1,277 @@ -+package com.awan.app.core.data.mcp -+ -+import com.awan.app.core.common.error.AppError -+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.CreateMcpTokenRequestDto -+import com.awan.app.core.network.dto.mcp.CreatedMcpTokenResponseDto -+import com.awan.app.core.network.dto.mcp.McpConnectionDetailsDto -+import com.awan.app.core.network.dto.mcp.McpTokenResponseDto -+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 org.junit.Assert.assertEquals -+import org.junit.Assert.assertTrue -+import org.junit.Test -+ -+private class FakeMcpApiService : McpApiService { -+ var connectionDetailsDto = McpConnectionDetailsDto( -+ mcpUrl = "https://mcp.awan.com", -+ clientId = "client-123", -+ ) -+ var tokensList = mutableListOf() -+ var createdTokenResponse = CreatedMcpTokenResponseDto( -+ id = "token-1", -+ name = "Default Token", -+ rawToken = "raw-secret-123", -+ maskedToken = "mcp_...123", -+ createdAt = "2026-08-11T00:00:00Z", -+ ) -+ var shouldFailWithException: Exception? = null -+ var lastCreatedName: String? = null -+ var lastDeletedId: String? = null -+ var lastRegeneratedId: String? = null -+ -+ override suspend fun getConnectionDetails(): McpConnectionDetailsDto { -+ shouldFailWithException?.let { throw it } -+ return connectionDetailsDto -+ } -+ -+ override suspend fun getTokens(): List { -+ shouldFailWithException?.let { throw it } -+ return tokensList -+ } -+ -+ override suspend fun createToken(request: CreateMcpTokenRequestDto): CreatedMcpTokenResponseDto { -+ shouldFailWithException?.let { throw it } -+ lastCreatedName = request.name -+ return createdTokenResponse.copy(name = request.name) -+ } -+ -+ override suspend fun deleteToken(id: String) { -+ shouldFailWithException?.let { throw it } -+ lastDeletedId = id -+ } -+ -+ override suspend fun regenerateToken(id: String): CreatedMcpTokenResponseDto { -+ shouldFailWithException?.let { throw it } -+ lastRegeneratedId = id -+ return createdTokenResponse -+ } -+} -+ -+private class FakeMcpTokenDao : McpTokenDao { -+ private val tokensState = MutableStateFlow>(emptyList()) -+ val storedTokens: List get() = tokensState.value -+ -+ override fun getMcpTokens(): Flow> = tokensState -+ -+ override suspend fun upsertMcpTokens(tokens: List) { -+ 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() -+ } -+} -+ -+@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 success when online`() = runTest(testDispatcher) { -+ val apiService = FakeMcpApiService() -+ val repository = buildRepository(apiService = apiService, monitor = onlineMonitor) -+ -+ val result = repository.getMcpConnectionDetails().first() -+ -+ assertTrue(result is Result.Success) -+ val details = (result as Result.Success).data -+ assertEquals("https://mcp.awan.com", details.mcpUrl) -+ assertEquals("client-123", details.clientId) -+ } -+ -+ @Test -+ fun `getMcpConnectionDetails returns network error when offline`() = runTest(testDispatcher) { -+ val repository = buildRepository(monitor = offlineMonitor) -+ -+ val result = repository.getMcpConnectionDetails().first() -+ -+ assertTrue(result is Result.Error) -+ assertTrue((result as Result.Error).error is AppError.Network) -+ } -+ -+ @Test -+ fun `getMcpTokens fetches remote and updates Room when online`() = runTest(testDispatcher) { -+ val apiService = FakeMcpApiService().apply { -+ tokensList.add( -+ McpTokenResponseDto( -+ id = "token-1", -+ name = "Claude Desktop", -+ maskedToken = "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) -+ } -+ -+ @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 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 deletes token from network and 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.lastDeletedId) -+ 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 updates token in network and Room`() = runTest(testDispatcher) { -+ val apiService = FakeMcpApiService().apply { -+ createdTokenResponse = CreatedMcpTokenResponseDto( -+ id = "token-1", -+ name = "Claude Desktop", -+ rawToken = "new-raw-secret", -+ maskedToken = "mcp_...new", -+ 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("token-1", apiService.lastRegeneratedId) -+ assertEquals("mcp_...new", tokenDao.storedTokens.first().maskedToken) -+ } -+} -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 0000000..5142afa ---- /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 0695d6b..0a37454 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 0000000..b531812 ---- /dev/null -+++ b/core/database/src/main/kotlin/com/awan/app/core/database/dao/McpTokenDao.kt -@@ -0,0 +1,22 @@ -+package com.awan.app.core.database.dao -+ -+import androidx.room.Dao -+import androidx.room.Query -+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() -+} -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 2788919..4828f5c 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 0000000..9752977 ---- /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/domain/build.gradle.kts b/core/domain/build.gradle.kts -index 4e6c9ba..7251014 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 0000000..98aad99 ---- /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 0000000..e65b3cd ---- /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 0000000..d36fa0e ---- /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 0000000..bee60bd ---- /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 0000000..70f0ce6 ---- /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 0000000..a86f125 ---- /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 0000000..a6d3a9c ---- /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 0000000..d095671 ---- /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 0000000..670ab11 ---- /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 0000000..10f7823 ---- /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/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 0000000..8034e34 ---- /dev/null -+++ b/core/network/src/main/kotlin/com/awan/app/core/network/api/McpApiService.kt -@@ -0,0 +1,28 @@ -+package com.awan.app.core.network.api -+ -+import com.awan.app.core.network.dto.mcp.CreateMcpTokenRequestDto -+import com.awan.app.core.network.dto.mcp.CreatedMcpTokenResponseDto -+import com.awan.app.core.network.dto.mcp.McpConnectionDetailsDto -+import com.awan.app.core.network.dto.mcp.McpTokenResponseDto -+import retrofit2.http.Body -+import retrofit2.http.DELETE -+import retrofit2.http.GET -+import retrofit2.http.POST -+import retrofit2.http.Path -+ -+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 -+} -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 c9a0011..2ea5244 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 -@@ -176,6 +176,11 @@ object NetworkModule { - fun providesGoalApiService(retrofit: Retrofit): GoalApiService = - retrofit.create(GoalApiService::class.java) - -+ @Provides -+ @Singleton -+ fun providesMcpApiService(retrofit: Retrofit): com.awan.app.core.network.api.McpApiService = -+ retrofit.create(com.awan.app.core.network.api.McpApiService::class.java) -+ - @Provides - @Singleton - fun providesDeviceIdProvider( -diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreateMcpTokenRequestDto.kt b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreateMcpTokenRequestDto.kt -new file mode 100644 -index 0000000..cace98e ---- /dev/null -+++ b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreateMcpTokenRequestDto.kt -@@ -0,0 +1,9 @@ -+package com.awan.app.core.network.dto.mcp -+ -+import kotlinx.serialization.SerialName -+import kotlinx.serialization.Serializable -+ -+@Serializable -+data class CreateMcpTokenRequestDto( -+ @SerialName("name") val name: String, -+) -diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreatedMcpTokenResponseDto.kt b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreatedMcpTokenResponseDto.kt -new file mode 100644 -index 0000000..37af60f ---- /dev/null -+++ b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreatedMcpTokenResponseDto.kt -@@ -0,0 +1,13 @@ -+package com.awan.app.core.network.dto.mcp -+ -+import kotlinx.serialization.SerialName -+import kotlinx.serialization.Serializable -+ -+@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, -+) -diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpConnectionDetailsDto.kt b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpConnectionDetailsDto.kt -new file mode 100644 -index 0000000..9ce2cec ---- /dev/null -+++ b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpConnectionDetailsDto.kt -@@ -0,0 +1,10 @@ -+package com.awan.app.core.network.dto.mcp -+ -+import kotlinx.serialization.SerialName -+import kotlinx.serialization.Serializable -+ -+@Serializable -+data class McpConnectionDetailsDto( -+ @SerialName("mcpUrl") val mcpUrl: String, -+ @SerialName("clientId") val clientId: String, -+) -diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpTokenResponseDto.kt b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpTokenResponseDto.kt -new file mode 100644 -index 0000000..3ef767e ---- /dev/null -+++ b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpTokenResponseDto.kt -@@ -0,0 +1,13 @@ -+package com.awan.app.core.network.dto.mcp -+ -+import kotlinx.serialization.SerialName -+import kotlinx.serialization.Serializable -+ -+@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, -+) -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 0000000..a1493fe ---- /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/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 0000000..adecf9a ---- /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 0000000..e978685 ---- /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 0000000..8d4ae14 ---- /dev/null -+++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/navigation/McpInfoRouteScreen.kt -@@ -0,0 +1,13 @@ -+package com.awan.feature.profile.impl.navigation -+ -+import androidx.compose.runtime.Composable -+import com.awan.feature.profile.impl.ui.McpInfoScreen -+ -+@Composable -+fun McpInfoRouteScreen( -+ onBack: () -> Unit, -+) { -+ McpInfoScreen( -+ 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 0000000..0a9adbc ---- /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 cda412e..33029b9 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 3e0db73..03fa4aa 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 0000000..744ac75 ---- /dev/null -+++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsAction.kt -@@ -0,0 +1,10 @@ -+package com.awan.feature.profile.impl.presentation -+ -+sealed interface 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 0000000..cdc461a ---- /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 0000000..6816efb ---- /dev/null -+++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsState.kt -@@ -0,0 +1,16 @@ -+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 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 0000000..f4a8929 ---- /dev/null -+++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsViewModel.kt -@@ -0,0 +1,151 @@ -+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.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() -+ -+ init { -+ loadData() -+ } -+ -+ fun onAction(action: McpSettingsAction) { -+ when (action) { -+ 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() { -+ viewModelScope.launch { -+ _uiState.update { it.copy(isLoading = true, error = null) } -+ getMcpConnectionDetailsUseCase().collect { result -> -+ when (result) { -+ is Result.Success -> { -+ _uiState.update { it.copy(connectionDetails = result.data, isLoading = false) } -+ } -+ is Result.Error -> { -+ val uiError = ProfileErrorMapper.mapToUiText(result.error) -+ _uiState.update { it.copy(error = uiError, isLoading = false) } -+ } -+ Result.Loading -> { -+ _uiState.update { it.copy(isLoading = true) } -+ } -+ } -+ } -+ } -+ -+ viewModelScope.launch { -+ getMcpTokensUseCase().collect { result -> -+ when (result) { -+ is Result.Success -> { -+ _uiState.update { it.copy(tokens = result.data) } -+ } -+ is Result.Error -> { -+ val uiError = ProfileErrorMapper.mapToUiText(result.error) -+ _uiState.update { it.copy(error = uiError) } -+ } -+ 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, -+ ) -+ } -+ _events.send(McpSettingsEvent.TokenCreated(created)) -+ } -+ is Result.Error -> { -+ val uiError = ProfileErrorMapper.mapToUiText(result.error) -+ _uiState.update { it.copy(isCreating = 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(tokens = state.tokens.filterNot { it.id == id }) -+ } -+ _events.send(McpSettingsEvent.TokenDeleted) -+ } -+ is Result.Error -> { -+ val uiError = ProfileErrorMapper.mapToUiText(result.error) -+ _uiState.update { it.copy(error = uiError) } -+ _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) -+ } -+ _events.send(McpSettingsEvent.TokenRegenerated(regenerated)) -+ } -+ is Result.Error -> { -+ val uiError = ProfileErrorMapper.mapToUiText(result.error) -+ _uiState.update { it.copy(error = uiError) } -+ _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 0000000..9bfecf8 ---- /dev/null -+++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/McpInfoScreen.kt -@@ -0,0 +1,228 @@ -+package com.awan.feature.profile.impl.ui -+ -+import android.content.ClipData -+import android.content.ClipboardManager -+import android.content.Context -+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.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.LocalContext -+import androidx.compose.ui.res.stringResource -+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 -+ -+@Composable -+fun McpInfoScreen( -+ onBackClick: () -> Unit, -+ modifier: Modifier = Modifier, -+) { -+ val context = LocalContext.current -+ val copiedToastMessage = stringResource(ProfileR.string.profile_mcp_token_copied) -+ -+ val claudeSnippet = """ -+ { -+ "mcpServers": { -+ "awan": { -+ "command": "npx", -+ "args": [ -+ "-y", -+ "@awan/mcp-server", -+ "--url", "https://mcp.awan.app/v1", -+ "--token", "YOUR_API_TOKEN" -+ ] -+ } -+ } -+ } -+ """.trimIndent() -+ -+ val cursorSnippet = """ -+ { -+ "mcp": { -+ "servers": { -+ "awan": { -+ "url": "https://mcp.awan.app/v1", -+ "headers": { -+ "Authorization": "Bearer YOUR_API_TOKEN" -+ } -+ } -+ } -+ } -+ } -+ """.trimIndent() -+ -+ Scaffold( -+ topBar = { -+ Row( -+ modifier = Modifier -+ .fillMaxWidth() -+ .statusBarsPadding() -+ .padding(horizontal = 16.dp, vertical = 12.dp), -+ horizontalArrangement = Arrangement.spacedBy(16.dp), -+ 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 = 20.dp, vertical = 16.dp), -+ verticalArrangement = Arrangement.spacedBy(16.dp) -+ ) { -+ // Setup steps card -+ AwanCard( -+ modifier = Modifier.fillMaxWidth(), -+ contentPadding = PaddingValues(AwanTheme.spacing.md) -+ ) { -+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { -+ AwanText( -+ text = "Setup Instructions", -+ 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 -+ ) -+ AwanText( -+ text = stringResource(ProfileR.string.profile_mcp_info_step3), -+ style = AwanTheme.styles.bodyText -+ ) -+ } -+ } -+ -+ // Claude Desktop Guide -+ AwanCard( -+ modifier = Modifier.fillMaxWidth(), -+ contentPadding = PaddingValues(AwanTheme.spacing.md) -+ ) { -+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { -+ Row( -+ modifier = Modifier.fillMaxWidth(), -+ horizontalArrangement = Arrangement.SpaceBetween, -+ verticalAlignment = Alignment.CenterVertically -+ ) { -+ AwanText( -+ text = "Claude Desktop (claude_desktop_config.json)", -+ style = AwanTheme.styles.headingText -+ ) -+ IconButton( -+ onClick = { -+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager -+ clipboard.setPrimaryClip(ClipData.newPlainText("Claude Config", claudeSnippet)) -+ Toast.makeText(context, copiedToastMessage, Toast.LENGTH_SHORT).show() -+ }, -+ modifier = Modifier.size(28.dp) -+ ) { -+ Icon( -+ imageVector = Icons.Default.ContentCopy, -+ contentDescription = "Copy Claude Snippet", -+ tint = AwanTheme.colors.sky, -+ modifier = Modifier.size(16.dp) -+ ) -+ } -+ } -+ Box( -+ modifier = Modifier -+ .fillMaxWidth() -+ .clip(RoundedCornerShape(8.dp)) -+ .background(AwanTheme.colors.disabledSurface) -+ .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(8.dp)) -+ .padding(12.dp) -+ ) { -+ 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(8.dp)) { -+ Row( -+ modifier = Modifier.fillMaxWidth(), -+ horizontalArrangement = Arrangement.SpaceBetween, -+ verticalAlignment = Alignment.CenterVertically -+ ) { -+ AwanText( -+ text = "Cursor IDE Setup", -+ style = AwanTheme.styles.headingText -+ ) -+ IconButton( -+ onClick = { -+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager -+ clipboard.setPrimaryClip(ClipData.newPlainText("Cursor Config", cursorSnippet)) -+ Toast.makeText(context, copiedToastMessage, Toast.LENGTH_SHORT).show() -+ }, -+ modifier = Modifier.size(28.dp) -+ ) { -+ Icon( -+ imageVector = Icons.Default.ContentCopy, -+ contentDescription = "Copy Cursor Snippet", -+ tint = AwanTheme.colors.sky, -+ modifier = Modifier.size(16.dp) -+ ) -+ } -+ } -+ Box( -+ modifier = Modifier -+ .fillMaxWidth() -+ .clip(RoundedCornerShape(8.dp)) -+ .background(AwanTheme.colors.disabledSurface) -+ .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(8.dp)) -+ .padding(12.dp) -+ ) { -+ AwanText( -+ text = cursorSnippet, -+ 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 0000000..6ff1aab ---- /dev/null -+++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/McpSettingsScreen.kt -@@ -0,0 +1,459 @@ -+package com.awan.feature.profile.impl.ui -+ -+import android.content.ClipData -+import android.content.ClipboardManager -+import android.content.Context -+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.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.LocalContext -+import androidx.compose.ui.res.stringResource -+import androidx.compose.ui.text.font.FontFamily -+import androidx.compose.ui.unit.dp -+import androidx.compose.ui.window.Dialog -+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 -+ -+@Composable -+fun McpSettingsScreen( -+ uiState: McpSettingsState, -+ onAction: (McpSettingsAction) -> Unit, -+ onInfoClick: () -> Unit, -+ onBackClick: () -> Unit, -+ modifier: Modifier = Modifier, -+) { -+ val context = LocalContext.current -+ val copiedToastMessage = stringResource(ProfileR.string.profile_mcp_token_copied) -+ var showAddTokenDialog by remember { mutableStateOf(false) } -+ var newTokenName by remember { mutableStateOf("") } -+ var deletingToken by remember { mutableStateOf(null) } -+ var regeneratingToken by remember { mutableStateOf(null) } -+ -+ if (uiState.createdToken != null) { -+ CreatedTokenModal( -+ createdToken = uiState.createdToken, -+ onDismiss = { onAction(McpSettingsAction.DismissCreatedModal) } -+ ) -+ } -+ -+ if (showAddTokenDialog) { -+ Dialog(onDismissRequest = { showAddTokenDialog = false }) { -+ AwanCard( -+ modifier = Modifier -+ .fillMaxWidth() -+ .padding(AwanTheme.spacing.md), -+ contentPadding = PaddingValues(AwanTheme.spacing.xl) -+ ) { -+ Column( -+ verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.md) -+ ) { -+ AwanText( -+ text = stringResource(ProfileR.string.profile_mcp_add_token), -+ style = AwanTheme.styles.titleText -+ ) -+ AwanTextField( -+ value = newTokenName, -+ onValueChange = { newTokenName = 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 = { -+ showAddTokenDialog = false -+ newTokenName = "" -+ }, -+ modifier = Modifier.weight(1f), -+ variant = AwanButtonVariant.Quiet -+ ) { -+ AwanText(stringResource(ProfileR.string.profile_cancel)) -+ } -+ AwanButton( -+ onClick = { -+ if (newTokenName.isNotBlank()) { -+ onAction(McpSettingsAction.CreateToken(newTokenName)) -+ showAddTokenDialog = false -+ newTokenName = "" -+ } -+ }, -+ modifier = Modifier.weight(1f), -+ enabled = newTokenName.isNotBlank() && !uiState.isCreating -+ ) { -+ if (uiState.isCreating) { -+ CircularProgressIndicator(modifier = Modifier.size(16.dp), color = AwanTheme.colors.surface) -+ } else { -+ AwanText(stringResource(ProfileR.string.profile_mcp_add_token)) -+ } -+ } -+ } -+ } -+ } -+ } -+ } -+ -+ if (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(deletingToken!!.id)) -+ deletingToken = null -+ }, -+ secondaryLabel = stringResource(ProfileR.string.profile_cancel), -+ onSecondary = { deletingToken = null }, -+ onDismiss = { deletingToken = null } -+ ) -+ } -+ -+ if (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(regeneratingToken!!.id)) -+ regeneratingToken = null -+ }, -+ secondaryLabel = stringResource(ProfileR.string.profile_cancel), -+ onSecondary = { regeneratingToken = null }, -+ onDismiss = { regeneratingToken = null } -+ ) -+ } -+ -+ Scaffold( -+ topBar = { -+ Row( -+ modifier = Modifier -+ .fillMaxWidth() -+ .statusBarsPadding() -+ .padding(horizontal = 16.dp, vertical = 12.dp), -+ 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 = "MCP Setup 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 = 20.dp, vertical = 16.dp), -+ verticalArrangement = Arrangement.spacedBy(16.dp) -+ ) { -+ // Connection Details Card -+ AwanCard( -+ modifier = Modifier.fillMaxWidth(), -+ contentPadding = PaddingValues(AwanTheme.spacing.md) -+ ) { -+ Column( -+ verticalArrangement = Arrangement.spacedBy(12.dp) -+ ) { -+ 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" -+ val clientId = details?.clientId ?: "awan-android-client" -+ -+ // MCP URL Row -+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { -+ AwanText( -+ text = stringResource(ProfileR.string.profile_mcp_url_label), -+ style = AwanTheme.styles.captionText -+ ) -+ Row( -+ modifier = Modifier -+ .fillMaxWidth() -+ .clip(RoundedCornerShape(8.dp)) -+ .background(AwanTheme.colors.disabledSurface) -+ .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(8.dp)) -+ .padding(horizontal = 12.dp, vertical = 8.dp), -+ 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 = { -+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager -+ clipboard.setPrimaryClip(ClipData.newPlainText("MCP URL", mcpUrl)) -+ Toast.makeText(context, copiedToastMessage, Toast.LENGTH_SHORT).show() -+ }, -+ modifier = Modifier.size(28.dp) -+ ) { -+ Icon( -+ imageVector = Icons.Default.ContentCopy, -+ contentDescription = "Copy URL", -+ tint = AwanTheme.colors.textSecondary, -+ modifier = Modifier.size(16.dp) -+ ) -+ } -+ } -+ } -+ -+ // Client ID Row -+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { -+ AwanText( -+ text = stringResource(ProfileR.string.profile_mcp_client_id_label), -+ style = AwanTheme.styles.captionText -+ ) -+ Row( -+ modifier = Modifier -+ .fillMaxWidth() -+ .clip(RoundedCornerShape(8.dp)) -+ .background(AwanTheme.colors.disabledSurface) -+ .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(8.dp)) -+ .padding(horizontal = 12.dp, vertical = 8.dp), -+ horizontalArrangement = Arrangement.SpaceBetween, -+ verticalAlignment = Alignment.CenterVertically -+ ) { -+ AwanText( -+ text = clientId, -+ style = AwanTheme.styles.bodyText.let { it.copy(textStyle = it.textStyle.copy(fontFamily = FontFamily.Monospace)) }, -+ modifier = Modifier.weight(1f) -+ ) -+ IconButton( -+ onClick = { -+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager -+ clipboard.setPrimaryClip(ClipData.newPlainText("Client ID", clientId)) -+ Toast.makeText(context, copiedToastMessage, Toast.LENGTH_SHORT).show() -+ }, -+ modifier = Modifier.size(28.dp) -+ ) { -+ Icon( -+ imageVector = Icons.Default.ContentCopy, -+ contentDescription = "Copy Client ID", -+ tint = AwanTheme.colors.textSecondary, -+ modifier = Modifier.size(16.dp) -+ ) -+ } -+ } -+ } -+ } -+ } -+ -+ // Tokens Card -+ AwanCard( -+ modifier = Modifier.fillMaxWidth(), -+ contentPadding = PaddingValues(AwanTheme.spacing.md) -+ ) { -+ Column( -+ verticalArrangement = Arrangement.spacedBy(12.dp) -+ ) { -+ 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 = { showAddTokenDialog = true }, -+ variant = AwanButtonVariant.Quiet -+ ) { -+ Row( -+ horizontalArrangement = Arrangement.spacedBy(4.dp), -+ verticalAlignment = Alignment.CenterVertically -+ ) { -+ Icon( -+ imageVector = Icons.Default.Add, -+ contentDescription = null, -+ modifier = Modifier.size(16.dp) -+ ) -+ AwanText(stringResource(ProfileR.string.profile_mcp_add_token)) -+ } -+ } -+ } -+ -+ if (uiState.tokens.isEmpty()) { -+ Box( -+ modifier = Modifier -+ .fillMaxWidth() -+ .padding(vertical = 16.dp), -+ contentAlignment = Alignment.Center -+ ) { -+ AwanText( -+ text = "No tokens added yet", -+ style = AwanTheme.styles.bodySecondaryText -+ ) -+ } -+ } else { -+ uiState.tokens.forEach { token -> -+ TokenItemRow( -+ token = token, -+ onRegenerate = { regeneratingToken = token }, -+ onDelete = { deletingToken = token } -+ ) -+ } -+ } -+ -+ // Security notice -+ AwanText( -+ text = stringResource(ProfileR.string.profile_mcp_token_obscured_notice) + ". " + -+ stringResource(ProfileR.string.profile_mcp_token_copy_disabled), -+ style = AwanTheme.styles.captionText, -+ modifier = Modifier.padding(top = 4.dp) -+ ) -+ } -+ } -+ } -+ -+ if (uiState.error != null) { -+ Box( -+ modifier = Modifier -+ .fillMaxSize() -+ .padding(20.dp), -+ 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(12.dp)) -+ .background(AwanTheme.colors.disabledSurface) -+ .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(12.dp)) -+ .padding(12.dp) -+ ) { -+ Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { -+ Row( -+ modifier = Modifier.fillMaxWidth(), -+ horizontalArrangement = Arrangement.SpaceBetween, -+ verticalAlignment = Alignment.CenterVertically -+ ) { -+ AwanText( -+ text = token.name, -+ style = AwanTheme.styles.bodyText -+ ) -+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { -+ IconButton( -+ onClick = onRegenerate, -+ modifier = Modifier.size(32.dp) -+ ) { -+ Icon( -+ imageVector = Icons.Default.Refresh, -+ contentDescription = "Regenerate Token", -+ tint = AwanTheme.colors.sky, -+ modifier = Modifier.size(18.dp) -+ ) -+ } -+ IconButton( -+ onClick = onDelete, -+ modifier = Modifier.size(32.dp) -+ ) { -+ Icon( -+ imageVector = Icons.Default.Delete, -+ contentDescription = "Delete Token", -+ 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 = 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 0000000..bff02c0 ---- /dev/null -+++ b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/ui/components/CreatedTokenModal.kt -@@ -0,0 +1,149 @@ -+package com.awan.feature.profile.impl.ui.components -+ -+import android.content.ClipData -+import android.content.ClipboardManager -+import android.content.Context -+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.LocalContext -+import androidx.compose.ui.res.stringResource -+import androidx.compose.ui.text.font.FontFamily -+import androidx.compose.ui.unit.dp -+import androidx.compose.ui.window.Dialog -+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.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 -+ -+@Composable -+fun CreatedTokenModal( -+ createdToken: CreatedMcpToken, -+ onDismiss: () -> Unit, -+ modifier: Modifier = Modifier, -+) { -+ val context = LocalContext.current -+ val copiedToastMessage = stringResource(ProfileR.string.profile_mcp_token_copied) -+ var copied by remember { mutableStateOf(false) } -+ -+ Dialog(onDismissRequest = onDismiss) { -+ AwanCard( -+ modifier = modifier -+ .fillMaxWidth() -+ .padding(AwanTheme.spacing.md), -+ contentPadding = PaddingValues(AwanTheme.spacing.xl) -+ ) { -+ Column( -+ 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(12.dp)) -+ .background(AwanTheme.colors.destructive.copy(alpha = 0.1f)) -+ .border(1.dp, AwanTheme.colors.destructive, RoundedCornerShape(12.dp)) -+ .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(20.dp) -+ ) -+ 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(12.dp)) -+ .background(AwanTheme.colors.disabledSurface) -+ .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(12.dp)) -+ .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 = { -+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager -+ val clip = ClipData.newPlainText("MCP Token", createdToken.rawToken) -+ clipboard.setPrimaryClip(clip) -+ 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(16.dp) -+ ) -+ 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 a37d896..ae33358 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 8a385cb..7527c67 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,28 @@ - ╪¼ - ╪│ - ╪¡ -+ -+ -+ ╪¬┘â╪º┘à┘ä MCP -+ ╪▒╪¿╪╖ ╪º┘ä┘à╪│╪º╪╣╪»┘è┘å ╪º┘ä╪░┘â┘è┘è┘å (Claude, Cursor) ╪╣╪¿╪▒ ╪¿╪▒┘ê╪¬┘ê┘â┘ê┘ä MCP -+ ╪¬┘ü╪º╪╡┘è┘ä ╪º┘ä╪º╪¬╪╡╪º┘ä -+ ╪▒╪º╪¿╪╖ ╪«╪º╪»┘à MCP -+ ┘à╪╣╪▒┘ü ╪º┘ä╪╣┘à┘è┘ä (Client ID) -+ ╪▒┘à┘ê╪▓ ╪º┘ä┘ê╪╡┘ê┘ä (Tokens) -+ ╪Ñ╪╢╪º┘ü╪⌐ ╪▒┘à╪▓ ╪¼╪»┘è╪» -+ ╪º╪│┘à ╪º┘ä╪▒┘à╪▓ (┘à╪½╪º┘ä: Claude Desktop) -+ ╪¬┘à ╪Ñ╪«┘ü╪º╪í ╪º┘ä┘à┘ü╪¬╪º╪¡ ┘ä╪ú╪│╪¿╪º╪¿ ╪ú┘à┘å┘è╪⌐ -+ ┘è┘à┘â┘å ┘å╪│╪« ╪º┘ä╪▒┘à╪▓ ┘ü┘é╪╖ ╪╣┘å╪» ╪Ñ┘å╪┤╪º╪ª┘ç ┘ä╪ú┘ê┘ä ┘à╪▒╪⌐ -+ ╪¬┘à ╪Ñ┘å╪┤╪º╪í ╪º┘ä╪▒┘à╪▓ ╪¿┘å╪¼╪º╪¡! -+ ╪º╪¡╪▒╪╡ ╪╣┘ä┘ë ┘å╪│╪« ╪▒┘à╪▓ ╪º┘ä┘ê╪╡┘ê┘ä ╪º┘ä╪«╪º╪╡ ╪¿┘â ╪º┘ä╪ó┘å. ┘ä┘å ╪¬╪¬┘à┘â┘å ┘à┘å ╪▒╪ñ┘è╪¬┘ç ┘à╪▒╪⌐ ╪ú╪«╪▒┘ë! -+ ┘å╪│╪« ╪º┘ä╪▒┘à╪▓ -+ ╪¬┘à ┘å╪│╪« ╪º┘ä╪▒┘à╪▓ ╪Ñ┘ä┘ë ╪º┘ä╪¡╪º┘ü╪╕╪⌐ -+ ╪¡╪░┘ü ╪º┘ä╪▒┘à╪▓╪ƒ -+ ┘ç┘ä ╪ú┘å╪¬ ╪¬╪ú┘â╪» ┘à┘å ╪¡╪░┘ü ╪▒┘à╪▓ MCP ┘ç╪░╪º╪ƒ ╪│┘è┘ü┘é╪» ╪º┘ä┘à╪│╪º╪╣╪» ╪º┘ä╪░┘â┘è ╪º┘ä┘ê╪╡┘ê┘ä ┘ü┘ê╪▒╪º┘ï. -+ ╪Ñ╪╣╪º╪»╪⌐ ╪Ñ┘å╪┤╪º╪í ╪º┘ä╪▒┘à╪▓╪ƒ -+ ╪Ñ╪╣╪º╪»╪⌐ ╪Ñ┘å╪┤╪º╪í ╪º┘ä╪▒┘à╪▓ ╪│╪¬┘ä╪║┘è ╪º┘ä┘à┘ü╪¬╪º╪¡ ╪º┘ä╪¡╪º┘ä┘è. ╪│╪¬╪¡╪¬╪º╪¼ ╪Ñ┘ä┘ë ╪¬╪¡╪»┘è╪½┘ç ┘ü┘è ╪º┘ä┘à╪│╪º╪╣╪» ╪º┘ä╪░┘â┘è. -+ ┘â┘è┘ü┘è╪⌐ ╪▒╪¿╪╖ ┘à╪│╪º╪╣╪»┘â ╪º┘ä╪░┘â┘è -+ 1. ╪º┘å╪│╪« ╪▒╪º╪¿╪╖ ╪«╪º╪»┘à MCP ┘ê┘à╪╣╪▒┘ü ╪º┘ä╪╣┘à┘è┘ä ╪ú╪╣┘ä╪º┘ç. -+ 2. ╪ú┘å╪┤╪ª ╪▒┘à╪▓ ┘ê╪╡┘ê┘ä ┘ê╪º╪¡┘ü╪╕ ╪º┘ä┘à┘ü╪¬╪º╪¡ ┘ü┘ê╪▒╪º┘ï. -+ 3. ┘é┘à ╪¿╪¬╪╢┘à┘è┘å ╪º┘ä╪Ñ╪╣╪»╪º╪»╪º╪¬ ┘ü┘è ┘à┘ä┘ü ╪º┘ä╪¬┘â┘ê┘è┘å (┘à╪½┘ä claude_desktop_config.json). - -diff --git a/feature/profile/impl/src/main/res/values/strings.xml b/feature/profile/impl/src/main/res/values/strings.xml -index 6f43778..9770cd0 100644 ---- a/feature/profile/impl/src/main/res/values/strings.xml -+++ b/feature/profile/impl/src/main/res/values/strings.xml -@@ -184,4 +184,28 @@ - Fri - Sat - Sun -+ -+ -+ 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). - -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 0000000..261d7b6 ---- /dev/null -+++ b/feature/profile/impl/src/test/java/com/awan/feature/profile/impl/presentation/McpSettingsViewModelTest.kt -@@ -0,0 +1,163 @@ -+package com.awan.feature.profile.impl.presentation -+ -+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 `CreateToken action creates token, sets createdToken, and emits TokenCreated event`() = runTest(testDispatcher) { -+ val events = mutableListOf() -+ val job = launch { viewModel.events.toList(events) } -+ -+ 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) -+ assertEquals(1, events.size) -+ assert(events.first() is McpSettingsEvent.TokenCreated) -+ -+ job.cancel() -+ } -+ -+ @Test -+ fun `DeleteToken action removes token from state and emits TokenDeleted event`() = runTest(testDispatcher) { -+ val events = mutableListOf() -+ val job = launch { viewModel.events.toList(events) } -+ -+ viewModel.onAction(McpSettingsAction.DeleteToken("token-1")) -+ -+ val state = viewModel.uiState.value -+ assertEquals(0, state.tokens.size) -+ assertEquals(1, events.size) -+ assert(events.first() is McpSettingsEvent.TokenDeleted) -+ -+ job.cancel() -+ } -+ -+ @Test -+ fun `RegenerateToken action sets new createdToken and emits TokenRegenerated event`() = runTest(testDispatcher) { -+ val events = mutableListOf() -+ val job = launch { viewModel.events.toList(events) } -+ -+ viewModel.onAction(McpSettingsAction.RegenerateToken("token-1")) -+ -+ val state = viewModel.uiState.value -+ assertNotNull(state.createdToken) -+ assertEquals("raw_regenerated_token-1", state.createdToken?.rawToken) -+ 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 { -+ 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 { -+ 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/nav_routes.txt b/nav_routes.txt deleted file mode 100644 index e69de29b..00000000 From 930b4f5b08d9f993e6d2ff26743b142205ffda88 Mon Sep 17 00:00:00 2001 From: "ZEIAD-LAPTOP\\zeiad" Date: Tue, 11 Aug 2026 08:16:03 +0300 Subject: [PATCH 05/13] AWAN-210: Align MCP endpoints with Postman Awan API Keys endpoints (v1/api-keys) --- core/data/build.gradle.kts | 1 + .../app/core/data/mcp/mapper/McpMappers.kt | 24 ++- .../data/mcp/repository/McpRepositoryImpl.kt | 68 +++++---- .../core/data/mcp/McpRepositoryImplTest.kt | 115 +++++++------- core/network/build.gradle.kts | 2 +- .../app/core/network/api/McpApiService.kt | 27 ++-- .../core/network/dto/mcp/ApiKeyResponseDto.kt | 12 ++ .../core/network/dto/mcp/ApiKeySummaryDto.kt | 12 ++ .../network/dto/mcp/CreateApiKeyRequestDto.kt | 9 ++ .../2026-08-11-postman-api-migration-plan.md | 142 ++++++++++++++++++ 10 files changed, 292 insertions(+), 120 deletions(-) create mode 100644 core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/ApiKeyResponseDto.kt create mode 100644 core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/ApiKeySummaryDto.kt create mode 100644 core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreateApiKeyRequestDto.kt create mode 100644 docs/feature/mcp/2026-08-11-postman-api-migration-plan.md 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/mapper/McpMappers.kt b/core/data/src/main/kotlin/com/awan/app/core/data/mcp/mapper/McpMappers.kt index 95546db7..99258558 100644 --- 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 @@ -1,22 +1,31 @@ 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.CreatedMcpTokenResponseDto -import com.awan.app.core.network.dto.mcp.McpTokenResponseDto +import com.awan.app.core.network.dto.mcp.ApiKeyResponseDto +import com.awan.app.core.network.dto.mcp.ApiKeySummaryDto -fun McpTokenResponseDto.toEntity(): McpTokenEntity = McpTokenEntity( +fun ApiKeySummaryDto.toEntity(): McpTokenEntity = McpTokenEntity( id = id, name = name, - maskedToken = maskedToken, + maskedToken = keyPrefix, createdAt = createdAt, - lastUsedAt = lastUsedAt, + lastUsedAt = null, ) -fun CreatedMcpTokenResponseDto.toEntity(): McpTokenEntity = McpTokenEntity( +fun ApiKeyResponseDto.toDomain(): CreatedMcpToken = CreatedMcpToken( id = id, name = name, - maskedToken = maskedToken, + 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, ) @@ -28,3 +37,4 @@ fun McpTokenEntity.toDomain(): McpToken = McpToken( 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 index 1a6ff400..0cab18c8 100644 --- a/core/data/src/main/kotlin/com/awan/app/core/data/mcp/repository/McpRepositoryImpl.kt +++ b/core/data/src/main/kotlin/com/awan/app/core/data/mcp/repository/McpRepositoryImpl.kt @@ -13,15 +13,17 @@ 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.api.McpApiService -import com.awan.app.core.network.dto.mcp.CreateMcpTokenRequestDto +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 @@ -34,26 +36,25 @@ class McpRepositoryImpl @Inject constructor( ) : McpRepository { override fun getMcpConnectionDetails(): Flow> = flow { - if (!connectivityMonitor.isCurrentlyOnline()) { - emit(Result.Error(AppError.Network)) - return@flow - } - val result = safeApiCall(ioDispatcher) { - val dto = mcpApiService.getConnectionDetails() - McpConnectionDetails( - mcpUrl = dto.mcpUrl, - clientId = dto.clientId, + emit( + Result.Success( + McpConnectionDetails( + mcpUrl = "https://backend-production-c701.up.railway.app/api/v1/mcp", + clientId = "awan-android-client", + ) ) - } - emit(result) + ) }.flowOn(ioDispatcher) override fun getMcpTokens(): Flow>> = flow { if (connectivityMonitor.isCurrentlyOnline()) { try { - val dtos = mcpApiService.getTokens() - val entities = dtos.map { it.toEntity() } - mcpTokenDao.replaceMcpTokens(entities) + 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 @@ -71,14 +72,10 @@ class McpRepositoryImpl @Inject constructor( return Result.Error(AppError.Network) } return safeApiCall(ioDispatcher) { - val dto = mcpApiService.createToken(CreateMcpTokenRequestDto(name = name)) - val createdToken = CreatedMcpToken( - id = dto.id, - name = dto.name, - rawToken = dto.rawToken, - maskedToken = dto.maskedToken, - createdAt = dto.createdAt, - ) + 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) { @@ -94,7 +91,8 @@ class McpRepositoryImpl @Inject constructor( return Result.Error(AppError.Network) } val result = safeApiCall(ioDispatcher) { - mcpApiService.deleteToken(id) + val response = mcpApiService.revokeApiKey(id) + if (!response.isSuccessful) throw HttpException(response) } if (result is Result.Success) { try { @@ -110,16 +108,21 @@ class McpRepositoryImpl @Inject constructor( if (!connectivityMonitor.isCurrentlyOnline()) { return Result.Error(AppError.Network) } + val existingTokenName = mcpTokenDao.getMcpTokens().first().find { it.id == id }?.name ?: "MCP Token" return safeApiCall(ioDispatcher) { - val dto = mcpApiService.regenerateToken(id) - val createdToken = CreatedMcpToken( - id = dto.id, - name = dto.name, - rawToken = dto.rawToken, - maskedToken = dto.maskedToken, - createdAt = dto.createdAt, - ) + 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() + try { + mcpApiService.revokeApiKey(id) + } catch (e: Exception) { + if (e is CancellationException) throw e + } + + try { + mcpTokenDao.deleteMcpToken(id) mcpTokenDao.upsertMcpTokens(listOf(dto.toEntity())) } catch (e: Exception) { if (e is CancellationException) throw e @@ -129,3 +132,4 @@ class McpRepositoryImpl @Inject constructor( } } } + diff --git a/core/data/src/test/java/com/awan/app/core/data/mcp/McpRepositoryImplTest.kt b/core/data/src/test/java/com/awan/app/core/data/mcp/McpRepositoryImplTest.kt index 828a5557..7cc32f55 100644 --- a/core/data/src/test/java/com/awan/app/core/data/mcp/McpRepositoryImplTest.kt +++ b/core/data/src/test/java/com/awan/app/core/data/mcp/McpRepositoryImplTest.kt @@ -7,10 +7,9 @@ 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.CreateMcpTokenRequestDto -import com.awan.app.core.network.dto.mcp.CreatedMcpTokenResponseDto -import com.awan.app.core.network.dto.mcp.McpConnectionDetailsDto -import com.awan.app.core.network.dto.mcp.McpTokenResponseDto +import com.awan.app.core.network.dto.mcp.ApiKeyResponseDto +import com.awan.app.core.network.dto.mcp.ApiKeySummaryDto +import com.awan.app.core.network.dto.mcp.CreateApiKeyRequestDto import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -18,53 +17,49 @@ 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 connectionDetailsDto = McpConnectionDetailsDto( - mcpUrl = "https://mcp.awan.com", - clientId = "client-123", - ) - var tokensList = mutableListOf() - var createdTokenResponse = CreatedMcpTokenResponseDto( + var apiKeysList = mutableListOf() + var createApiKeyResponse = ApiKeyResponseDto( id = "token-1", name = "Default Token", - rawToken = "raw-secret-123", - maskedToken = "mcp_...123", + keyValue = "raw-secret-123", createdAt = "2026-08-11T00:00:00Z", ) var shouldFailWithException: Exception? = null var lastCreatedName: String? = null - var lastDeletedId: String? = null - var lastRegeneratedId: String? = null - - override suspend fun getConnectionDetails(): McpConnectionDetailsDto { - shouldFailWithException?.let { throw it } - return connectionDetailsDto - } + var lastRevokedId: String? = null + var httpErrorCode: Int? = null - override suspend fun getTokens(): List { + override suspend fun getApiKeys(): Response> { shouldFailWithException?.let { throw it } - return tokensList + httpErrorCode?.let { + return Response.error(it, "Error".toResponseBody(null)) + } + return Response.success(apiKeysList) } - override suspend fun createToken(request: CreateMcpTokenRequestDto): CreatedMcpTokenResponseDto { + override suspend fun createApiKey(request: CreateApiKeyRequestDto): Response { shouldFailWithException?.let { throw it } + httpErrorCode?.let { + return Response.error(it, "Error".toResponseBody(null)) + } lastCreatedName = request.name - return createdTokenResponse.copy(name = request.name) + return Response.success(createApiKeyResponse.copy(name = request.name)) } - override suspend fun deleteToken(id: String) { + override suspend fun revokeApiKey(keyId: String): Response { shouldFailWithException?.let { throw it } - lastDeletedId = id - } - - override suspend fun regenerateToken(id: String): CreatedMcpTokenResponseDto { - shouldFailWithException?.let { throw it } - lastRegeneratedId = id - return createdTokenResponse + httpErrorCode?.let { + return Response.error(it, "Error".toResponseBody(null)) + } + lastRevokedId = keyId + return Response.success(Unit) } } @@ -130,36 +125,25 @@ class McpRepositoryImplTest { ) @Test - fun `getMcpConnectionDetails returns success when online`() = runTest(testDispatcher) { - val apiService = FakeMcpApiService() - val repository = buildRepository(apiService = apiService, monitor = onlineMonitor) + 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("https://mcp.awan.com", details.mcpUrl) - assertEquals("client-123", details.clientId) + assertEquals("https://backend-production-c701.up.railway.app/api/v1/mcp", details.mcpUrl) + assertEquals("awan-android-client", details.clientId) } @Test - fun `getMcpConnectionDetails returns network error when offline`() = runTest(testDispatcher) { - val repository = buildRepository(monitor = offlineMonitor) - - val result = repository.getMcpConnectionDetails().first() - - assertTrue(result is Result.Error) - assertTrue((result as Result.Error).error is AppError.Network) - } - - @Test - fun `getMcpTokens fetches remote and updates Room atomically when online`() = runTest(testDispatcher) { + fun `getMcpTokens fetches remote api keys and updates Room atomically when online`() = runTest(testDispatcher) { val apiService = FakeMcpApiService().apply { - tokensList.add( - McpTokenResponseDto( - id = "token-1", + apiKeysList.add( + ApiKeySummaryDto( + id = "key-1", name = "Claude Desktop", - maskedToken = "mcp_...123", + keyPrefix = "mcp_...123", createdAt = "2026-08-11T00:00:00Z", ) ) @@ -175,6 +159,7 @@ class McpRepositoryImplTest { 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) } @@ -244,7 +229,7 @@ class McpRepositoryImplTest { } @Test - fun `deleteMcpToken deletes token from network and Room`() = runTest(testDispatcher) { + fun `deleteMcpToken revokes token on network and deletes from Room`() = runTest(testDispatcher) { val apiService = FakeMcpApiService() val tokenDao = FakeMcpTokenDao().apply { upsertMcpTokens( @@ -258,7 +243,7 @@ class McpRepositoryImplTest { val result = repository.deleteMcpToken("token-1") assertTrue(result is Result.Success) - assertEquals("token-1", apiService.lastDeletedId) + assertEquals("token-1", apiService.lastRevokedId) assertTrue(tokenDao.storedTokens.none { it.id == "token-1" }) } @@ -273,13 +258,12 @@ class McpRepositoryImplTest { } @Test - fun `regenerateMcpToken updates token in network and Room`() = runTest(testDispatcher) { + fun `regenerateMcpToken creates new token, revokes old token on network, and updates Room`() = runTest(testDispatcher) { val apiService = FakeMcpApiService().apply { - createdTokenResponse = CreatedMcpTokenResponseDto( - id = "token-1", + createApiKeyResponse = ApiKeyResponseDto( + id = "token-2", name = "Claude Desktop", - rawToken = "new-raw-secret", - maskedToken = "mcp_...new", + keyValue = "new-raw-secret", createdAt = "2026-08-11T01:00:00Z", ) } @@ -297,18 +281,20 @@ class McpRepositoryImplTest { assertTrue(result is Result.Success) val createdToken = (result as Result.Success).data assertEquals("new-raw-secret", createdToken.rawToken) - assertEquals("token-1", apiService.lastRegeneratedId) - assertEquals("mcp_...new", tokenDao.storedTokens.first().maskedToken) + 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 preserves raw token success even if Room write fails`() = runTest(testDispatcher) { val apiService = FakeMcpApiService().apply { - createdTokenResponse = CreatedMcpTokenResponseDto( - id = "token-1", + createApiKeyResponse = ApiKeyResponseDto( + id = "token-2", name = "Claude Desktop", - rawToken = "new-raw-secret", - maskedToken = "mcp_...new", + keyValue = "new-raw-secret", createdAt = "2026-08-11T01:00:00Z", ) } @@ -322,3 +308,4 @@ class McpRepositoryImplTest { assertEquals("new-raw-secret", createdToken.rawToken) } } + 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 index 8034e347..eecef632 100644 --- a/core/network/src/main/kotlin/com/awan/app/core/network/api/McpApiService.kt +++ b/core/network/src/main/kotlin/com/awan/app/core/network/api/McpApiService.kt @@ -1,9 +1,9 @@ package com.awan.app.core.network.api -import com.awan.app.core.network.dto.mcp.CreateMcpTokenRequestDto -import com.awan.app.core.network.dto.mcp.CreatedMcpTokenResponseDto -import com.awan.app.core.network.dto.mcp.McpConnectionDetailsDto -import com.awan.app.core.network.dto.mcp.McpTokenResponseDto +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 @@ -11,18 +11,13 @@ import retrofit2.http.POST import retrofit2.http.Path interface McpApiService { - @GET("v1/mcp/settings/connection-details") - suspend fun getConnectionDetails(): McpConnectionDetailsDto + @GET("v1/api-keys") + suspend fun getApiKeys(): Response> - @GET("v1/mcp/tokens") - suspend fun getTokens(): List + @POST("v1/api-keys") + suspend fun createApiKey(@Body request: CreateApiKeyRequestDto): Response - @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 + @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/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-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. From 10f2c238c81fdc2021b1d694726de1d0abfc9cf3 Mon Sep 17 00:00:00 2001 From: "ZEIAD-LAPTOP\\zeiad" Date: Tue, 11 Aug 2026 08:28:50 +0300 Subject: [PATCH 06/13] AWAN-210: Prevent Dialog Constraints IllegalArgumentException in AwanButton/AwanCard and optimize dialog content paddings --- .../awan/app/core/designsystem/AwanButton.kt | 9 ++- .../awan/app/core/designsystem/AwanCard.kt | 10 ++- .../2026-08-11-mcp-dialog-crash-fix-plan.md | 78 +++++++++++++++++++ .../profile/impl/ui/McpSettingsScreen.kt | 4 +- .../impl/ui/components/CreatedTokenModal.kt | 4 +- 5 files changed, 93 insertions(+), 12 deletions(-) create mode 100644 docs/feature/mcp/2026-08-11-mcp-dialog-crash-fix-plan.md 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/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/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 index 09c044f3..571f568d 100644 --- 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 @@ -76,8 +76,8 @@ fun McpSettingsScreen( AwanCard( modifier = Modifier .fillMaxWidth() - .padding(AwanTheme.spacing.md), - contentPadding = PaddingValues(AwanTheme.spacing.xl) + .padding(AwanTheme.spacing.xs), + contentPadding = PaddingValues(AwanTheme.spacing.md) ) { Column( verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.md) 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 index 5be944a6..57e0c504 100644 --- 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 @@ -54,8 +54,8 @@ fun CreatedTokenModal( AwanCard( modifier = modifier .fillMaxWidth() - .padding(AwanTheme.spacing.md), - contentPadding = PaddingValues(AwanTheme.spacing.xl) + .padding(AwanTheme.spacing.xs), + contentPadding = PaddingValues(AwanTheme.spacing.md) ) { Column( horizontalAlignment = Alignment.CenterHorizontally, From c41e224df75901700914235545d96c2389975b59 Mon Sep 17 00:00:00 2001 From: "ZEIAD-LAPTOP\\zeiad" Date: Tue, 11 Aug 2026 08:31:35 +0300 Subject: [PATCH 07/13] AWAN-210: Wrap JSON snippets in SelectionContainer and update backend server URL in McpInfoScreen --- .../2026-08-11-mcp-info-copyable-json-plan.md | 54 +++++++++++++++++++ .../feature/profile/impl/ui/McpInfoScreen.kt | 25 +++++---- 2 files changed, 69 insertions(+), 10 deletions(-) create mode 100644 docs/feature/mcp/2026-08-11-mcp-info-copyable-json-plan.md 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/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 index e9d0b795..2ce67c7b 100644 --- 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 @@ -16,6 +16,7 @@ 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 @@ -54,7 +55,7 @@ fun McpInfoScreen( "args": [ "-y", "@awan/mcp-server", - "--url", "https://mcp.awan.app/v1", + "--url", "https://backend-production-c701.up.railway.app/api/v1/mcp", "--token", "YOUR_API_TOKEN" ] } @@ -67,7 +68,7 @@ fun McpInfoScreen( "mcp": { "servers": { "awan": { - "url": "https://mcp.awan.app/v1", + "url": "https://backend-production-c701.up.railway.app/api/v1/mcp", "headers": { "Authorization": "Bearer YOUR_API_TOKEN" } @@ -168,10 +169,12 @@ fun McpInfoScreen( .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(AwanTheme.spacing.xs)) .padding(AwanTheme.spacing.sm) ) { - AwanText( - text = claudeSnippet, - style = AwanTheme.styles.bodyText.let { it.copy(textStyle = it.textStyle.copy(fontFamily = FontFamily.Monospace)) } - ) + SelectionContainer { + AwanText( + text = claudeSnippet, + style = AwanTheme.styles.bodyText.let { it.copy(textStyle = it.textStyle.copy(fontFamily = FontFamily.Monospace)) } + ) + } } } } @@ -214,10 +217,12 @@ fun McpInfoScreen( .border(1.dp, AwanTheme.colors.line, RoundedCornerShape(AwanTheme.spacing.xs)) .padding(AwanTheme.spacing.sm) ) { - AwanText( - text = cursorSnippet, - style = AwanTheme.styles.bodyText.let { it.copy(textStyle = it.textStyle.copy(fontFamily = FontFamily.Monospace)) } - ) + SelectionContainer { + AwanText( + text = cursorSnippet, + style = AwanTheme.styles.bodyText.let { it.copy(textStyle = it.textStyle.copy(fontFamily = FontFamily.Monospace)) } + ) + } } } } From a82ed11beca70749d7664b1e773fb37526fa8b57 Mon Sep 17 00:00:00 2001 From: "ZEIAD-LAPTOP\\zeiad" Date: Tue, 11 Aug 2026 08:37:02 +0300 Subject: [PATCH 08/13] AWAN-210: Add Connection Details card with copy buttons for Server URL and Client ID on McpInfoScreen --- ...-08-11-mcp-info-connection-details-plan.md | 124 ++++++++++++++++++ .../feature/profile/impl/ui/McpInfoScreen.kt | 96 ++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 docs/feature/mcp/2026-08-11-mcp-info-connection-details-plan.md 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/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 index 2ce67c7b..70dfae5a 100644 --- 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 @@ -106,6 +106,102 @@ fun McpInfoScreen( .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 mcpUrl = "https://backend-production-c701.up.railway.app/api/v1/mcp" + val clientId = "awan-android-client" + + // MCP Server 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 + ) { + 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) + ) + } + } + } + + // OAuth Client ID Row + 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) + ) + } + } + } + } + } + // Setup steps card AwanCard( modifier = Modifier.fillMaxWidth(), From 0196d2e2b0af2569f1fd9296366421636f73ee7f Mon Sep 17 00:00:00 2001 From: "ZEIAD-LAPTOP\\zeiad" Date: Tue, 11 Aug 2026 08:41:18 +0300 Subject: [PATCH 09/13] AWAN-210: Convert token creation and raw token reveal dialogs into ModalBottomSheet containers --- .../mcp/2026-08-11-mcp-bottom-sheets-plan.md | 44 ++++++++++ .../profile/impl/ui/McpSettingsScreen.kt | 85 ++++++++++--------- .../impl/ui/components/CreatedTokenModal.kt | 29 ++++--- 3 files changed, 106 insertions(+), 52 deletions(-) create mode 100644 docs/feature/mcp/2026-08-11-mcp-bottom-sheets-plan.md 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/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 index 571f568d..ee1408a1 100644 --- 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 @@ -36,7 +36,9 @@ 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.ui.window.Dialog +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 @@ -52,6 +54,7 @@ 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, @@ -72,51 +75,53 @@ fun McpSettingsScreen( } if (uiState.showAddTokenDialog) { - Dialog(onDismissRequest = { onAction(McpSettingsAction.HideAddTokenDialog) }) { - AwanCard( + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = { onAction(McpSettingsAction.HideAddTokenDialog) }, + sheetState = sheetState, + containerColor = AwanTheme.colors.surface + ) { + Column( modifier = Modifier .fillMaxWidth() - .padding(AwanTheme.spacing.xs), - contentPadding = PaddingValues(AwanTheme.spacing.md) + .padding(horizontal = AwanTheme.spacing.lg) + .padding(bottom = AwanTheme.spacing.xl), + verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.md) ) { - Column( - 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) ) { - 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 ) { - 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)) + 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)) } } } 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 index 57e0c504..3302902c 100644 --- 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 @@ -30,15 +30,17 @@ 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.ui.window.Dialog +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.AwanCard 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, @@ -49,18 +51,22 @@ fun CreatedTokenModal( val clipboardManager = LocalClipboardManager.current val copiedToastMessage = stringResource(ProfileR.string.profile_mcp_token_copied) var copied by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - Dialog(onDismissRequest = onDismiss) { - AwanCard( - modifier = modifier + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = AwanTheme.colors.surface, + modifier = modifier, + ) { + Column( + modifier = Modifier .fillMaxWidth() - .padding(AwanTheme.spacing.xs), - contentPadding = PaddingValues(AwanTheme.spacing.md) + .padding(horizontal = AwanTheme.spacing.lg) + .padding(bottom = AwanTheme.spacing.xl), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.md) ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.md) - ) { AwanText( text = stringResource(ProfileR.string.profile_mcp_token_created_banner_title), style = AwanTheme.styles.titleText @@ -143,5 +149,4 @@ fun CreatedTokenModal( } } } - } } From 56c2111632d83257a0652f1e543ca62a74ce2dd2 Mon Sep 17 00:00:00 2001 From: "ZEIAD-LAPTOP\\zeiad" Date: Tue, 11 Aug 2026 09:51:01 +0300 Subject: [PATCH 10/13] AWAN-210: Add AI setup prompt card, refined copy toast notifications, and date formatting unit tests --- .../feature/profile/impl/ui/McpInfoScreen.kt | 183 ++++++++---------- .../profile/impl/ui/McpSettingsScreen.kt | 21 +- .../impl/src/main/res/values-ar/strings.xml | 16 +- .../impl/src/main/res/values/strings.xml | 16 +- .../impl/ui/McpSettingsDateFormatterTest.kt | 15 ++ 5 files changed, 135 insertions(+), 116 deletions(-) create mode 100644 feature/profile/impl/src/test/java/com/awan/feature/profile/impl/ui/McpSettingsDateFormatterTest.kt 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 index 70dfae5a..bf8ba97a 100644 --- 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 @@ -38,6 +38,9 @@ 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( onBackClick: () -> Unit, @@ -45,7 +48,10 @@ fun McpInfoScreen( ) { val context = LocalContext.current val clipboardManager = LocalClipboardManager.current - val copiedToastMessage = stringResource(ProfileR.string.profile_mcp_token_copied) + val aiSetupPrompt = stringResource(ProfileR.string.profile_mcp_ai_setup_prompt) + 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 = """ { @@ -106,102 +112,6 @@ fun McpInfoScreen( .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 mcpUrl = "https://backend-production-c701.up.railway.app/api/v1/mcp" - val clientId = "awan-android-client" - - // MCP Server 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 - ) { - 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) - ) - } - } - } - - // OAuth Client ID Row - 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) - ) - } - } - } - } - } - // Setup steps card AwanCard( modifier = Modifier.fillMaxWidth(), @@ -209,7 +119,7 @@ fun McpInfoScreen( ) { Column(verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.sm)) { AwanText( - text = "Setup Instructions", + text = stringResource(ProfileR.string.profile_mcp_setup_title), style = AwanTheme.styles.headingText ) AwanText( @@ -220,10 +130,6 @@ fun McpInfoScreen( text = stringResource(ProfileR.string.profile_mcp_info_step2), style = AwanTheme.styles.bodyText ) - AwanText( - text = stringResource(ProfileR.string.profile_mcp_info_step3), - style = AwanTheme.styles.bodyText - ) } } @@ -240,12 +146,18 @@ fun McpInfoScreen( ) { AwanText( text = "Claude Desktop (claude_desktop_config.json)", - style = AwanTheme.styles.headingText + style = AwanTheme.styles.headingText, + modifier = Modifier.weight(1f), + maxLines = 2 ) IconButton( onClick = { clipboardManager.setText(AnnotatedString(claudeSnippet)) - Toast.makeText(context, copiedToastMessage, Toast.LENGTH_SHORT).show() + Toast.makeText( + context, + claudeCopiedToastMessage, + Toast.LENGTH_SHORT + ).show() }, modifier = Modifier.size(28.dp) ) { @@ -288,12 +200,18 @@ fun McpInfoScreen( ) { AwanText( text = "Cursor IDE Setup", - style = AwanTheme.styles.headingText + style = AwanTheme.styles.headingText, + modifier = Modifier.weight(1f), + maxLines = 2 ) IconButton( onClick = { clipboardManager.setText(AnnotatedString(cursorSnippet)) - Toast.makeText(context, copiedToastMessage, Toast.LENGTH_SHORT).show() + Toast.makeText( + context, + cursorCopiedToastMessage, + Toast.LENGTH_SHORT + ).show() }, modifier = Modifier.size(28.dp) ) { @@ -322,6 +240,59 @@ fun McpInfoScreen( } } } + // 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 index ee1408a1..567ff623 100644 --- 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 @@ -65,7 +65,9 @@ fun McpSettingsScreen( ) { val context = LocalContext.current val clipboardManager = LocalClipboardManager.current - val copiedToastMessage = stringResource(ProfileR.string.profile_mcp_token_copied) + val urlCopiedToastMessage = stringResource(ProfileR.string.profile_mcp_url_copied) + val clientIdCopiedToastMessage = stringResource(ProfileR.string.profile_mcp_client_id_copied) + if (uiState.createdToken != null) { CreatedTokenModal( @@ -239,7 +241,11 @@ fun McpSettingsScreen( IconButton( onClick = { clipboardManager.setText(AnnotatedString(mcpUrl)) - Toast.makeText(context, copiedToastMessage, Toast.LENGTH_SHORT).show() + Toast.makeText( + context, + urlCopiedToastMessage, + Toast.LENGTH_SHORT + ).show() }, modifier = Modifier.size(28.dp) ) { @@ -277,7 +283,11 @@ fun McpSettingsScreen( IconButton( onClick = { clipboardManager.setText(AnnotatedString(clientId)) - Toast.makeText(context, copiedToastMessage, Toast.LENGTH_SHORT).show() + Toast.makeText( + context, + clientIdCopiedToastMessage, + Toast.LENGTH_SHORT + ).show() }, modifier = Modifier.size(28.dp) ) { @@ -441,7 +451,10 @@ private fun TokenItemRow( style = AwanTheme.styles.captionText.let { it.copy(textStyle = it.textStyle.copy(fontFamily = FontFamily.Monospace)) } ) AwanText( - text = token.createdAt, + text = stringResource( + ProfileR.string.profile_mcp_token_created_on, + formatMcpTokenCreationDate(token.createdAt) + ), style = AwanTheme.styles.captionText ) } 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 0fb19032..93c87807 100644 --- a/feature/profile/impl/src/main/res/values-ar/strings.xml +++ b/feature/profile/impl/src/main/res/values-ar/strings.xml @@ -201,14 +201,24 @@ احرص على نسخ رمز الوصول الخاص بك الآن. لن تتمكن من رؤيته مرة أخرى! نسخ الرمز تم نسخ الرمز إلى الحافظة + تم نسخ رابط خادم MCP إلى الحافظة + تم نسخ معرف العميل إلى الحافظة + تم نسخ إعداد Claude Desktop إلى الحافظة + تم نسخ إعداد Cursor إلى الحافظة + تم نسخ مطالبة إعداد الذكاء الاصطناعي إلى الحافظة حذف الرمز؟ هل أنت تأكد من حذف رمز MCP هذا؟ سيفقد المساعد الذكي الوصول فوراً. إعادة إنشاء الرمز؟ إعادة إنشاء الرمز ستلغي المفتاح الحالي. ستحتاج إلى تحديثه في المساعد الذكي. كيفية ربط مساعدك الذكي - 1. انسخ رابط خادم MCP ومعرف العميل أعلاه. - 2. أنشئ رمز وصول واحفظ المفتاح فوراً. - 3. قم بتضمين الإعدادات في ملف التكوين (مثل claude_desktop_config.json). + تعليمات الإعداد + 1. احصل على رمز API من شاشة تكامل MCP. اضغط على إضافة رمز جديد وانسخه من رسالة النجاح التي تظهر مرة واحدة. + 2. انسخ إعداداً من الأسفل واستبدل YOUR_API_TOKEN بالرمز الخاص بك، أو انسخ مطالبة إعداد الذكاء الاصطناعي والصقها في مساعدك. + أو دع مساعدك الذكي يتولى الإعداد + نسخ مطالبة إعداد الذكاء الاصطناعي + صِلني بخادم Awan عبر MCP.\n\nأولاً، اطلب مني رمز MCP API الخاص بـ Awan. لا تتابع قبل أن أقدمه لك.\n\nبعد أن أرسل الرمز، أعد إعداد خادم Awan MCP لهذا المساعد باستخدام:\n- رابط الخادم: https://backend-production-c701.up.railway.app/api/v1/mcp\n- التفويض: Bearer [الرمز الذي أقدمه]\n\nتولَّ إعداد MCP بنفسك، وتحقق من الاتصال، وأخبرني عند جاهزيته. + + تاريخ الإنشاء: %1$s معلومات إعداد MCP نسخ رابط خادم MCP نسخ معرف العميل diff --git a/feature/profile/impl/src/main/res/values/strings.xml b/feature/profile/impl/src/main/res/values/strings.xml index 55339008..037497dc 100644 --- a/feature/profile/impl/src/main/res/values/strings.xml +++ b/feature/profile/impl/src/main/res/values/strings.xml @@ -201,14 +201,24 @@ 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 + Client ID 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 - 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). + 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: https://backend-production-c701.up.railway.app/api/v1/mcp\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 OAuth Client ID 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") + ) + } +} From 2c1e04ea6819fb01577117c6d2f0b061cb391532 Mon Sep 17 00:00:00 2001 From: "ZEIAD-LAPTOP\\zeiad" Date: Tue, 11 Aug 2026 11:28:27 +0300 Subject: [PATCH 11/13] AWAN-152: Remove obsolete MCP Client ID from settings --- .../profile/impl/ui/McpSettingsScreen.kt | 43 ------------------- .../impl/src/main/res/values-ar/strings.xml | 3 -- .../impl/src/main/res/values/strings.xml | 3 -- 3 files changed, 49 deletions(-) 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 index 567ff623..6e85df1d 100644 --- 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 @@ -66,7 +66,6 @@ fun McpSettingsScreen( val context = LocalContext.current val clipboardManager = LocalClipboardManager.current val urlCopiedToastMessage = stringResource(ProfileR.string.profile_mcp_url_copied) - val clientIdCopiedToastMessage = stringResource(ProfileR.string.profile_mcp_client_id_copied) if (uiState.createdToken != null) { @@ -215,7 +214,6 @@ fun McpSettingsScreen( val details = uiState.connectionDetails val mcpUrl = details?.mcpUrl ?: "https://mcp.awan.app/v1" - val clientId = details?.clientId ?: "awan-android-client" // MCP URL Row Column(verticalArrangement = Arrangement.spacedBy(AwanTheme.spacing.xxs)) { @@ -259,47 +257,6 @@ fun McpSettingsScreen( } } - // Client ID Row - 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 - ) { - AwanText( - text = clientId, - style = AwanTheme.styles.bodyText.let { it.copy(textStyle = it.textStyle.copy(fontFamily = FontFamily.Monospace)) }, - modifier = Modifier.weight(1f) - ) - IconButton( - onClick = { - clipboardManager.setText(AnnotatedString(clientId)) - Toast.makeText( - context, - clientIdCopiedToastMessage, - 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.textSecondary, - modifier = Modifier.size(AwanTheme.spacing.md) - ) - } - } - } } } 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 93c87807..c7d6031c 100644 --- a/feature/profile/impl/src/main/res/values-ar/strings.xml +++ b/feature/profile/impl/src/main/res/values-ar/strings.xml @@ -190,7 +190,6 @@ ربط المساعدين الذكيين (Claude, Cursor) عبر بروتوكول MCP تفاصيل الاتصال رابط خادم MCP - معرف العميل (Client ID) رموز الوصول (Tokens) إضافة رمز جديد اسم الرمز (مثال: Claude Desktop) @@ -202,7 +201,6 @@ نسخ الرمز تم نسخ الرمز إلى الحافظة تم نسخ رابط خادم MCP إلى الحافظة - تم نسخ معرف العميل إلى الحافظة تم نسخ إعداد Claude Desktop إلى الحافظة تم نسخ إعداد Cursor إلى الحافظة تم نسخ مطالبة إعداد الذكاء الاصطناعي إلى الحافظة @@ -221,7 +219,6 @@ تاريخ الإنشاء: %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 037497dc..b767dc30 100644 --- a/feature/profile/impl/src/main/res/values/strings.xml +++ b/feature/profile/impl/src/main/res/values/strings.xml @@ -190,7 +190,6 @@ 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) @@ -202,7 +201,6 @@ Copy Token Token copied to clipboard MCP server URL copied to clipboard - Client ID copied to clipboard Claude Desktop configuration copied to clipboard Cursor configuration copied to clipboard AI setup prompt copied to clipboard @@ -221,7 +219,6 @@ Created: %1$s MCP Setup Info Copy MCP Server URL - Copy OAuth Client ID Copy Configuration Snippet Delete Token %1$s Regenerate Token %1$s From ebcf90ee5b8b4e889a9e94f4bcede22ab4799a2c Mon Sep 17 00:00:00 2001 From: "ZEIAD-LAPTOP\\zeiad" Date: Tue, 11 Aug 2026 12:26:48 +0300 Subject: [PATCH 12/13] AWAN-152: Show token creation errors in snackbar --- .../impl/navigation/McpSettingsRouteScreen.kt | 16 ---------------- .../impl/presentation/McpSettingsViewModel.kt | 2 +- .../presentation/McpSettingsViewModelTest.kt | 15 +++++++++++++++ 3 files changed, 16 insertions(+), 17 deletions(-) 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 index 67517009..0a9adbc9 100644 --- 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 @@ -1,13 +1,9 @@ package com.awan.feature.profile.impl.navigation -import android.widget.Toast import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.ui.platform.LocalContext import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.awan.app.core.designsystem.ObserveAsEvents -import com.awan.feature.profile.impl.presentation.McpSettingsEvent import com.awan.feature.profile.impl.presentation.McpSettingsViewModel import com.awan.feature.profile.impl.ui.McpSettingsScreen @@ -18,18 +14,6 @@ fun McpSettingsRouteScreen( onBack: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val context = LocalContext.current - - ObserveAsEvents(viewModel.events) { event -> - when (event) { - is McpSettingsEvent.Error -> { - Toast.makeText(context, event.message.asString(context), Toast.LENGTH_SHORT).show() - } - is McpSettingsEvent.TokenCreated -> {} - McpSettingsEvent.TokenDeleted -> {} - is McpSettingsEvent.TokenRegenerated -> {} - } - } McpSettingsScreen( uiState = uiState, 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 index 6e43b4e7..c11e3d82 100644 --- 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 @@ -111,7 +111,7 @@ class McpSettingsViewModel @Inject constructor( } is Result.Error -> { val uiError = ProfileErrorMapper.mapToUiText(result.error) - _uiState.update { it.copy(isCreating = false, error = uiError) } + _uiState.update { it.copy(isCreating = false, showAddTokenDialog = false, error = uiError) } _events.send(McpSettingsEvent.Error(uiError)) } Result.Loading -> Unit 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 index 7543fb8f..3df4d7bf 100644 --- 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 @@ -1,5 +1,6 @@ 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 @@ -115,6 +116,18 @@ class McpSettingsViewModelTest { 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() @@ -162,6 +175,7 @@ class McpSettingsViewModelTest { } private class FakeMcpRepository : McpRepository { + var createResult: Result? = null private val tokensList = mutableListOf( McpToken("token-1", "Claude Desktop", "••••••••abcd", "2026-08-11T00:00:00Z") ) @@ -174,6 +188,7 @@ class McpSettingsViewModelTest { 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, From cbf825918cea2ccf920cb0451607f556921d0c8c Mon Sep 17 00:00:00 2001 From: "ZEIAD-LAPTOP\\zeiad" Date: Tue, 11 Aug 2026 13:24:31 +0300 Subject: [PATCH 13/13] AWAN-152: Resolve MCP review findings --- .gitignore | 1 + app/src/main/java/com/awan/app/AwanApp.kt | 6 +- .../data/mcp/repository/McpRepositoryImpl.kt | 10 +-- .../core/data/mcp/McpRepositoryImplTest.kt | 33 ++++++++- .../awan/app/core/network/di/NetworkModule.kt | 5 +- .../dto/mcp/CreateMcpTokenRequestDto.kt | 9 --- .../dto/mcp/CreatedMcpTokenResponseDto.kt | 13 ---- .../dto/mcp/McpConnectionDetailsDto.kt | 10 --- .../network/dto/mcp/McpTokenResponseDto.kt | 13 ---- .../impl/navigation/McpInfoRouteScreen.kt | 8 ++ .../impl/presentation/McpSettingsViewModel.kt | 69 ++++++++++++------ .../feature/profile/impl/ui/McpInfoScreen.kt | 7 +- .../impl/src/main/res/values-ar/strings.xml | 2 +- .../impl/src/main/res/values/strings.xml | 2 +- vm_repo.txt | Bin 2014 -> 0 bytes 15 files changed, 103 insertions(+), 85 deletions(-) delete mode 100644 core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreateMcpTokenRequestDto.kt delete mode 100644 core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreatedMcpTokenResponseDto.kt delete mode 100644 core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpConnectionDetailsDto.kt delete mode 100644 core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpTokenResponseDto.kt delete mode 100644 vm_repo.txt 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 5ab1b5df..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 @@ -251,8 +253,8 @@ fun AwanApp( onLogout = { navigator.replaceAll(LoginRoute) }, onBack = { navigator.goBack()}, onNavigateToInventory = { navigator.navigate(InventoryRoute) }, - onNavigateToMcpSettings = { navigator.navigate(com.awan.feature.profile.api.McpSettingsRoute) }, - onNavigateToMcpInfo = { navigator.navigate(com.awan.feature.profile.api.McpInfoRoute) }, + onNavigateToMcpSettings = { navigator.navigate(McpSettingsRoute) }, + onNavigateToMcpInfo = { navigator.navigate(McpInfoRoute) }, ) goalPreviewEntry( onBack = { navigator.goBack() }, diff --git a/core/data/src/main/kotlin/com/awan/app/core/data/mcp/repository/McpRepositoryImpl.kt b/core/data/src/main/kotlin/com/awan/app/core/data/mcp/repository/McpRepositoryImpl.kt index 0cab18c8..c2207889 100644 --- a/core/data/src/main/kotlin/com/awan/app/core/data/mcp/repository/McpRepositoryImpl.kt +++ b/core/data/src/main/kotlin/com/awan/app/core/data/mcp/repository/McpRepositoryImpl.kt @@ -12,6 +12,7 @@ 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 @@ -39,7 +40,7 @@ class McpRepositoryImpl @Inject constructor( emit( Result.Success( McpConnectionDetails( - mcpUrl = "https://backend-production-c701.up.railway.app/api/v1/mcp", + mcpUrl = "${BuildConfig.AWAN_BASE_URL.trimEnd('/')}/v1/mcp", clientId = "awan-android-client", ) ) @@ -115,11 +116,8 @@ class McpRepositoryImpl @Inject constructor( val dto = createResponse.body() ?: throw IllegalStateException("Empty response body") val createdToken = dto.toDomain() - try { - mcpApiService.revokeApiKey(id) - } catch (e: Exception) { - if (e is CancellationException) throw e - } + val revokeResponse = mcpApiService.revokeApiKey(id) + if (!revokeResponse.isSuccessful) throw HttpException(revokeResponse) try { mcpTokenDao.deleteMcpToken(id) diff --git a/core/data/src/test/java/com/awan/app/core/data/mcp/McpRepositoryImplTest.kt b/core/data/src/test/java/com/awan/app/core/data/mcp/McpRepositoryImplTest.kt index 7cc32f55..a7f07364 100644 --- a/core/data/src/test/java/com/awan/app/core/data/mcp/McpRepositoryImplTest.kt +++ b/core/data/src/test/java/com/awan/app/core/data/mcp/McpRepositoryImplTest.kt @@ -1,6 +1,7 @@ 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 @@ -35,6 +36,7 @@ private class FakeMcpApiService : McpApiService { 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 } @@ -55,6 +57,9 @@ private class FakeMcpApiService : McpApiService { 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)) } @@ -132,7 +137,7 @@ class McpRepositoryImplTest { assertTrue(result is Result.Success) val details = (result as Result.Success).data - assertEquals("https://backend-production-c701.up.railway.app/api/v1/mcp", details.mcpUrl) + assertEquals("${BuildConfig.AWAN_BASE_URL.trimEnd('/')}/v1/mcp", details.mcpUrl) assertEquals("awan-android-client", details.clientId) } @@ -288,6 +293,32 @@ class McpRepositoryImplTest { 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 { 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 2ea52448..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 @@ -178,8 +179,8 @@ object NetworkModule { @Provides @Singleton - fun providesMcpApiService(retrofit: Retrofit): com.awan.app.core.network.api.McpApiService = - retrofit.create(com.awan.app.core.network.api.McpApiService::class.java) + fun providesMcpApiService(retrofit: Retrofit): McpApiService = + retrofit.create(McpApiService::class.java) @Provides @Singleton diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreateMcpTokenRequestDto.kt b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreateMcpTokenRequestDto.kt deleted file mode 100644 index cace98e2..00000000 --- a/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreateMcpTokenRequestDto.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.awan.app.core.network.dto.mcp - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class CreateMcpTokenRequestDto( - @SerialName("name") val name: String, -) diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreatedMcpTokenResponseDto.kt b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreatedMcpTokenResponseDto.kt deleted file mode 100644 index 37af60f8..00000000 --- a/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/CreatedMcpTokenResponseDto.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.awan.app.core.network.dto.mcp - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@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, -) diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpConnectionDetailsDto.kt b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpConnectionDetailsDto.kt deleted file mode 100644 index 9ce2cec4..00000000 --- a/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpConnectionDetailsDto.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.awan.app.core.network.dto.mcp - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class McpConnectionDetailsDto( - @SerialName("mcpUrl") val mcpUrl: String, - @SerialName("clientId") val clientId: String, -) diff --git a/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpTokenResponseDto.kt b/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpTokenResponseDto.kt deleted file mode 100644 index 3ef767e2..00000000 --- a/core/network/src/main/kotlin/com/awan/app/core/network/dto/mcp/McpTokenResponseDto.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.awan.app.core.network.dto.mcp - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@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, -) 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 index 8d4ae147..2480085f 100644 --- 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 @@ -1,13 +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/presentation/McpSettingsViewModel.kt b/feature/profile/impl/src/main/java/com/awan/feature/profile/impl/presentation/McpSettingsViewModel.kt index c11e3d82..17bfe39e 100644 --- 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 @@ -10,6 +10,7 @@ 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 @@ -35,6 +36,8 @@ class McpSettingsViewModel @Inject constructor( private val _events = Channel(Channel.BUFFERED) val events: Flow = _events.receiveAsFlow() + private var loadJob: Job? = null + init { loadData() } @@ -58,35 +61,54 @@ class McpSettingsViewModel @Inject constructor( } private fun loadData() { - viewModelScope.launch { + loadJob?.cancel() + loadJob = viewModelScope.launch { _uiState.update { it.copy(isLoading = true, error = null) } - getMcpConnectionDetailsUseCase().collect { result -> - when (result) { - is Result.Success -> { - _uiState.update { it.copy(connectionDetails = result.data, isLoading = false) } - } - is Result.Error -> { - val uiError = ProfileErrorMapper.mapToUiText(result.error) - _uiState.update { it.copy(error = uiError, isLoading = false) } - } - Result.Loading -> { - _uiState.update { it.copy(isLoading = true) } + 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) } } } } - } - viewModelScope.launch { - getMcpTokensUseCase().collect { result -> - when (result) { - is Result.Success -> { - _uiState.update { it.copy(tokens = result.data) } - } - is Result.Error -> { - val uiError = ProfileErrorMapper.mapToUiText(result.error) - _uiState.update { it.copy(error = uiError) } + 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 } - Result.Loading -> Unit } } } @@ -125,7 +147,6 @@ class McpSettingsViewModel @Inject constructor( is Result.Success -> { _uiState.update { state -> state.copy( - tokens = state.tokens.filterNot { it.id == id }, deletingToken = null, ) } 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 index bf8ba97a..d0002b20 100644 --- 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 @@ -43,12 +43,13 @@ internal fun formatMcpTokenCreationDate(createdAt: String): String = @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) + 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) @@ -61,7 +62,7 @@ fun McpInfoScreen( "args": [ "-y", "@awan/mcp-server", - "--url", "https://backend-production-c701.up.railway.app/api/v1/mcp", + "--url", "$mcpUrl", "--token", "YOUR_API_TOKEN" ] } @@ -74,7 +75,7 @@ fun McpInfoScreen( "mcp": { "servers": { "awan": { - "url": "https://backend-production-c701.up.railway.app/api/v1/mcp", + "url": "$mcpUrl", "headers": { "Authorization": "Bearer YOUR_API_TOKEN" } 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 c7d6031c..06280db3 100644 --- a/feature/profile/impl/src/main/res/values-ar/strings.xml +++ b/feature/profile/impl/src/main/res/values-ar/strings.xml @@ -214,7 +214,7 @@ 2. انسخ إعداداً من الأسفل واستبدل YOUR_API_TOKEN بالرمز الخاص بك، أو انسخ مطالبة إعداد الذكاء الاصطناعي والصقها في مساعدك. أو دع مساعدك الذكي يتولى الإعداد نسخ مطالبة إعداد الذكاء الاصطناعي - صِلني بخادم Awan عبر MCP.\n\nأولاً، اطلب مني رمز MCP API الخاص بـ Awan. لا تتابع قبل أن أقدمه لك.\n\nبعد أن أرسل الرمز، أعد إعداد خادم Awan MCP لهذا المساعد باستخدام:\n- رابط الخادم: https://backend-production-c701.up.railway.app/api/v1/mcp\n- التفويض: Bearer [الرمز الذي أقدمه]\n\nتولَّ إعداد MCP بنفسك، وتحقق من الاتصال، وأخبرني عند جاهزيته. + صِلني بخادم Awan عبر MCP.\n\nأولاً، اطلب مني رمز MCP API الخاص بـ Awan. لا تتابع قبل أن أقدمه لك.\n\nبعد أن أرسل الرمز، أعد إعداد خادم Awan MCP لهذا المساعد باستخدام:\n- رابط الخادم: %1$s\n- التفويض: Bearer [الرمز الذي أقدمه]\n\nتولَّ إعداد MCP بنفسك، وتحقق من الاتصال، وأخبرني عند جاهزيته. تاريخ الإنشاء: %1$s معلومات إعداد MCP diff --git a/feature/profile/impl/src/main/res/values/strings.xml b/feature/profile/impl/src/main/res/values/strings.xml index b767dc30..ef4d68d9 100644 --- a/feature/profile/impl/src/main/res/values/strings.xml +++ b/feature/profile/impl/src/main/res/values/strings.xml @@ -214,7 +214,7 @@ 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: https://backend-production-c701.up.railway.app/api/v1/mcp\n- Authorization: Bearer [the token I provide]\n\nHandle the MCP setup yourself, verify the connection, and tell me when it is ready. + 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 diff --git a/vm_repo.txt b/vm_repo.txt deleted file mode 100644 index aa2d45c0eb9bbd8baee0fcc470e89e2e8472f1e4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2014 zcmd^h9Mfn#Y85p3|P#dGK+@+a3DTpiMMJU+PKc=nCYDoqnc^*y(kweP*4r?e%iA+#-C` zxtUZjqF}Muk%oAgpihZwdduQ7UKBF?94S^}&T(#0YS^?eXk4Q~i z+jIHrMElfFp$k}ghE>!Us#iz}71-zNhe(+&18q;e)s=g=0=AP7yNPR^*|OWSu1t4z z097{|)@jnvvFvs`{y}}u?H^eualuxyj<8tgS;orz%{$ZoBf6)Z1)_7ZZM>XLIuYB_ hw$`-{dr<2waQj+`gHVeVeWFWkx!|d}_MBb<$#=VSd(Z#?