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
+9 -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 {
@@ -55,4 +63,3 @@ dependencies {
implementation(Dependencies.coroutinesCore)
implementation(Dependencies.coroutinesAndroid)
}
@@ -0,0 +1,21 @@
package com.smoa.modules.atf.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface ATFFormDao {
@Query("SELECT * FROM atf_form_drafts WHERE id = :id")
suspend fun getById(id: String): ATFFormDraftEntity?
@Query("SELECT * FROM atf_form_drafts ORDER BY updatedAtEpochMs DESC")
suspend fun listAll(): List<ATFFormDraftEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(entity: ATFFormDraftEntity)
@Query("DELETE FROM atf_form_drafts WHERE id = :id")
suspend fun deleteById(id: String)
}
@@ -3,14 +3,11 @@ package com.smoa.modules.atf.data
import androidx.room.Database
import androidx.room.RoomDatabase
// TODO: Add entities when implementing storage
// Temporarily commented out to allow build to proceed
// @Database(
// entities = [],
// version = 1,
// exportSchema = false
// )
@Database(
entities = [ATFFormDraftEntity::class],
version = 1,
exportSchema = false
)
abstract class ATFFormDatabase : RoomDatabase() {
// DAOs will be added here
abstract fun atfFormDao(): ATFFormDao
}
@@ -0,0 +1,16 @@
package com.smoa.modules.atf.data
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Local draft storage for ATF forms (4473, 1, 4, etc.) as JSON payload until submitted.
*/
@Entity(tableName = "atf_form_drafts")
data class ATFFormDraftEntity(
@PrimaryKey val id: String,
/** e.g. FORM_4473, FORM_1, FORM_4 */
val formType: String,
val jsonPayload: String,
val updatedAtEpochMs: Long
)
@@ -54,7 +54,7 @@ class ATFService @Inject constructor(
)
}
// TODO: Integrate with ATF eTrace API (requires federal approval)
// Live eTrace requires federal approval and ATF-issued credentials; submission is simulated below.
// For now, simulate submission
val submissionResult = SubmissionResult(
submissionId = UUID.randomUUID().toString(),
+9 -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 {
@@ -52,4 +60,3 @@ dependencies {
testImplementation(Dependencies.coroutinesTest)
testImplementation(Dependencies.truth)
}
@@ -25,11 +25,12 @@ class BrowserService @Inject constructor(
* Navigate to URL with security checks.
*/
suspend fun navigateToURL(url: String): Result<String> {
// Enforce VPN requirement
try {
vpnManager.enforceVPNRequirement()
} catch (e: VPNRequiredException) {
return Result.failure(e)
if (vpnManager.isBrowserVPNEnforced()) {
try {
vpnManager.enforceVPNRequirement()
} catch (e: VPNRequiredException) {
return Result.failure(e)
}
}
// Check URL allow-list
@@ -13,8 +13,12 @@ class URLFilter @Inject constructor() {
private val allowedPaths = mutableMapOf<String, Set<String>>()
init {
// Default allow-list (can be configured via policy)
// Add default mission/agency resources here
// Demo / dev defaults — replace with policy-backed list in production
addAllowedDomain("example.com")
addAllowedDomain("www.wikipedia.org")
addAllowedDomain("developer.android.com")
addAllowedDomain("source.android.com")
addAllowedDomain("duckduckgo.com")
}
/**
@@ -1,22 +1,54 @@
package com.smoa.modules.browser.ui
import android.annotation.SuppressLint
import android.os.Build
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.smoa.core.security.ScreenProtection
import com.smoa.modules.browser.domain.BrowserService
import com.smoa.modules.browser.domain.URLFilter
import kotlinx.coroutines.launch
private const val DEFAULT_START_URL = "https://example.com"
/**
* Controlled browser screen with VPN enforcement and URL filtering.
*/
@SuppressLint("SetJavaScriptEnabled")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BrowserScreen(
@@ -25,19 +57,26 @@ fun BrowserScreen(
screenProtection: ScreenProtection,
modifier: Modifier = Modifier
) {
// Enable screen protection
screenProtection.EnableScreenProtection()
var currentURL by remember { mutableStateOf("") }
var urlInput by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf<String?>(null) }
var isLoading by remember { mutableStateOf(false) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
// WebView state
var webView by remember { mutableStateOf<WebView?>(null) }
// Navigate to URL
fun normalizeUrl(raw: String): String {
val trimmed = raw.trim()
if (trimmed.isEmpty()) return trimmed
return if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) {
"https://$trimmed"
} else {
trimmed
}
}
suspend fun navigateToURL(url: String) {
isLoading = true
errorMessage = null
@@ -45,14 +84,18 @@ fun BrowserScreen(
browserService.navigateToURL(url)
.onSuccess { allowedURL ->
currentURL = allowedURL
urlInput = allowedURL
isLoading = true
webView?.loadUrl(allowedURL)
}
.onFailure { error ->
errorMessage = error.message
}
.also {
isLoading = false
}
}
LaunchedEffect(webView) {
if (webView == null) return@LaunchedEffect
navigateToURL(DEFAULT_START_URL)
}
Column(
@@ -60,7 +103,38 @@ fun BrowserScreen(
.fillMaxSize()
.padding(8.dp)
) {
// URL bar
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = { if (webView?.canGoBack() == true) webView?.goBack() },
enabled = webView?.canGoBack() == true
) {
Icon(Icons.Filled.ArrowBack, contentDescription = "Back")
}
IconButton(
onClick = { if (webView?.canGoForward() == true) webView?.goForward() },
enabled = webView?.canGoForward() == true
) {
Icon(Icons.Filled.ArrowForward, contentDescription = "Forward")
}
IconButton(
onClick = {
val target = normalizeUrl(urlInput).ifBlank { currentURL }
if (target.isNotBlank()) {
scope.launch { navigateToURL(target) }
} else {
webView?.reload()
}
},
enabled = webView != null
) {
Icon(Icons.Default.Refresh, contentDescription = "Reload")
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -77,16 +151,9 @@ fun BrowserScreen(
Button(
onClick = {
if (urlInput.isNotBlank()) {
// Add https:// if no protocol specified
val url = if (!urlInput.startsWith("http://") && !urlInput.startsWith("https://")) {
"https://$urlInput"
} else {
urlInput
}
// Navigate (this would need to be in a coroutine scope)
// For now, just update the URL
currentURL = url
val url = normalizeUrl(urlInput)
if (url.isNotBlank()) {
scope.launch { navigateToURL(url) }
}
},
enabled = !isLoading && urlInput.isNotBlank()
@@ -95,9 +162,15 @@ fun BrowserScreen(
}
}
Text(
text = "Allowed: ${urlFilter.getAllowedDomains().joinToString()}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 4.dp)
)
Spacer(modifier = Modifier.height(8.dp))
// Error message
errorMessage?.let { error ->
Card(
colors = CardDefaults.cardColors(
@@ -114,13 +187,11 @@ fun BrowserScreen(
Spacer(modifier = Modifier.height(8.dp))
}
// Loading indicator
if (isLoading) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
Spacer(modifier = Modifier.height(8.dp))
}
// WebView
AndroidView(
factory = { ctx ->
WebView(ctx).apply {
@@ -133,19 +204,46 @@ fun BrowserScreen(
settings.displayZoomControls = false
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
// Check if URL is allowed before loading
url?.let {
if (!browserService.isURLAllowed(it)) {
errorMessage = "URL not in allow-list: $it"
return true // Block navigation
}
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
val url = request.url?.toString() ?: return false
if (!browserService.isURLAllowed(url)) {
errorMessage = "URL not in allow-list: $url"
return true
}
return false
}
@Deprecated("Deprecated in Java")
override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
return false
}
val u = url ?: return false
if (!browserService.isURLAllowed(u)) {
errorMessage = "URL not in allow-list: $u"
return true
}
return false
}
override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) {
isLoading = true
url?.let { urlInput = it }
}
override fun onPageFinished(view: WebView?, url: String?) {
isLoading = false
url?.let {
currentURL = it
urlInput = it
}
return false // Allow navigation
}
}
webView = this
}
},
update = { wv ->
if (webView !== wv) {
webView = wv
}
},
modifier = Modifier
@@ -154,4 +252,3 @@ fun BrowserScreen(
)
}
}
@@ -33,7 +33,7 @@ class BrowserServiceTest {
fun `navigateToURL should fail when VPN not connected`() = runTest {
// Given
val url = "https://example.com"
every { vpnManager.isVPNRequired() } returns true
every { vpnManager.isBrowserVPNEnforced() } returns true
every { vpnManager.isVPNConnected() } returns false
every { vpnManager.enforceVPNRequirement() } throws VPNRequiredException("VPN required")
@@ -49,8 +49,7 @@ class BrowserServiceTest {
fun `navigateToURL should fail when URL not in allow-list`() = runTest {
// Given
val url = "https://blocked.com"
every { vpnManager.isVPNRequired() } returns true
every { vpnManager.isVPNConnected() } returns true
every { vpnManager.isBrowserVPNEnforced() } returns false
every { urlFilter.isAllowed(url) } returns false
// When
@@ -65,8 +64,9 @@ class BrowserServiceTest {
fun `navigateToURL should succeed for allowed URL with VPN`() = runTest {
// Given
val url = "https://allowed.com"
every { vpnManager.isVPNRequired() } returns true
every { vpnManager.isBrowserVPNEnforced() } returns true
every { vpnManager.isVPNConnected() } returns true
every { vpnManager.enforceVPNRequirement() } returns Unit
every { urlFilter.isAllowed(url) } returns true
// When
+10 -3
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 {
@@ -49,9 +57,8 @@ dependencies {
implementation(Dependencies.retrofit)
implementation(Dependencies.okHttp)
// WebRTC - TODO: Configure WebRTC dependency
// Optional: implementation(Dependencies.webrtc) when using org.webrtc:google-webrtc AAR
// WebRTC library needs to be built from source or obtained separately
// Uncomment when WebRTC is available:
// implementation(Dependencies.webrtc)
}
@@ -12,7 +12,7 @@ import com.smoa.modules.communications.domain.MediaCodecPolicy
import com.smoa.modules.communications.domain.MediaRoutingPolicy
import com.smoa.modules.communications.domain.NetworkPathSelector
import com.smoa.modules.communications.domain.SmartRoutingService
import com.smoa.modules.communications.domain.StubConnectionQualityMonitor
import com.smoa.modules.communications.domain.NetworkEstimatesConnectionQualityMonitor
import com.smoa.modules.communications.domain.VoiceTransport
import com.smoa.modules.communications.domain.WebRTCManager
import dagger.Module
@@ -28,8 +28,8 @@ object CommunicationsModule {
@Provides
@Singleton
fun provideConnectionQualityMonitor(
stub: StubConnectionQualityMonitor
): ConnectionQualityMonitor = stub
networkEstimates: NetworkEstimatesConnectionQualityMonitor
): ConnectionQualityMonitor = networkEstimates
@Provides
@Singleton
@@ -13,8 +13,31 @@ class ChannelManager @Inject constructor() {
private val channels = mutableMapOf<String, Channel>()
init {
// Initialize default channels (can be loaded from policy/config)
// Example channels would be added here
// Demo channels until policy/backend supplies live definitions
registerChannel(
Channel(
id = "unit1-ops",
name = "Unit 1 Ops",
description = "Demo — replace with backend-fed channel list",
unitRestricted = true,
allowedUnits = setOf("Unit1"),
allowedRoles = setOf(RBACFramework.Role.OPERATOR, RBACFramework.Role.ADMIN)
)
)
registerChannel(
Channel(
id = "command-net",
name = "Command Net",
description = "Demo shared net",
unitRestricted = false,
allowedUnits = emptySet(),
allowedRoles = setOf(
RBACFramework.Role.OPERATOR,
RBACFramework.Role.VIEWER,
RBACFramework.Role.ADMIN
)
)
)
}
/**
@@ -0,0 +1,79 @@
package com.smoa.modules.communications.domain
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
/**
* Derives coarse [ConnectionQuality] from Android [NetworkCapabilities] link bandwidth
* (downstream kbps). RTT and packet loss stay unknown until WebRTC `getStats()` is integrated;
* call [applyWebRtcStats] from a future WebRTC-backed monitor to merge metrics.
*/
@Singleton
class NetworkEstimatesConnectionQualityMonitor @Inject constructor(
@ApplicationContext context: Context
) : ConnectionQualityMonitor {
private val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val _currentQuality = MutableStateFlow(snapshot(cm))
init {
cm.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() {
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
_currentQuality.value = fromCapabilities(networkCapabilities)
}
override fun onLost(network: Network) {
_currentQuality.value = ConnectionQuality(
estimatedBandwidthKbps = 0,
rttMs = -1,
packetLossFraction = -1f,
tier = ConnectionTier.MEDIUM
)
}
})
}
override val currentQuality: StateFlow<ConnectionQuality> = _currentQuality.asStateFlow()
override fun qualityUpdates(): Flow<ConnectionQuality> = currentQuality
/**
* Optional hook for WebRTC stats layer: overrides bandwidth/tier when RTT/loss known.
*/
fun applyWebRtcStats(estimatedBandwidthKbps: Int, rttMs: Int, packetLoss: Float) {
val tier = connectionTierFromBandwidth(estimatedBandwidthKbps, rttMs, packetLoss)
_currentQuality.value = ConnectionQuality(
estimatedBandwidthKbps = estimatedBandwidthKbps,
rttMs = rttMs,
packetLossFraction = packetLoss,
tier = tier
)
}
private fun snapshot(cm: ConnectivityManager): ConnectionQuality {
val n = cm.activeNetwork ?: return ConnectionQuality(0, -1, -1f, ConnectionTier.MEDIUM)
val caps = cm.getNetworkCapabilities(n) ?: return ConnectionQuality(0, -1, -1f, ConnectionTier.MEDIUM)
return fromCapabilities(caps)
}
private fun fromCapabilities(caps: NetworkCapabilities): ConnectionQuality {
val kbps = caps.linkDownstreamBandwidthKbps
val effective = kbps.coerceAtLeast(0)
val tier = connectionTierFromBandwidth(effective, -1, -1f)
return ConnectionQuality(
estimatedBandwidthKbps = effective,
rttMs = -1,
packetLossFraction = -1f,
tier = tier
)
}
}
@@ -10,7 +10,10 @@ import javax.inject.Singleton
/**
* WebRTC Manager for voice and video communication.
* Provides WebRTC peer connection management for Communications and Meetings modules.
*
* **Stub:** Uses in-memory [WebRTCPeerConnection] placeholders. For production, add the
* `org.webrtc:google-webrtc` (or vendor) AAR, then replace stubs with `PeerConnectionFactory`,
* `PeerConnection`, and `getStats()` feeding [NetworkEstimatesConnectionQualityMonitor.applyWebRtcStats].
*/
@Singleton
class WebRTCManager @Inject constructor(
@@ -36,14 +39,7 @@ class WebRTCManager @Inject constructor(
// Create peer connection configuration
val rtcConfig = createRTCConfiguration()
// TODO: Initialize actual WebRTC PeerConnection when library is fully integrated
// This would:
// 1. Initialize PeerConnectionFactory with options
// 2. Create PeerConnection with rtcConfig
// 3. Set up audio/video tracks based on isAudioOnly
// 4. Configure ICE candidates
// 5. Set up signaling channel
// STUB: create domain placeholder; swap for real PeerConnection when WebRTC AAR is linked.
val peerConnection = WebRTCPeerConnection(
channelId = channelId,
isAudioOnly = isAudioOnly,
@@ -106,12 +102,6 @@ class WebRTCManager @Inject constructor(
stopVideoTransmission(peerConnection)
}
// TODO: Close actual WebRTC PeerConnection when library is fully integrated
// This would:
// 1. Close peer connection
// 2. Release all tracks
// 3. Dispose of resources
peerConnections.remove(peerConnection.channelId)
_connectionState.value = WebRTCConnectionState.Disconnected
Result.Success(Unit)
@@ -125,15 +115,7 @@ class WebRTCManager @Inject constructor(
*/
suspend fun startAudioTransmission(peerConnection: WebRTCPeerConnection): Result<Unit> {
return try {
// TODO: Start audio capture when WebRTC library is fully integrated
// This would:
// 1. Create AudioSource with constraints
// 2. Create AudioTrack from source
// 3. Add track to peer connection's sender
// 4. Enable track
// 5. Start audio capture
peerConnection.isAudioActive = true
peerConnection.isAudioActive = true // STUB: wire AudioTrack when WebRTC is integrated
Result.Success(Unit)
} catch (e: Exception) {
Result.Error(e)
@@ -145,13 +127,6 @@ class WebRTCManager @Inject constructor(
*/
suspend fun stopAudioTransmission(peerConnection: WebRTCPeerConnection): Result<Unit> {
return try {
// TODO: Stop audio capture when WebRTC library is fully integrated
// This would:
// 1. Disable audio track
// 2. Remove track from peer connection sender
// 3. Stop track
// 4. Release audio source
peerConnection.isAudioActive = false
Result.Success(Unit)
} catch (e: Exception) {
@@ -168,15 +143,7 @@ class WebRTCManager @Inject constructor(
return Result.Error(IllegalStateException("Video not supported for audio-only connection"))
}
// TODO: Start video capture when WebRTC library is fully integrated
// This would:
// 1. Create VideoSource with camera constraints
// 2. Create VideoTrack from source
// 3. Add track to peer connection's sender
// 4. Enable track
// 5. Start camera capture
peerConnection.isVideoActive = true
peerConnection.isVideoActive = true // STUB: wire VideoTrack / CameraX when WebRTC is integrated
Result.Success(Unit)
} catch (e: Exception) {
Result.Error(e)
@@ -188,13 +155,6 @@ class WebRTCManager @Inject constructor(
*/
suspend fun stopVideoTransmission(peerConnection: WebRTCPeerConnection): Result<Unit> {
return try {
// TODO: Stop video capture when WebRTC library is fully integrated
// This would:
// 1. Disable video track
// 2. Remove track from peer connection sender
// 3. Stop track
// 4. Release video source and camera
peerConnection.isVideoActive = false
Result.Success(Unit)
} catch (e: Exception) {
@@ -212,8 +172,6 @@ data class WebRTCPeerConnection(
val config: RTCConfiguration,
var isAudioActive: Boolean = false,
var isVideoActive: Boolean = false
// TODO: Add actual WebRTC PeerConnection instance when library is integrated
// private val peerConnection: PeerConnection
)
/**
+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"))
}
}
}
+9 -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 {
@@ -59,4 +67,3 @@ dependencies {
testImplementation(Dependencies.coroutinesTest)
testImplementation(Dependencies.truth)
}
@@ -27,6 +27,7 @@ object DirectoryDatabaseModule {
"directory_database"
)
.openHelperFactory(factory)
.addCallback(DirectoryDemoSeedCallback)
.build()
}
@@ -0,0 +1,30 @@
package com.smoa.modules.directory.data
import androidx.room.RoomDatabase
import androidx.sqlite.db.SupportSQLiteDatabase
/**
* Inserts a few [DirectoryEntity] rows on first DB create so the UI has demo content
* before a backend sync is wired. Matches default dev session unit "Unit1" in [com.smoa.MainActivity].
*/
internal object DirectoryDemoSeedCallback : RoomDatabase.Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
val now = System.currentTimeMillis()
exec(
db,
"""
INSERT OR IGNORE INTO directory_entries
(id,name,title,unit,phoneNumber,extension,email,secureRoutingId,role,clearanceLevel,lastUpdated)
VALUES
('seed-1','Avery, R.','Watch Officer','Unit1','555-0100',NULL,'[email protected]',NULL,'OPERATOR','SECRET',$now),
('seed-2','Brooks, M.','Duty Supervisor','Unit1','555-0101','221','[email protected]',NULL,'SUPERVISOR','SECRET',$now),
('seed-3','Chen, L.','Comms Tech','Unit1','555-0102',NULL,'[email protected]',NULL,'OPERATOR','CONFIDENTIAL',$now)
""".trimIndent().replace("\n", " ")
)
}
private fun exec(db: SupportSQLiteDatabase, sql: String) {
db.execSQL(sql)
}
}
@@ -28,18 +28,9 @@ fun DirectoryListScreen(
var isLoading by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
// Observe directory entries
LaunchedEffect(userRole, userUnit) {
directoryService.observeDirectoryEntries(userRole, userUnit)
.collect { entries ->
directoryEntries = entries
}
}
// Search functionality
LaunchedEffect(searchQuery) {
// Single collector: parallel LaunchedEffects both collected the same Flow and raced updates.
LaunchedEffect(userRole, userUnit, searchQuery) {
if (searchQuery.isBlank()) {
// Show all entries when search is empty
directoryService.observeDirectoryEntries(userRole, userUnit)
.collect { entries ->
directoryEntries = entries
+9 -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 {
@@ -56,4 +64,3 @@ dependencies {
implementation(Dependencies.coroutinesCore)
implementation(Dependencies.coroutinesAndroid)
}
+9 -2
View File
@@ -2,7 +2,7 @@ plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("kotlin-kapt")
id("dagger.hilt.android.plugin")
id("com.google.dagger.hilt.android")
}
android {
@@ -29,6 +29,14 @@ android {
composeOptions {
kotlinCompilerExtensionVersion = "1.5.4"
}
hilt {
enableAggregatingTask = true
}
}
kapt {
correctErrorTypes = true
}
dependencies {
@@ -47,4 +55,3 @@ dependencies {
implementation(Dependencies.coroutinesCore)
implementation(Dependencies.coroutinesAndroid)
}
+9 -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 {
@@ -49,4 +57,3 @@ dependencies {
implementation(Dependencies.coroutinesCore)
implementation(Dependencies.coroutinesAndroid)
}
+10 -3
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 {
@@ -50,9 +58,8 @@ dependencies {
implementation(Dependencies.retrofit)
implementation(Dependencies.okHttp)
// WebRTC - TODO: Configure WebRTC dependency
// Optional: WebRTC AAR via :modules:communications or shared artifact when integrating PeerConnection
// WebRTC library needs to be built from source or obtained separately
// Uncomment when WebRTC is available:
// implementation(Dependencies.webrtc)
}
@@ -14,6 +14,20 @@ import javax.inject.Singleton
class MeetingRoomManager @Inject constructor() {
private val meetings = mutableMapOf<String, MeetingRoom>()
init {
val demo = MeetingRoom(
id = "demo-briefing",
name = "Daily briefing (demo)",
description = "Sample room — backend can publish live meetings here",
hostId = "system",
allowedRoles = setOf(RBACFramework.Role.OPERATOR, RBACFramework.Role.ADMIN),
allowScreenSharing = true,
allowFileTransfer = false,
allowExternalParticipants = false
)
meetings[demo.id] = demo
}
/**
* Get meeting by ID.
*/
@@ -102,13 +102,7 @@ class VideoTransport @Inject constructor(
*/
suspend fun toggleScreenSharing(enabled: Boolean): Result<Unit> {
return try {
// TODO: Implement actual screen sharing
// This would:
// 1. Create screen capture source
// 2. Create video track from screen capture
// 3. Add/replace video track in peer connection
// 4. Start/stop screen capture
// STUB: MediaProjection + WebRTC screen capture track not wired; toggles local flag only.
isScreenSharing = enabled
Result.Success(Unit)
} catch (e: Exception) {
@@ -121,7 +115,7 @@ class VideoTransport @Inject constructor(
*/
suspend fun toggleFileTransfer(enabled: Boolean): Result<Unit> {
return try {
// TODO: Implement actual file transfer capability
// STUB: Data channel / SCTP file transfer not wired; toggles local flag only.
isFileTransferEnabled = enabled
Result.Success(Unit)
} catch (e: Exception) {
+9 -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 {
@@ -48,4 +56,3 @@ dependencies {
implementation(Dependencies.coroutinesCore)
implementation(Dependencies.coroutinesAndroid)
}
+13 -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 {
@@ -44,6 +52,10 @@ dependencies {
implementation(Dependencies.hiltAndroid)
kapt(Dependencies.hiltAndroidCompiler)
implementation(Dependencies.roomRuntime)
implementation(Dependencies.roomKtx)
kapt(Dependencies.roomCompiler)
implementation(Dependencies.retrofit)
implementation(Dependencies.okHttp)
implementation(Dependencies.retrofitGson)
@@ -51,4 +63,3 @@ dependencies {
implementation(Dependencies.coroutinesCore)
implementation(Dependencies.coroutinesAndroid)
}
@@ -0,0 +1,18 @@
package com.smoa.modules.ncic.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface NCICQueryDao {
@Query("SELECT * FROM ncic_query_log WHERE queryId = :queryId LIMIT 1")
suspend fun getByQueryId(queryId: String): NCICQueryLogEntity?
@Query("SELECT * FROM ncic_query_log ORDER BY createdAtEpochMs DESC LIMIT :limit")
suspend fun recent(limit: Int = 100): List<NCICQueryLogEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(entity: NCICQueryLogEntity)
}
@@ -1,17 +1,13 @@
package com.smoa.modules.ncic.data
// import androidx.room.Database
// import androidx.room.RoomDatabase
// TODO: Add entities when implementing storage
// Temporarily commented out to allow build to proceed
// @Database(
// entities = [],
// version = 1,
// exportSchema = false
// )
// Temporarily commented out - will be re-enabled when entities are added
// abstract class NCICQueryDatabase : RoomDatabase() {
// // DAOs will be added here
// }
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(
entities = [NCICQueryLogEntity::class],
version = 1,
exportSchema = false
)
abstract class NCICQueryDatabase : RoomDatabase() {
abstract fun ncicQueryDao(): NCICQueryDao
}
@@ -0,0 +1,21 @@
package com.smoa.modules.ncic.data
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Local audit/history row for NCIC/III queries (no live CJIS API; stores simulated or future responses).
*/
@Entity(tableName = "ncic_query_log")
data class NCICQueryLogEntity(
@PrimaryKey val id: String,
val queryId: String,
val ori: String,
val ucn: String,
val queryType: String,
val responseCode: String,
val message: String?,
/** Optional JSON snapshot of response for offline review */
val rawPayloadJson: String?,
val createdAtEpochMs: Long
)
@@ -35,8 +35,7 @@ class NCICService @Inject constructor(
return Result.failure(IllegalArgumentException("Invalid UCN format"))
}
// TODO: Integrate with NCIC API (requires CJIS approval)
// For now, simulate response
// Live NCIC/III requires CJIS approval and agency endpoint; simulated response below.
val response = NCICResponse(
queryId = query.queryId,
responseCode = NCICResponseCode.NO_HIT,
+9 -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 {
@@ -56,4 +64,3 @@ dependencies {
implementation(Dependencies.coroutinesCore)
implementation(Dependencies.coroutinesAndroid)
}
+9 -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 {
@@ -62,4 +70,3 @@ dependencies {
implementation(Dependencies.coroutinesCore)
implementation(Dependencies.coroutinesAndroid)
}