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:
defiQUG
2026-03-23 20:19:24 -07:00
parent f97e23592c
commit a2dc194a49
234 changed files with 10008 additions and 1149 deletions
+19 -2
View File
@@ -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)
}
@@ -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") }
}
)
}
@@ -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()
}
@@ -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
}
@@ -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()
}
@@ -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
)
@@ -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)
}
}
@@ -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
}
@@ -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
)
@@ -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
)
}
@@ -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"
}
@@ -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()
}
}
}
}
@@ -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"))
}
}
}