Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ skills-lock.json
.gemini/
.claude/
.artifacts/
vm_repo.txt
6 changes: 5 additions & 1 deletion app/src/main/java/com/awan/app/AwanApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -250,7 +252,9 @@ fun AwanApp(
onNavigateToEditRoutine = { templateId -> navigator.navigate(EditRoutineRoute(templateId)) },
onLogout = { navigator.replaceAll(LoginRoute) },
onBack = { navigator.goBack()},
onNavigateToInventory = { navigator.navigate(InventoryRoute) }
onNavigateToInventory = { navigator.navigate(InventoryRoute) },
onNavigateToMcpSettings = { navigator.navigate(McpSettingsRoute) },
onNavigateToMcpInfo = { navigator.navigate(McpInfoRoute) },
)
goalPreviewEntry(
onBack = { navigator.goBack() },
Expand Down
1 change: 1 addition & 0 deletions core/data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.awan.app.core.data.mcp.mapper

import com.awan.app.core.database.model.McpTokenEntity
import com.awan.app.core.domain.mcp.model.CreatedMcpToken
import com.awan.app.core.domain.mcp.model.McpToken
import com.awan.app.core.network.dto.mcp.ApiKeyResponseDto
import com.awan.app.core.network.dto.mcp.ApiKeySummaryDto

fun ApiKeySummaryDto.toEntity(): McpTokenEntity = McpTokenEntity(
id = id,
name = name,
maskedToken = keyPrefix,
createdAt = createdAt,
lastUsedAt = null,
)

fun ApiKeyResponseDto.toDomain(): CreatedMcpToken = CreatedMcpToken(
id = id,
name = name,
rawToken = keyValue,
maskedToken = if (keyValue.length >= 12) keyValue.take(12) + "..." else keyValue,
createdAt = createdAt,
)

fun ApiKeyResponseDto.toEntity(): McpTokenEntity = McpTokenEntity(
id = id,
name = name,
maskedToken = if (keyValue.length >= 12) keyValue.take(12) + "..." else keyValue,
createdAt = createdAt,
lastUsedAt = null,
)

fun McpTokenEntity.toDomain(): McpToken = McpToken(
id = id,
name = name,
maskedToken = maskedToken,
createdAt = createdAt,
lastUsedAt = lastUsedAt,
)

Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package com.awan.app.core.data.mcp.repository

import com.awan.app.core.common.dispatcher.AwanDispatchers
import com.awan.app.core.common.dispatcher.Dispatcher
import com.awan.app.core.common.error.AppError
import com.awan.app.core.common.result.Result
import com.awan.app.core.data.mcp.mapper.toDomain
import com.awan.app.core.data.mcp.mapper.toEntity
import com.awan.app.core.database.dao.McpTokenDao
import com.awan.app.core.domain.mcp.model.CreatedMcpToken
import com.awan.app.core.domain.mcp.model.McpConnectionDetails
import com.awan.app.core.domain.mcp.model.McpToken
import com.awan.app.core.domain.mcp.repository.McpRepository
import com.awan.app.core.domain.network.NetworkConnectivityMonitor
import com.awan.app.core.network.BuildConfig
import com.awan.app.core.network.api.McpApiService
import com.awan.app.core.network.dto.mcp.CreateApiKeyRequestDto
import com.awan.app.core.network.error.safeApiCall
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import retrofit2.HttpException
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class McpRepositoryImpl @Inject constructor(
private val mcpApiService: McpApiService,
private val mcpTokenDao: McpTokenDao,
private val connectivityMonitor: NetworkConnectivityMonitor,
@Dispatcher(AwanDispatchers.IO) private val ioDispatcher: CoroutineDispatcher,
) : McpRepository {

override fun getMcpConnectionDetails(): Flow<Result<McpConnectionDetails>> = flow {
emit(
Result.Success(
McpConnectionDetails(
Comment thread
ZeiadT marked this conversation as resolved.
mcpUrl = "${BuildConfig.AWAN_BASE_URL.trimEnd('/')}/v1/mcp",
clientId = "awan-android-client",
)
)
)
}.flowOn(ioDispatcher)

override fun getMcpTokens(): Flow<Result<List<McpToken>>> = flow {
if (connectivityMonitor.isCurrentlyOnline()) {
try {
val response = mcpApiService.getApiKeys()
if (response.isSuccessful) {
val dtos = response.body().orEmpty()
val entities = dtos.map { it.toEntity() }
mcpTokenDao.replaceMcpTokens(entities)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
// If network sync fails, fallback to Room cached tokens
}
}
emitAll(
mcpTokenDao.getMcpTokens().map { entities ->
Result.Success(entities.map { it.toDomain() })
}
)
}.flowOn(ioDispatcher)

override suspend fun createMcpToken(name: String): Result<CreatedMcpToken> {
if (!connectivityMonitor.isCurrentlyOnline()) {
return Result.Error(AppError.Network)
}
return safeApiCall(ioDispatcher) {
val response = mcpApiService.createApiKey(CreateApiKeyRequestDto(name = name))
if (!response.isSuccessful) throw HttpException(response)
val dto = response.body() ?: throw IllegalStateException("Empty response body")
val createdToken = dto.toDomain()
try {
mcpTokenDao.upsertMcpTokens(listOf(dto.toEntity()))
} catch (e: Exception) {
if (e is CancellationException) throw e
// Local DB cache write failure must NOT drop or cause failure of the returned raw token
}
createdToken
}
}

override suspend fun deleteMcpToken(id: String): Result<Unit> {
if (!connectivityMonitor.isCurrentlyOnline()) {
return Result.Error(AppError.Network)
}
val result = safeApiCall(ioDispatcher) {
val response = mcpApiService.revokeApiKey(id)
if (!response.isSuccessful) throw HttpException(response)
}
if (result is Result.Success) {
try {
mcpTokenDao.deleteMcpToken(id)
} catch (e: Exception) {
if (e is CancellationException) throw e
}
}
return result
}

override suspend fun regenerateMcpToken(id: String): Result<CreatedMcpToken> {
if (!connectivityMonitor.isCurrentlyOnline()) {
return Result.Error(AppError.Network)
}
val existingTokenName = mcpTokenDao.getMcpTokens().first().find { it.id == id }?.name ?: "MCP Token"
return safeApiCall(ioDispatcher) {
val createResponse = mcpApiService.createApiKey(CreateApiKeyRequestDto(name = existingTokenName))
if (!createResponse.isSuccessful) throw HttpException(createResponse)
val dto = createResponse.body() ?: throw IllegalStateException("Empty response body")
val createdToken = dto.toDomain()

val revokeResponse = mcpApiService.revokeApiKey(id)
if (!revokeResponse.isSuccessful) throw HttpException(revokeResponse)

try {
mcpTokenDao.deleteMcpToken(id)
mcpTokenDao.upsertMcpTokens(listOf(dto.toEntity()))
} catch (e: Exception) {
if (e is CancellationException) throw e
// Local DB cache write failure must NOT drop or cause failure of the returned raw token
}
createdToken
}
}
}

Loading
Loading