Monorepo: Gitea CI, docs, auth/sync, backend APIs, gitignore
- Add Gitea Actions workflow; point README to gitea.d-bis.org/Sankofa_Phoenix/SMOA - Expand .gitignore for Spring H2 data, secrets, Kotlin .kotlin/, tooling - Track docs/api/generated ReDoc bundle; refresh api docs README - Android: network/auth/sync, UI shell, tests; backend credentials/integrity APIs - Docs, scripts (generate-api-docs), modules and core updates Made-with: Cursor
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("com.google.dagger.hilt.android")
|
||||
id("kotlin-kapt")
|
||||
id("dagger.hilt.android.plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -29,6 +29,14 @@ android {
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.4"
|
||||
}
|
||||
|
||||
hilt {
|
||||
enableAggregatingTask = true
|
||||
}
|
||||
}
|
||||
|
||||
kapt {
|
||||
correctErrorTypes = true
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -43,9 +51,13 @@ dependencies {
|
||||
implementation(Dependencies.composeMaterial3)
|
||||
implementation(Dependencies.androidxCoreKtx)
|
||||
implementation(Dependencies.androidxLifecycleRuntimeKtx)
|
||||
implementation(Dependencies.androidxLifecycleViewmodelKtx)
|
||||
implementation(Dependencies.androidxLifecycleViewmodelCompose)
|
||||
implementation(Dependencies.androidxLifecycleRuntimeCompose)
|
||||
|
||||
implementation(Dependencies.hiltAndroid)
|
||||
kapt(Dependencies.hiltAndroidCompiler)
|
||||
implementation(Dependencies.hiltNavigationCompose)
|
||||
|
||||
implementation(Dependencies.roomRuntime)
|
||||
implementation(Dependencies.roomKtx)
|
||||
@@ -56,5 +68,10 @@ dependencies {
|
||||
|
||||
implementation(Dependencies.coroutinesCore)
|
||||
implementation(Dependencies.coroutinesAndroid)
|
||||
}
|
||||
|
||||
implementation("com.google.code.gson:gson:2.10.1")
|
||||
|
||||
testImplementation(Dependencies.junit)
|
||||
testImplementation(Dependencies.mockk)
|
||||
testImplementation(Dependencies.coroutinesTest)
|
||||
}
|
||||
|
||||
+320
-17
@@ -1,36 +1,339 @@
|
||||
package com.smoa.modules.credentials
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.smoa.core.common.sync.CredentialSyncRequestDto
|
||||
import com.smoa.modules.credentials.domain.SmoaCredentialTemplateIds
|
||||
import com.smoa.modules.credentials.ui.CredentialsViewModel
|
||||
|
||||
/**
|
||||
* Credentials module - Secure presentation of government-issued credentials.
|
||||
* Credentials, travel (ICAO MRTD and related types), and pointers to document modules (orders, evidence, reports).
|
||||
* Lists data from encrypted Room cache; refreshes via sync pull and hydrates payloads with GET /credentials/{id}.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CredentialsModule(
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: CredentialsViewModel = hiltViewModel(),
|
||||
onOpenOrders: () -> Unit = {},
|
||||
onOpenEvidence: () -> Unit = {},
|
||||
onOpenReports: () -> Unit = {}
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Issued Credentials",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
Text(
|
||||
text = "Barcode generation and credential display functionality available",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
// Credential display UI with barcode integration will be implemented here
|
||||
val credentials by viewModel.credentials.collectAsStateWithLifecycle()
|
||||
val selectedId by viewModel.selectedId.collectAsStateWithLifecycle()
|
||||
var issueOpen by remember { mutableStateOf(false) }
|
||||
val gson = remember { Gson() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.hydrateMissingPayloads()
|
||||
}
|
||||
|
||||
val travel = credentials.filter { it.credentialType == SmoaCredentialTemplateIds.ICAO9303_MRTD }
|
||||
val other = credentials.filter { it.credentialType != SmoaCredentialTemplateIds.ICAO9303_MRTD }
|
||||
val selected = selectedId?.let { id -> credentials.find { it.credentialId == id } }
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
if (selected != null) "Credential" else "Credentials & travel"
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
if (selected != null) {
|
||||
IconButton(onClick = { viewModel.selectCredential(null) }) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (selected == null) {
|
||||
IconButton(onClick = { viewModel.refreshFromServer() }) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Sync")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (selected == null) {
|
||||
FloatingActionButton(onClick = { issueOpen = true }) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Issue credential")
|
||||
}
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
if (issueOpen) {
|
||||
IssueCredentialDialog(
|
||||
gson = gson,
|
||||
onDismiss = { issueOpen = false },
|
||||
onSubmit = { dto ->
|
||||
viewModel.queueIssuance(dto)
|
||||
issueOpen = false
|
||||
}
|
||||
)
|
||||
}
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
if (selected != null) {
|
||||
CredentialDetailBody(entity = selected, modifier = Modifier.verticalScroll(rememberScrollState()))
|
||||
} else {
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
"Travel documents use credential type ${SmoaCredentialTemplateIds.ICAO9303_MRTD}. " +
|
||||
"Other templates cover PIV-class PKI, DL/ID, military ID, badges, and mobile credentials.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
item {
|
||||
Text("Travel (MRTD)", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
if (travel.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
"No travel credentials in cache. Sync with backend or issue a new credential.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(travel, key = { it.credentialId }) { row ->
|
||||
CredentialRowCard(row) { viewModel.selectCredential(row.credentialId) }
|
||||
}
|
||||
}
|
||||
item {
|
||||
Text("Other credentials", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
if (other.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
"No other credentials in cache.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(other, key = { it.credentialId }) { row ->
|
||||
CredentialRowCard(row) { viewModel.selectCredential(row.credentialId) }
|
||||
}
|
||||
}
|
||||
item {
|
||||
Text("Documents (orders, evidence, reports)", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
"Open synced document modules from the menu or below.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton(onClick = onOpenOrders) { Text("Orders") }
|
||||
TextButton(onClick = onOpenEvidence) { Text("Evidence") }
|
||||
TextButton(onClick = onOpenReports) { Text("Reports") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CredentialRowCard(
|
||||
row: com.smoa.modules.credentials.data.CredentialCacheEntity,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(row.credentialType, style = MaterialTheme.typography.titleSmall)
|
||||
Text("ID: ${row.credentialId}", style = MaterialTheme.typography.bodySmall)
|
||||
row.issuer?.let { Text("Issuer: $it", style = MaterialTheme.typography.bodySmall) }
|
||||
Text(
|
||||
if (row.payloadJson.isNullOrBlank()) "Payload: not loaded (tap after sync)"
|
||||
else "Payload: loaded",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CredentialDetailBody(
|
||||
entity: com.smoa.modules.credentials.data.CredentialCacheEntity,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Type: ${entity.credentialType}", style = MaterialTheme.typography.bodyLarge)
|
||||
Text("Credential ID: ${entity.credentialId}", style = MaterialTheme.typography.bodyMedium)
|
||||
Text("Holder: ${entity.holderId}", style = MaterialTheme.typography.bodyMedium)
|
||||
entity.issuer?.let { Text("Issuer: $it", style = MaterialTheme.typography.bodyMedium) }
|
||||
entity.issuedAt?.let { Text("Issued at (epoch ms): $it", style = MaterialTheme.typography.bodySmall) }
|
||||
entity.expiresAt?.let { Text("Expires at (epoch ms): $it", style = MaterialTheme.typography.bodySmall) }
|
||||
Text("Updated at (epoch ms): ${entity.updatedAt}", style = MaterialTheme.typography.bodySmall)
|
||||
Text("Payload", style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
text = entity.payloadJson ?: "(empty — use Refresh on the list to pull full record from server)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IssueCredentialDialog(
|
||||
gson: Gson,
|
||||
onDismiss: () -> Unit,
|
||||
onSubmit: (CredentialSyncRequestDto) -> Unit
|
||||
) {
|
||||
var credentialId by remember { mutableStateOf("") }
|
||||
var holderId by remember { mutableStateOf("") }
|
||||
var credentialType by remember { mutableStateOf(SmoaCredentialTemplateIds.ICAO9303_MRTD) }
|
||||
var issuer by remember { mutableStateOf("") }
|
||||
var payloadText by remember { mutableStateOf("{}") }
|
||||
var typeMenu by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Issue credential") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = credentialId,
|
||||
onValueChange = { credentialId = it },
|
||||
label = { Text("Credential ID") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = holderId,
|
||||
onValueChange = { holderId = it },
|
||||
label = { Text("Holder ID") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Type:", style = MaterialTheme.typography.bodyMedium)
|
||||
TextButton(onClick = { typeMenu = !typeMenu }) {
|
||||
Text(credentialType)
|
||||
}
|
||||
}
|
||||
if (typeMenu) {
|
||||
Column {
|
||||
SmoaCredentialTemplateIds.CANONICAL.sorted().forEach { t ->
|
||||
TextButton(onClick = {
|
||||
credentialType = t
|
||||
typeMenu = false
|
||||
}) { Text(t) }
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = issuer,
|
||||
onValueChange = { issuer = it },
|
||||
label = { Text("Issuer (optional)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = payloadText,
|
||||
onValueChange = { payloadText = it },
|
||||
label = { Text("Payload JSON object") },
|
||||
minLines = 3,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
error?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) }
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (credentialId.isBlank() || holderId.isBlank()) {
|
||||
error = "Credential ID and holder ID are required."
|
||||
return@TextButton
|
||||
}
|
||||
val payload: Map<String, Any?>? = runCatching {
|
||||
val type = object : TypeToken<Map<String, Any?>>() {}.type
|
||||
gson.fromJson<Map<String, Any?>>(payloadText.ifBlank { "{}" }, type) ?: emptyMap()
|
||||
}.getOrElse {
|
||||
error = "Invalid JSON payload."
|
||||
return@TextButton
|
||||
}
|
||||
error = null
|
||||
onSubmit(
|
||||
CredentialSyncRequestDto(
|
||||
credentialId = credentialId.trim(),
|
||||
holderId = holderId.trim(),
|
||||
credentialType = credentialType,
|
||||
issuer = issuer.ifBlank { null },
|
||||
issuedAt = System.currentTimeMillis(),
|
||||
payload = payload,
|
||||
clientUpdatedAt = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
}
|
||||
) { Text("Queue sync") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.smoa.modules.credentials.data
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface CredentialCacheDao {
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertAll(items: List<CredentialCacheEntity>)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(item: CredentialCacheEntity)
|
||||
|
||||
@Query("SELECT * FROM credential_cache ORDER BY updatedAt DESC")
|
||||
suspend fun getAll(): List<CredentialCacheEntity>
|
||||
|
||||
@Query("SELECT * FROM credential_cache ORDER BY updatedAt DESC")
|
||||
fun observeAll(): Flow<List<CredentialCacheEntity>>
|
||||
|
||||
@Query("SELECT * FROM credential_cache WHERE holderId = :holderId ORDER BY updatedAt DESC")
|
||||
suspend fun getByHolder(holderId: String): List<CredentialCacheEntity>
|
||||
|
||||
@Query("SELECT * FROM credential_cache WHERE credentialId = :credentialId LIMIT 1")
|
||||
suspend fun getByCredentialId(credentialId: String): CredentialCacheEntity?
|
||||
|
||||
@Query("DELETE FROM credential_cache WHERE credentialId = :credentialId")
|
||||
suspend fun deleteById(credentialId: String)
|
||||
|
||||
@Query("DELETE FROM credential_cache")
|
||||
suspend fun clearAll()
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.smoa.modules.credentials.data
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
|
||||
@Database(
|
||||
entities = [CredentialCacheEntity::class],
|
||||
version = 1,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class CredentialCacheDatabase : RoomDatabase() {
|
||||
abstract fun credentialCacheDao(): CredentialCacheDao
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.smoa.modules.credentials.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import com.smoa.core.security.EncryptedDatabaseHelper
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object CredentialCacheDatabaseModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCredentialCacheDatabase(
|
||||
@ApplicationContext context: Context,
|
||||
encryptedDatabaseHelper: EncryptedDatabaseHelper
|
||||
): CredentialCacheDatabase {
|
||||
val factory = encryptedDatabaseHelper.createOpenHelperFactory("credential_cache_database")
|
||||
return Room.databaseBuilder(
|
||||
context,
|
||||
CredentialCacheDatabase::class.java,
|
||||
"credential_cache_database"
|
||||
)
|
||||
.openHelperFactory(factory)
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideCredentialCacheDao(db: CredentialCacheDatabase): CredentialCacheDao =
|
||||
db.credentialCacheDao()
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.smoa.modules.credentials.data
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* Local cache row aligned with server `credentials` and [CredentialSyncRequestDto].
|
||||
*/
|
||||
@Entity(tableName = "credential_cache")
|
||||
data class CredentialCacheEntity(
|
||||
@PrimaryKey val credentialId: String,
|
||||
val holderId: String,
|
||||
val credentialType: String,
|
||||
val issuer: String?,
|
||||
val issuedAt: Long?,
|
||||
val expiresAt: Long?,
|
||||
val payloadJson: String?,
|
||||
val updatedAt: Long
|
||||
)
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package com.smoa.modules.credentials.data
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.smoa.core.common.sync.CredentialCacheSyncPort
|
||||
import com.smoa.core.common.sync.CredentialConflictRemoteJson
|
||||
import com.smoa.core.common.sync.CredentialSyncRequestDto
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class CredentialCacheMerger @Inject constructor(
|
||||
private val dao: CredentialCacheDao
|
||||
) : CredentialCacheSyncPort {
|
||||
|
||||
private val gson = Gson()
|
||||
|
||||
suspend fun mergeCredentialConflictRemoteData(json: ByteArray, itemId: String) {
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val row = CredentialConflictRemoteJson.parse(json, itemId)?.cacheRow ?: return@runCatching
|
||||
dao.upsert(
|
||||
CredentialCacheEntity(
|
||||
credentialId = row.credentialId,
|
||||
holderId = row.holderId,
|
||||
credentialType = row.credentialType,
|
||||
issuer = row.issuer,
|
||||
issuedAt = row.issuedAt,
|
||||
expiresAt = row.expiresAt,
|
||||
payloadJson = row.payloadJson,
|
||||
updatedAt = row.updatedAt
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun mergeDetailJson(json: ByteArray) {
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val dto = gson.fromJson(json.toString(Charsets.UTF_8), CredentialPullDetailDto::class.java)
|
||||
?: return@runCatching
|
||||
dao.upsert(
|
||||
CredentialCacheEntity(
|
||||
credentialId = dto.credentialId,
|
||||
holderId = dto.holderId,
|
||||
credentialType = dto.credentialType,
|
||||
issuer = dto.issuer,
|
||||
issuedAt = dto.issuedAt,
|
||||
expiresAt = dto.expiresAt,
|
||||
payloadJson = dto.payloadJson,
|
||||
updatedAt = dto.updatedAt
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun mergeFromPullCredentialsJson(json: ByteArray) {
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val text = json.toString(Charsets.UTF_8)
|
||||
val type = object : TypeToken<List<CredentialPullListItemDto>>() {}.type
|
||||
val list: List<CredentialPullListItemDto> = gson.fromJson(text, type) ?: return@runCatching
|
||||
for (dto in list) {
|
||||
val existing = dao.getByCredentialId(dto.credentialId)
|
||||
val entity = dto.toEntityPreservingPayload(existing?.payloadJson)
|
||||
dao.upsert(entity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun upsertAfterCredentialSync(data: Any, serverTimestamp: Long, itemId: String) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val dto = when (data) {
|
||||
is CredentialSyncRequestDto -> data
|
||||
is Map<*, *> -> gson.fromJson(gson.toJson(data), CredentialSyncRequestDto::class.java)
|
||||
is ByteArray ->
|
||||
CredentialConflictRemoteJson.parse(data, itemId)?.syncDto ?: return@withContext
|
||||
else -> return@withContext
|
||||
}
|
||||
if (dto.credentialId != itemId) return@withContext
|
||||
val payloadJson = dto.payload?.let { gson.toJson(it) }
|
||||
val existing = dao.getByCredentialId(dto.credentialId)
|
||||
val entity = CredentialCacheEntity(
|
||||
credentialId = dto.credentialId,
|
||||
holderId = dto.holderId,
|
||||
credentialType = dto.credentialType,
|
||||
issuer = dto.issuer,
|
||||
issuedAt = dto.issuedAt,
|
||||
expiresAt = dto.expiresAt,
|
||||
payloadJson = payloadJson ?: existing?.payloadJson,
|
||||
updatedAt = serverTimestamp
|
||||
)
|
||||
dao.upsert(entity)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun removeCredential(credentialId: String) {
|
||||
withContext(Dispatchers.IO) {
|
||||
dao.deleteById(credentialId)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun restoreLocalCredentialAfterUseLocal(data: Any, itemId: String) {
|
||||
val dto = when (data) {
|
||||
is CredentialSyncRequestDto -> data
|
||||
is Map<*, *> -> gson.fromJson(gson.toJson(data), CredentialSyncRequestDto::class.java)
|
||||
is ByteArray ->
|
||||
CredentialConflictRemoteJson.parse(data, itemId)?.syncDto
|
||||
?: runCatching {
|
||||
gson.fromJson(data.toString(Charsets.UTF_8), CredentialSyncRequestDto::class.java)
|
||||
}.getOrNull()
|
||||
else -> null
|
||||
} ?: return
|
||||
if (dto.credentialId != itemId) return
|
||||
val ts = dto.clientUpdatedAt ?: System.currentTimeMillis()
|
||||
upsertAfterCredentialSync(dto, ts, itemId)
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.smoa.modules.credentials.data
|
||||
|
||||
import com.smoa.core.common.sync.CredentialCacheSyncPort
|
||||
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 CredentialCachePortModule {
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindCredentialCacheSyncPort(impl: CredentialCacheMerger): CredentialCacheSyncPort
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.smoa.modules.credentials.data
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/** JSON from GET /api/v1/credentials/{credentialId}. */
|
||||
internal data class CredentialPullDetailDto(
|
||||
@SerializedName("credentialId") val credentialId: String,
|
||||
@SerializedName("holderId") val holderId: String,
|
||||
@SerializedName("credentialType") val credentialType: String,
|
||||
@SerializedName("issuer") val issuer: String?,
|
||||
@SerializedName("issuedAt") val issuedAt: Long?,
|
||||
@SerializedName("expiresAt") val expiresAt: Long?,
|
||||
@SerializedName("payloadJson") val payloadJson: String?,
|
||||
@SerializedName("updatedAt") val updatedAt: Long
|
||||
)
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.smoa.modules.credentials.data
|
||||
|
||||
/**
|
||||
* JSON shape for `GET /api/v1/credentials` (backend [com.smoa.backend.api.dto.CredentialListItem]).
|
||||
*/
|
||||
internal data class CredentialPullListItemDto(
|
||||
val credentialId: String,
|
||||
val holderId: String,
|
||||
val credentialType: String,
|
||||
val issuer: String?,
|
||||
val issuedAt: Long?,
|
||||
val expiresAt: Long?,
|
||||
val updatedAt: Long
|
||||
) {
|
||||
fun toEntityPreservingPayload(existingPayload: String?) = CredentialCacheEntity(
|
||||
credentialId = credentialId,
|
||||
holderId = holderId,
|
||||
credentialType = credentialType,
|
||||
issuer = issuer,
|
||||
issuedAt = issuedAt,
|
||||
expiresAt = expiresAt,
|
||||
payloadJson = existingPayload,
|
||||
updatedAt = updatedAt
|
||||
)
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.smoa.modules.credentials.domain
|
||||
|
||||
/**
|
||||
* Canonical `credentialType` strings for SMOA, aligned with Complete Credential
|
||||
* `CredentialRef.domain` and format-specific templates (ICAO 9303, AAMVA, MIL-STD-129).
|
||||
*
|
||||
* Keep literal values in sync with backend `SmoaCredentialType` and
|
||||
* `docs/reference/IDENTITY-TEMPLATE-ALIGNMENT.md`.
|
||||
*/
|
||||
object SmoaCredentialTemplateIds {
|
||||
|
||||
const val PIV_PKI = "piv_pki"
|
||||
const val PAYMENT = "payment"
|
||||
const val NFC_ACCESS = "nfc_access"
|
||||
const val MOBILE = "mobile"
|
||||
|
||||
const val ICAO9303_MRTD = "icao9303_mrtd"
|
||||
const val AAMVA_DLID = "aamva_dlid"
|
||||
const val MIL_STD_129 = "mil_std_129"
|
||||
const val AGENCY_BADGE = "agency_badge"
|
||||
|
||||
/** All canonical types (excludes legacy aliases). */
|
||||
val CANONICAL: Set<String> = setOf(
|
||||
PIV_PKI,
|
||||
PAYMENT,
|
||||
NFC_ACCESS,
|
||||
MOBILE,
|
||||
ICAO9303_MRTD,
|
||||
AAMVA_DLID,
|
||||
MIL_STD_129,
|
||||
AGENCY_BADGE
|
||||
)
|
||||
|
||||
/**
|
||||
* Optional payload key: mirrors Complete Credential OpenAPI `CredentialRef.domain`
|
||||
* (`payment` | `piv_pki` | `nfc_access` | `mobile`).
|
||||
*/
|
||||
const val PAYLOAD_KEY_CC_DOMAIN = "completeCredentialDomain"
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package com.smoa.modules.credentials.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.smoa.core.common.ConnectivityManager
|
||||
import com.smoa.core.common.PullAPI
|
||||
import com.smoa.core.common.Result
|
||||
import com.smoa.core.common.SyncItem
|
||||
import com.smoa.core.common.SyncItemType
|
||||
import com.smoa.core.common.SyncOperation
|
||||
import com.smoa.core.common.SyncService
|
||||
import com.smoa.core.common.sync.CredentialSyncRequestDto
|
||||
import com.smoa.modules.credentials.data.CredentialCacheDao
|
||||
import com.smoa.modules.credentials.data.CredentialCacheEntity
|
||||
import com.smoa.modules.credentials.data.CredentialCacheMerger
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class CredentialsViewModel @Inject constructor(
|
||||
private val dao: CredentialCacheDao,
|
||||
private val pullAPI: PullAPI,
|
||||
private val merger: CredentialCacheMerger,
|
||||
private val syncService: SyncService,
|
||||
private val connectivityManager: ConnectivityManager
|
||||
) : ViewModel() {
|
||||
|
||||
val credentials: StateFlow<List<CredentialCacheEntity>> = dao.observeAll()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
private val _selectedId = MutableStateFlow<String?>(null)
|
||||
val selectedId: StateFlow<String?> = _selectedId.asStateFlow()
|
||||
|
||||
fun selectCredential(id: String?) {
|
||||
_selectedId.value = id
|
||||
}
|
||||
|
||||
fun hydrateMissingPayloads() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
hydratePayloadsIfMissing()
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshFromServer() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
if (!connectivityManager.isOnline()) return@launch
|
||||
syncService.startSync()
|
||||
hydratePayloadsIfMissing()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun hydratePayloadsIfMissing() {
|
||||
if (!connectivityManager.isOnline()) return
|
||||
for (row in dao.getAll()) {
|
||||
if (row.payloadJson.isNullOrBlank()) {
|
||||
when (val r = pullAPI.pullCredentialDetail(row.credentialId)) {
|
||||
is Result.Success -> merger.mergeDetailJson(r.data)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun queueIssuance(dto: CredentialSyncRequestDto) {
|
||||
viewModelScope.launch {
|
||||
syncService.queueSync(
|
||||
SyncItem(
|
||||
id = dto.credentialId,
|
||||
type = SyncItemType.Credential,
|
||||
data = dto,
|
||||
operation = SyncOperation.Create
|
||||
)
|
||||
)
|
||||
if (connectivityManager.isOnline()) {
|
||||
syncService.startSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
package com.smoa.modules.credentials.data
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.smoa.core.common.sync.CredentialSyncRequestDto
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class CredentialCacheMergerTest {
|
||||
|
||||
private val dao = mockk<CredentialCacheDao>(relaxed = true)
|
||||
private val merger = CredentialCacheMerger(dao)
|
||||
|
||||
@Test
|
||||
fun `mergeFromPullCredentialsJson preserves existing payload`() = runTest {
|
||||
val json = """
|
||||
[{"credentialId":"c1","holderId":"h1","credentialType":"ID","issuer":"I",
|
||||
"issuedAt":1,"expiresAt":2,"updatedAt":99}]
|
||||
""".trimIndent().toByteArray(Charsets.UTF_8)
|
||||
val existing = CredentialCacheEntity(
|
||||
credentialId = "c1",
|
||||
holderId = "old",
|
||||
credentialType = "old",
|
||||
issuer = null,
|
||||
issuedAt = null,
|
||||
expiresAt = null,
|
||||
payloadJson = """{"k":"v"}""",
|
||||
updatedAt = 1L
|
||||
)
|
||||
coEvery { dao.getByCredentialId("c1") } returns existing
|
||||
|
||||
val slot = slot<CredentialCacheEntity>()
|
||||
merger.mergeFromPullCredentialsJson(json)
|
||||
|
||||
coVerify { dao.upsert(capture(slot)) }
|
||||
with(slot.captured) {
|
||||
assertEquals("c1", credentialId)
|
||||
assertEquals("h1", holderId)
|
||||
assertEquals("ID", credentialType)
|
||||
assertEquals(99L, updatedAt)
|
||||
assertEquals("""{"k":"v"}""", payloadJson)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mergeFromPullCredentialsJson inserts row when absent`() = runTest {
|
||||
val json = """
|
||||
[{"credentialId":"new","holderId":"h","credentialType":"T","issuer":null,
|
||||
"issuedAt":null,"expiresAt":null,"updatedAt":5}]
|
||||
""".trimIndent().toByteArray(Charsets.UTF_8)
|
||||
coEvery { dao.getByCredentialId("new") } returns null
|
||||
|
||||
val slot = slot<CredentialCacheEntity>()
|
||||
merger.mergeFromPullCredentialsJson(json)
|
||||
|
||||
coVerify { dao.upsert(capture(slot)) }
|
||||
with(slot.captured) {
|
||||
assertEquals("new", credentialId)
|
||||
assertNull(payloadJson)
|
||||
assertEquals(5L, updatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `upsertAfterCredentialSync writes payload and timestamps`() = runTest {
|
||||
val dto = CredentialSyncRequestDto(
|
||||
credentialId = "x",
|
||||
holderId = "h",
|
||||
credentialType = "T",
|
||||
issuer = "iss",
|
||||
issuedAt = 10L,
|
||||
expiresAt = 20L,
|
||||
payload = mapOf("a" to 1),
|
||||
clientUpdatedAt = null
|
||||
)
|
||||
coEvery { dao.getByCredentialId("x") } returns null
|
||||
|
||||
val slot = slot<CredentialCacheEntity>()
|
||||
merger.upsertAfterCredentialSync(dto, serverTimestamp = 555L, itemId = "x")
|
||||
|
||||
coVerify { dao.upsert(capture(slot)) }
|
||||
with(slot.captured) {
|
||||
assertEquals("x", credentialId)
|
||||
assertEquals(555L, updatedAt)
|
||||
val pj = payloadJson
|
||||
assertEquals(true, pj != null && pj.contains("\"a\"") && pj.contains("1"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removeCredential deletes by id`() = runTest {
|
||||
merger.removeCredential("id1")
|
||||
coVerify { dao.deleteById("id1") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restoreLocalCredentialAfterUseLocal upserts from DTO`() = runTest {
|
||||
val dto = CredentialSyncRequestDto(
|
||||
credentialId = "x",
|
||||
holderId = "h",
|
||||
credentialType = "ID_CARD",
|
||||
issuer = null,
|
||||
issuedAt = 1L,
|
||||
expiresAt = 2L,
|
||||
payload = mapOf("a" to 1),
|
||||
clientUpdatedAt = 42L
|
||||
)
|
||||
coEvery { dao.getByCredentialId("x") } returns null
|
||||
val slot = slot<CredentialCacheEntity>()
|
||||
|
||||
merger.restoreLocalCredentialAfterUseLocal(dto, itemId = "x")
|
||||
|
||||
coVerify { dao.upsert(capture(slot)) }
|
||||
assertEquals(42L, slot.captured.updatedAt)
|
||||
assertEquals("x", slot.captured.credentialId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mergeCredentialConflictRemoteData upserts server snapshot`() = runTest {
|
||||
val map = mapOf(
|
||||
"credentialId" to "c1",
|
||||
"holderId" to "h1",
|
||||
"credentialType" to "ID",
|
||||
"issuer" to null,
|
||||
"issuedAt" to 1L,
|
||||
"expiresAt" to 2L,
|
||||
"payloadJson" to """{"k":1}""",
|
||||
"updatedAt" to 9L
|
||||
)
|
||||
val json = Gson().toJson(map).toByteArray(Charsets.UTF_8)
|
||||
val slot = slot<CredentialCacheEntity>()
|
||||
|
||||
merger.mergeCredentialConflictRemoteData(json, itemId = "c1")
|
||||
|
||||
coVerify { dao.upsert(capture(slot)) }
|
||||
with(slot.captured) {
|
||||
assertEquals("c1", credentialId)
|
||||
assertEquals("h1", holderId)
|
||||
assertEquals("ID", credentialType)
|
||||
assertEquals(9L, updatedAt)
|
||||
val pj = payloadJson
|
||||
assertEquals(true, pj != null && pj.contains("k"))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user