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 {
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+25
-2
@@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+79
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+7
-49
@@ -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
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -27,6 +27,7 @@ object DirectoryDatabaseModule {
|
||||
"directory_database"
|
||||
)
|
||||
.openHelperFactory(factory)
|
||||
.addCallback(DirectoryDemoSeedCallback)
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
+30
@@ -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)
|
||||
}
|
||||
}
|
||||
+2
-11
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user