Initial commit
This commit is contained in:
58
modules/atf/build.gradle.kts
Normal file
58
modules/atf/build.gradle.kts
Normal file
@@ -0,0 +1,58 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("kotlin-kapt")
|
||||
id("dagger.hilt.android.plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.smoa.modules.atf"
|
||||
compileSdk = AppConfig.compileSdk
|
||||
|
||||
defaultConfig {
|
||||
minSdk = AppConfig.minSdk
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:common"))
|
||||
implementation(project(":core:auth"))
|
||||
implementation(project(":core:security"))
|
||||
|
||||
implementation(platform(Dependencies.composeBom))
|
||||
implementation(Dependencies.composeUi)
|
||||
implementation(Dependencies.composeMaterial3)
|
||||
implementation(Dependencies.androidxCoreKtx)
|
||||
|
||||
implementation(Dependencies.hiltAndroid)
|
||||
kapt(Dependencies.hiltAndroidCompiler)
|
||||
|
||||
implementation(Dependencies.roomRuntime)
|
||||
implementation(Dependencies.roomKtx)
|
||||
kapt(Dependencies.roomCompiler)
|
||||
|
||||
implementation(Dependencies.retrofit)
|
||||
implementation(Dependencies.okHttp)
|
||||
implementation(Dependencies.retrofitGson)
|
||||
|
||||
implementation(Dependencies.coroutinesCore)
|
||||
implementation(Dependencies.coroutinesAndroid)
|
||||
}
|
||||
|
||||
25
modules/atf/src/main/java/com/smoa/modules/atf/ATFModule.kt
Normal file
25
modules/atf/src/main/java/com/smoa/modules/atf/ATFModule.kt
Normal file
@@ -0,0 +1,25 @@
|
||||
package com.smoa.modules.atf
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun ATFModule(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "ATF Forms",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
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
|
||||
// )
|
||||
abstract class ATFFormDatabase : RoomDatabase() {
|
||||
// DAOs will be added here
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.smoa.modules.atf.domain
|
||||
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* ATF Form 1 - Application to Make and Register a Firearm
|
||||
*/
|
||||
data class ATFForm1(
|
||||
val formId: String,
|
||||
val applicationDate: Date,
|
||||
val applicantInfo: PersonInfo,
|
||||
val firearmType: String,
|
||||
val firearmDescription: String,
|
||||
val serialNumber: String?,
|
||||
val caliber: String,
|
||||
val barrelLength: String,
|
||||
val overallLength: String,
|
||||
val purpose: String,
|
||||
val signatures: List<DigitalSignature>,
|
||||
val status: FormStatus
|
||||
)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.smoa.modules.atf.domain
|
||||
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* ATF Form 4 - Application for Tax Paid Transfer and Registration
|
||||
*/
|
||||
data class ATFForm4(
|
||||
val formId: String,
|
||||
val applicationDate: Date,
|
||||
val transferorInfo: PersonInfo,
|
||||
val transfereeInfo: PersonInfo,
|
||||
val firearmDescription: String,
|
||||
val serialNumber: String,
|
||||
val manufacturer: String,
|
||||
val model: String,
|
||||
val caliber: String,
|
||||
val transferType: TransferType,
|
||||
val signatures: List<DigitalSignature>,
|
||||
val status: FormStatus
|
||||
)
|
||||
|
||||
enum class TransferType {
|
||||
INDIVIDUAL,
|
||||
TRUST,
|
||||
CORPORATION,
|
||||
PARTNERSHIP
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.smoa.modules.atf.domain
|
||||
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* ATF Form 4473 - Firearms Transaction Record
|
||||
*/
|
||||
data class ATFForm4473(
|
||||
val formId: String,
|
||||
val transactionDate: Date,
|
||||
val firearmManufacturer: String,
|
||||
val firearmModel: String,
|
||||
val firearmSerialNumber: String,
|
||||
val firearmCaliber: String,
|
||||
val firearmType: FirearmType,
|
||||
val transfereeInfo: PersonInfo,
|
||||
val transferorInfo: PersonInfo,
|
||||
val nicsCheckNumber: String?,
|
||||
val nicsCheckDate: Date?,
|
||||
val signatures: List<DigitalSignature>,
|
||||
val status: FormStatus
|
||||
)
|
||||
|
||||
enum class FirearmType {
|
||||
HANDGUN,
|
||||
RIFLE,
|
||||
SHOTGUN,
|
||||
OTHER
|
||||
}
|
||||
|
||||
enum class FormStatus {
|
||||
DRAFT,
|
||||
SUBMITTED,
|
||||
APPROVED,
|
||||
REJECTED,
|
||||
PENDING
|
||||
}
|
||||
|
||||
data class PersonInfo(
|
||||
val name: String,
|
||||
val address: String,
|
||||
val city: String,
|
||||
val state: String,
|
||||
val zipCode: String,
|
||||
val dateOfBirth: Date,
|
||||
val socialSecurityNumber: String? // Last 4 digits or full
|
||||
)
|
||||
|
||||
data class DigitalSignature(
|
||||
val signatureId: String,
|
||||
val signerId: String,
|
||||
val signatureDate: Date,
|
||||
val signatureData: ByteArray
|
||||
)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.smoa.modules.atf.domain
|
||||
|
||||
import com.smoa.core.security.AuditLogger
|
||||
import com.smoa.core.security.AuditEventType
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* ATF Form service for form validation and submission.
|
||||
*/
|
||||
@Singleton
|
||||
class ATFService @Inject constructor(
|
||||
private val auditLogger: AuditLogger
|
||||
) {
|
||||
|
||||
/**
|
||||
* Validate Form 4473 data.
|
||||
*/
|
||||
fun validateForm4473(form: ATFForm4473): ValidationResult {
|
||||
val errors = mutableListOf<String>()
|
||||
|
||||
if (form.firearmSerialNumber.isBlank()) {
|
||||
errors.add("Firearm serial number is required")
|
||||
}
|
||||
|
||||
if (form.transfereeInfo.name.isBlank()) {
|
||||
errors.add("Transferee name is required")
|
||||
}
|
||||
|
||||
if (form.transfereeInfo.dateOfBirth.after(Date())) {
|
||||
errors.add("Date of birth cannot be in the future")
|
||||
}
|
||||
|
||||
return if (errors.isEmpty()) {
|
||||
ValidationResult.Success
|
||||
} else {
|
||||
ValidationResult.Failure(errors)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit Form 4473 to ATF eTrace system.
|
||||
* Note: Requires federal API approval and OAuth 2.0 authentication.
|
||||
*/
|
||||
suspend fun submitForm4473(form: ATFForm4473): Result<SubmissionResult> {
|
||||
return try {
|
||||
// Validate form
|
||||
val validation = validateForm4473(form)
|
||||
if (validation is ValidationResult.Failure) {
|
||||
return Result.failure(
|
||||
IllegalArgumentException("Form validation failed: ${validation.errors.joinToString()}")
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: Integrate with ATF eTrace API (requires federal approval)
|
||||
// For now, simulate submission
|
||||
val submissionResult = SubmissionResult(
|
||||
submissionId = UUID.randomUUID().toString(),
|
||||
formId = form.formId,
|
||||
status = SubmissionStatus.SUBMITTED,
|
||||
submittedAt = Date()
|
||||
)
|
||||
|
||||
auditLogger.logEvent(
|
||||
AuditEventType.POLICY_UPDATE,
|
||||
userId = form.transfereeInfo.name,
|
||||
module = "atf",
|
||||
details = "ATF Form 4473 submitted: ${form.formId}"
|
||||
)
|
||||
|
||||
Result.success(submissionResult)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class ValidationResult {
|
||||
object Success : ValidationResult()
|
||||
data class Failure(val errors: List<String>) : ValidationResult()
|
||||
}
|
||||
|
||||
data class SubmissionResult(
|
||||
val submissionId: String,
|
||||
val formId: String,
|
||||
val status: SubmissionStatus,
|
||||
val submittedAt: Date
|
||||
)
|
||||
|
||||
enum class SubmissionStatus {
|
||||
SUBMITTED,
|
||||
PENDING,
|
||||
APPROVED,
|
||||
REJECTED
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.smoa.modules.atf.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun ATFForm4473Screen(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "ATF Form 4473",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
55
modules/browser/build.gradle.kts
Normal file
55
modules/browser/build.gradle.kts
Normal file
@@ -0,0 +1,55 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("kotlin-kapt")
|
||||
id("dagger.hilt.android.plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.smoa.modules.browser"
|
||||
compileSdk = AppConfig.compileSdk
|
||||
|
||||
defaultConfig {
|
||||
minSdk = AppConfig.minSdk
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:common"))
|
||||
implementation(project(":core:auth"))
|
||||
implementation(project(":core:security"))
|
||||
|
||||
implementation(platform(Dependencies.composeBom))
|
||||
implementation(Dependencies.composeUi)
|
||||
implementation(Dependencies.composeUiGraphics)
|
||||
implementation(Dependencies.composeMaterial3)
|
||||
implementation(Dependencies.androidxCoreKtx)
|
||||
implementation(Dependencies.androidxLifecycleRuntimeKtx)
|
||||
|
||||
implementation(Dependencies.hiltAndroid)
|
||||
kapt(Dependencies.hiltAndroidCompiler)
|
||||
|
||||
// Testing
|
||||
testImplementation(Dependencies.junit)
|
||||
testImplementation(Dependencies.mockk)
|
||||
testImplementation(Dependencies.coroutinesTest)
|
||||
testImplementation(Dependencies.truth)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.smoa.modules.browser
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.smoa.core.security.ScreenProtection
|
||||
import com.smoa.modules.browser.domain.BrowserService
|
||||
import com.smoa.modules.browser.domain.URLFilter
|
||||
import com.smoa.modules.browser.ui.BrowserScreen
|
||||
|
||||
/**
|
||||
* Browser module - Secure access to designated mission or agency web resources.
|
||||
*/
|
||||
@Composable
|
||||
fun BrowserModule(
|
||||
browserService: BrowserService,
|
||||
urlFilter: URLFilter,
|
||||
screenProtection: ScreenProtection,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
BrowserScreen(
|
||||
browserService = browserService,
|
||||
urlFilter = urlFilter,
|
||||
screenProtection = screenProtection,
|
||||
modifier = modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.smoa.modules.browser.di
|
||||
|
||||
import com.smoa.core.security.ScreenProtection
|
||||
import com.smoa.core.security.VPNManager
|
||||
import com.smoa.modules.browser.domain.BrowserService
|
||||
import com.smoa.modules.browser.domain.URLFilter
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object BrowserModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideURLFilter(): URLFilter {
|
||||
return URLFilter()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideBrowserService(
|
||||
vpnManager: VPNManager,
|
||||
urlFilter: URLFilter
|
||||
): BrowserService {
|
||||
return BrowserService(vpnManager, urlFilter)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.smoa.modules.browser.domain
|
||||
|
||||
import com.smoa.core.security.VPNManager
|
||||
import com.smoa.core.security.VPNRequiredException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Browser service for controlled web browsing.
|
||||
* Enforces VPN requirement and URL allow-list.
|
||||
*/
|
||||
@Singleton
|
||||
class BrowserService @Inject constructor(
|
||||
private val vpnManager: VPNManager,
|
||||
private val urlFilter: URLFilter
|
||||
) {
|
||||
/**
|
||||
* Check if URL is allowed.
|
||||
*/
|
||||
fun isURLAllowed(url: String): Boolean {
|
||||
return urlFilter.isAllowed(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
||||
// Check URL allow-list
|
||||
if (!isURLAllowed(url)) {
|
||||
return Result.failure(SecurityException("URL not in allow-list: $url"))
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
if (!isValidURL(url)) {
|
||||
return Result.failure(IllegalArgumentException("Invalid URL format: $url"))
|
||||
}
|
||||
|
||||
return Result.success(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate URL format.
|
||||
*/
|
||||
private fun isValidURL(url: String): Boolean {
|
||||
return try {
|
||||
java.net.URL(url)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if download is allowed.
|
||||
*/
|
||||
fun isDownloadAllowed(): Boolean {
|
||||
// Downloads can be controlled by policy
|
||||
// For now, downloads are disabled by default
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if external app sharing is allowed.
|
||||
*/
|
||||
fun isExternalSharingAllowed(): Boolean {
|
||||
// External sharing is disabled by default per spec
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.smoa.modules.browser.domain
|
||||
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* URL filter for allow-list management.
|
||||
* Restricts browser to designated mission or agency web resources.
|
||||
*/
|
||||
@Singleton
|
||||
class URLFilter @Inject constructor() {
|
||||
private val allowedDomains = mutableSetOf<String>()
|
||||
private val allowedPaths = mutableMapOf<String, Set<String>>()
|
||||
|
||||
init {
|
||||
// Default allow-list (can be configured via policy)
|
||||
// Add default mission/agency resources here
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if URL is allowed.
|
||||
*/
|
||||
fun isAllowed(url: String): Boolean {
|
||||
return try {
|
||||
val urlObj = java.net.URL(url)
|
||||
val host = urlObj.host
|
||||
val path = urlObj.path
|
||||
|
||||
// Check if domain is allowed
|
||||
if (!isDomainAllowed(host)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if path is allowed for this domain
|
||||
if (!isPathAllowed(host, path)) {
|
||||
return false
|
||||
}
|
||||
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if domain is allowed.
|
||||
*/
|
||||
private fun isDomainAllowed(host: String): Boolean {
|
||||
// Check exact match
|
||||
if (allowedDomains.contains(host)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check subdomain match
|
||||
return allowedDomains.any { allowedDomain ->
|
||||
host.endsWith(".$allowedDomain") || host == allowedDomain
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if path is allowed for domain.
|
||||
*/
|
||||
private fun isPathAllowed(host: String, path: String): Boolean {
|
||||
val allowedPathsForDomain = allowedPaths[host]
|
||||
|
||||
// If no path restrictions for this domain, allow all paths
|
||||
if (allowedPathsForDomain == null || allowedPathsForDomain.isEmpty()) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if path matches any allowed path
|
||||
return allowedPathsForDomain.any { allowedPath ->
|
||||
path.startsWith(allowedPath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add allowed domain.
|
||||
*/
|
||||
fun addAllowedDomain(domain: String) {
|
||||
allowedDomains.add(domain)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove allowed domain.
|
||||
*/
|
||||
fun removeAllowedDomain(domain: String) {
|
||||
allowedDomains.remove(domain)
|
||||
allowedPaths.remove(domain)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add allowed path for domain.
|
||||
*/
|
||||
fun addAllowedPath(domain: String, path: String) {
|
||||
val paths = allowedPaths.getOrPut(domain) { mutableSetOf() } as MutableSet
|
||||
paths.add(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all allowed domains.
|
||||
*/
|
||||
fun getAllowedDomains(): Set<String> {
|
||||
return allowedDomains.toSet()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all allowed domains and paths.
|
||||
*/
|
||||
fun clear() {
|
||||
allowedDomains.clear()
|
||||
allowedPaths.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.smoa.modules.browser.ui
|
||||
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
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
|
||||
|
||||
/**
|
||||
* Controlled browser screen with VPN enforcement and URL filtering.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BrowserScreen(
|
||||
browserService: BrowserService,
|
||||
urlFilter: URLFilter,
|
||||
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
|
||||
|
||||
// WebView state
|
||||
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||
|
||||
// Navigate to URL
|
||||
suspend fun navigateToURL(url: String) {
|
||||
isLoading = true
|
||||
errorMessage = null
|
||||
|
||||
browserService.navigateToURL(url)
|
||||
.onSuccess { allowedURL ->
|
||||
currentURL = allowedURL
|
||||
webView?.loadUrl(allowedURL)
|
||||
}
|
||||
.onFailure { error ->
|
||||
errorMessage = error.message
|
||||
}
|
||||
.also {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(8.dp)
|
||||
) {
|
||||
// URL bar
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = urlInput,
|
||||
onValueChange = { urlInput = it },
|
||||
label = { Text("URL") },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
enabled = !isLoading
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
},
|
||||
enabled = !isLoading && urlInput.isNotBlank()
|
||||
) {
|
||||
Text("Go")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Error message
|
||||
errorMessage?.let { error ->
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
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 {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.allowFileAccess = false
|
||||
settings.allowContentAccess = false
|
||||
settings.setSupportZoom(true)
|
||||
settings.builtInZoomControls = false
|
||||
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
|
||||
}
|
||||
}
|
||||
return false // Allow navigation
|
||||
}
|
||||
}
|
||||
|
||||
webView = this
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.smoa.modules.browser.domain
|
||||
|
||||
import com.smoa.core.security.VPNManager
|
||||
import com.smoa.core.security.VPNRequiredException
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for BrowserService.
|
||||
*/
|
||||
class BrowserServiceTest {
|
||||
private val vpnManager = mockk<VPNManager>(relaxed = true)
|
||||
private val urlFilter = mockk<URLFilter>(relaxed = true)
|
||||
private val browserService = BrowserService(vpnManager, urlFilter)
|
||||
|
||||
@Test
|
||||
fun `isURLAllowed should delegate to URLFilter`() {
|
||||
// Given
|
||||
val url = "https://example.com"
|
||||
every { urlFilter.isAllowed(url) } returns true
|
||||
|
||||
// When
|
||||
val result = browserService.isURLAllowed(url)
|
||||
|
||||
// Then
|
||||
assertTrue(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `navigateToURL should fail when VPN not connected`() = runTest {
|
||||
// Given
|
||||
val url = "https://example.com"
|
||||
every { vpnManager.isVPNRequired() } returns true
|
||||
every { vpnManager.isVPNConnected() } returns false
|
||||
every { vpnManager.enforceVPNRequirement() } throws VPNRequiredException("VPN required")
|
||||
|
||||
// When
|
||||
val result = browserService.navigateToURL(url)
|
||||
|
||||
// Then
|
||||
assertTrue(result.isFailure)
|
||||
assertTrue(result.exceptionOrNull() is VPNRequiredException)
|
||||
}
|
||||
|
||||
@Test
|
||||
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 { urlFilter.isAllowed(url) } returns false
|
||||
|
||||
// When
|
||||
val result = browserService.navigateToURL(url)
|
||||
|
||||
// Then
|
||||
assertTrue(result.isFailure)
|
||||
assertTrue(result.exceptionOrNull() is SecurityException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `navigateToURL should succeed for allowed URL with VPN`() = runTest {
|
||||
// Given
|
||||
val url = "https://allowed.com"
|
||||
every { vpnManager.isVPNRequired() } returns true
|
||||
every { vpnManager.isVPNConnected() } returns true
|
||||
every { urlFilter.isAllowed(url) } returns true
|
||||
|
||||
// When
|
||||
val result = browserService.navigateToURL(url)
|
||||
|
||||
// Then
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals(url, result.getOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isDownloadAllowed should return false by default`() {
|
||||
// When
|
||||
val result = browserService.isDownloadAllowed()
|
||||
|
||||
// Then
|
||||
assertFalse(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isExternalSharingAllowed should return false by default`() {
|
||||
// When
|
||||
val result = browserService.isExternalSharingAllowed()
|
||||
|
||||
// Then
|
||||
assertFalse(result)
|
||||
}
|
||||
}
|
||||
|
||||
57
modules/communications/build.gradle.kts
Normal file
57
modules/communications/build.gradle.kts
Normal file
@@ -0,0 +1,57 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("kotlin-kapt")
|
||||
id("dagger.hilt.android.plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.smoa.modules.communications"
|
||||
compileSdk = AppConfig.compileSdk
|
||||
|
||||
defaultConfig {
|
||||
minSdk = AppConfig.minSdk
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:common"))
|
||||
implementation(project(":core:auth"))
|
||||
implementation(project(":core:security"))
|
||||
|
||||
implementation(platform(Dependencies.composeBom))
|
||||
implementation(Dependencies.composeUi)
|
||||
implementation(Dependencies.composeUiGraphics)
|
||||
implementation(Dependencies.composeMaterial3)
|
||||
implementation(Dependencies.androidxCoreKtx)
|
||||
implementation(Dependencies.androidxLifecycleRuntimeKtx)
|
||||
|
||||
implementation(Dependencies.hiltAndroid)
|
||||
kapt(Dependencies.hiltAndroidCompiler)
|
||||
|
||||
implementation(Dependencies.retrofit)
|
||||
implementation(Dependencies.okHttp)
|
||||
|
||||
// WebRTC - TODO: Configure WebRTC dependency
|
||||
// WebRTC library needs to be built from source or obtained separately
|
||||
// Uncomment when WebRTC is available:
|
||||
// implementation(Dependencies.webrtc)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.smoa.modules.communications
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.smoa.core.auth.RBACFramework
|
||||
import com.smoa.modules.communications.domain.CommunicationsService
|
||||
import com.smoa.modules.communications.ui.CommunicationsScreen
|
||||
|
||||
/**
|
||||
* Communications module - Mission voice communications using channelized, unit-based access.
|
||||
*/
|
||||
@Composable
|
||||
fun CommunicationsModule(
|
||||
communicationsService: CommunicationsService,
|
||||
userRole: RBACFramework.Role,
|
||||
userUnit: String?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
CommunicationsScreen(
|
||||
communicationsService = communicationsService,
|
||||
userRole = userRole,
|
||||
userUnit = userUnit,
|
||||
modifier = modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.smoa.modules.communications.di
|
||||
|
||||
import android.content.Context
|
||||
import com.smoa.core.security.AuditLogger
|
||||
import com.smoa.modules.communications.domain.ChannelManager
|
||||
import com.smoa.modules.communications.domain.CommunicationsService
|
||||
import com.smoa.modules.communications.domain.VoiceTransport
|
||||
import com.smoa.modules.communications.domain.WebRTCManager
|
||||
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 CommunicationsModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideWebRTCManager(
|
||||
@ApplicationContext context: Context
|
||||
): WebRTCManager {
|
||||
return WebRTCManager(context)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideChannelManager(): ChannelManager {
|
||||
return ChannelManager()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideVoiceTransport(
|
||||
webRTCManager: WebRTCManager
|
||||
): VoiceTransport {
|
||||
return VoiceTransport(webRTCManager)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCommunicationsService(
|
||||
channelManager: ChannelManager,
|
||||
voiceTransport: VoiceTransport,
|
||||
auditLogger: AuditLogger,
|
||||
rbacFramework: com.smoa.core.auth.RBACFramework
|
||||
): CommunicationsService {
|
||||
return CommunicationsService(
|
||||
channelManager,
|
||||
voiceTransport,
|
||||
auditLogger,
|
||||
rbacFramework
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.smoa.modules.communications.domain
|
||||
|
||||
import com.smoa.core.auth.RBACFramework
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Channel manager for communication channels.
|
||||
* Manages channel access based on role and unit authorization.
|
||||
*/
|
||||
@Singleton
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Get channel by ID.
|
||||
*/
|
||||
fun getChannel(channelId: String): Channel? {
|
||||
return channels[channelId]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available channels for user based on role and unit.
|
||||
*/
|
||||
fun getAvailableChannels(
|
||||
userRole: RBACFramework.Role,
|
||||
userUnit: String?
|
||||
): List<Channel> {
|
||||
return channels.values.filter { channel ->
|
||||
hasAccess(channel, userRole, userUnit)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has access to channel.
|
||||
*/
|
||||
fun hasAccess(
|
||||
channel: Channel,
|
||||
userRole: RBACFramework.Role,
|
||||
userUnit: String?
|
||||
): Boolean {
|
||||
// Admins can access all channels
|
||||
if (userRole == RBACFramework.Role.ADMIN) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check unit authorization
|
||||
if (channel.unitRestricted && userUnit != null && channel.allowedUnits.contains(userUnit)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check role authorization
|
||||
if (channel.allowedRoles.contains(userRole)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a channel.
|
||||
*/
|
||||
fun registerChannel(channel: Channel) {
|
||||
channels[channel.id] = channel
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a channel.
|
||||
*/
|
||||
fun removeChannel(channelId: String) {
|
||||
channels.remove(channelId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Communication channel.
|
||||
*/
|
||||
data class Channel(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String?,
|
||||
val unitRestricted: Boolean,
|
||||
val allowedUnits: Set<String>,
|
||||
val allowedRoles: Set<RBACFramework.Role>,
|
||||
val priority: ChannelPriority = ChannelPriority.NORMAL,
|
||||
val encrypted: Boolean = true
|
||||
)
|
||||
|
||||
/**
|
||||
* Channel priority levels.
|
||||
*/
|
||||
enum class ChannelPriority {
|
||||
LOW,
|
||||
NORMAL,
|
||||
HIGH,
|
||||
ALERT
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.smoa.modules.communications.domain
|
||||
|
||||
import com.smoa.core.auth.RBACFramework
|
||||
import com.smoa.core.common.Result
|
||||
import com.smoa.core.security.AuditLogger
|
||||
import com.smoa.core.security.AuditEventType
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Communications service for radio-style voice communications.
|
||||
* Supports multi-channel push-to-talk (PTT) with encrypted voice transport.
|
||||
*/
|
||||
@Singleton
|
||||
class CommunicationsService @Inject constructor(
|
||||
private val channelManager: ChannelManager,
|
||||
private val voiceTransport: VoiceTransport,
|
||||
private val auditLogger: AuditLogger,
|
||||
private val rbacFramework: RBACFramework
|
||||
) {
|
||||
private val _currentChannel = MutableStateFlow<Channel?>(null)
|
||||
val currentChannel: StateFlow<Channel?> = _currentChannel.asStateFlow()
|
||||
|
||||
private val _isPTTActive = MutableStateFlow(false)
|
||||
val isPTTActive: StateFlow<Boolean> = _isPTTActive.asStateFlow()
|
||||
|
||||
/**
|
||||
* Join a communication channel.
|
||||
*/
|
||||
suspend fun joinChannel(
|
||||
channelId: String,
|
||||
userRole: RBACFramework.Role,
|
||||
userUnit: String?
|
||||
): Result<Channel> {
|
||||
val channel = channelManager.getChannel(channelId) ?: return Result.Error(
|
||||
IllegalArgumentException("Channel not found: $channelId")
|
||||
)
|
||||
|
||||
// Check authorization
|
||||
if (!channelManager.hasAccess(channel, userRole, userUnit)) {
|
||||
return Result.Error(SecurityException("Access denied to channel: $channelId"))
|
||||
}
|
||||
|
||||
// Leave current channel if any
|
||||
_currentChannel.value?.let { leaveChannel(it.id) }
|
||||
|
||||
// Join new channel
|
||||
val joinResult = voiceTransport.joinChannel(channelId)
|
||||
return when (joinResult) {
|
||||
is Result.Success -> {
|
||||
_currentChannel.value = channel
|
||||
auditLogger.logEvent(
|
||||
AuditEventType.CHANNEL_JOINED,
|
||||
mapOf(
|
||||
"channelId" to channelId,
|
||||
"channelName" to channel.name,
|
||||
"timestamp" to Date().toString()
|
||||
)
|
||||
)
|
||||
Result.Success(channel)
|
||||
}
|
||||
is Result.Error -> joinResult
|
||||
is Result.Loading -> Result.Error(Exception("Unexpected loading state"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave current channel.
|
||||
*/
|
||||
suspend fun leaveChannel(channelId: String): Result<Unit> {
|
||||
val result = voiceTransport.leaveChannel(channelId)
|
||||
when (result) {
|
||||
is Result.Success -> {
|
||||
_currentChannel.value = null
|
||||
auditLogger.logEvent(
|
||||
AuditEventType.CHANNEL_LEFT,
|
||||
mapOf(
|
||||
"channelId" to channelId,
|
||||
"timestamp" to Date().toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Start push-to-talk (PTT).
|
||||
*/
|
||||
suspend fun startPTT(): Result<Unit> {
|
||||
val channel = _currentChannel.value ?: return Result.Error(
|
||||
IllegalStateException("Not connected to any channel")
|
||||
)
|
||||
|
||||
val result = voiceTransport.startTransmission(channel.id)
|
||||
when (result) {
|
||||
is Result.Success -> {
|
||||
_isPTTActive.value = true
|
||||
auditLogger.logEvent(
|
||||
AuditEventType.PTT_STARTED,
|
||||
mapOf(
|
||||
"channelId" to channel.id,
|
||||
"timestamp" to Date().toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop push-to-talk (PTT).
|
||||
*/
|
||||
suspend fun stopPTT(): Result<Unit> {
|
||||
val channel = _currentChannel.value ?: return Result.Error(
|
||||
IllegalStateException("Not connected to any channel")
|
||||
)
|
||||
|
||||
val result = voiceTransport.stopTransmission(channel.id)
|
||||
when (result) {
|
||||
is Result.Success -> {
|
||||
_isPTTActive.value = false
|
||||
auditLogger.logEvent(
|
||||
AuditEventType.PTT_STOPPED,
|
||||
mapOf(
|
||||
"channelId" to channel.id,
|
||||
"timestamp" to Date().toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available channels for user.
|
||||
*/
|
||||
suspend fun getAvailableChannels(
|
||||
userRole: RBACFramework.Role,
|
||||
userUnit: String?
|
||||
): List<Channel> {
|
||||
return channelManager.getAvailableChannels(userRole, userUnit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.smoa.modules.communications.domain
|
||||
|
||||
import com.smoa.core.common.Result
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Voice transport for encrypted voice communication.
|
||||
* Uses WebRTC for peer-to-peer encrypted voice transmission.
|
||||
*/
|
||||
@Singleton
|
||||
class VoiceTransport @Inject constructor(
|
||||
private val webRTCManager: WebRTCManager
|
||||
) {
|
||||
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||
|
||||
private var currentChannelId: String? = null
|
||||
private var isTransmitting = false
|
||||
private var peerConnection: WebRTCPeerConnection? = null
|
||||
|
||||
/**
|
||||
* Join a communication channel.
|
||||
*/
|
||||
suspend fun joinChannel(channelId: String): Result<Unit> {
|
||||
return try {
|
||||
_connectionState.value = ConnectionState.Connecting(channelId)
|
||||
|
||||
// Initialize WebRTC peer connection (audio only for voice)
|
||||
val connectionResult = webRTCManager.initializePeerConnection(channelId, isAudioOnly = true)
|
||||
|
||||
when (connectionResult) {
|
||||
is Result.Success -> {
|
||||
peerConnection = connectionResult.data
|
||||
currentChannelId = channelId
|
||||
_connectionState.value = ConnectionState.Connected(channelId)
|
||||
Result.Success(Unit)
|
||||
}
|
||||
is Result.Error -> {
|
||||
_connectionState.value = ConnectionState.Error(connectionResult.exception.message ?: "Failed to connect")
|
||||
Result.Error(connectionResult.exception)
|
||||
}
|
||||
is Result.Loading -> {
|
||||
_connectionState.value = ConnectionState.Error("Unexpected loading state")
|
||||
Result.Error(Exception("Unexpected loading state"))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_connectionState.value = ConnectionState.Error(e.message ?: "Unknown error")
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave current channel.
|
||||
*/
|
||||
suspend fun leaveChannel(channelId: String): Result<Unit> {
|
||||
return try {
|
||||
if (isTransmitting) {
|
||||
stopTransmission(channelId)
|
||||
}
|
||||
|
||||
// Close WebRTC peer connection
|
||||
peerConnection?.let { connection ->
|
||||
webRTCManager.closePeerConnection(connection)
|
||||
}
|
||||
|
||||
peerConnection = null
|
||||
currentChannelId = null
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
Result.Success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start voice transmission (PTT).
|
||||
*/
|
||||
suspend fun startTransmission(channelId: String): Result<Unit> {
|
||||
return try {
|
||||
if (currentChannelId != channelId) {
|
||||
return Result.Error(IllegalStateException("Not connected to channel: $channelId"))
|
||||
}
|
||||
|
||||
val connection = peerConnection ?: return Result.Error(
|
||||
IllegalStateException("No active peer connection")
|
||||
)
|
||||
|
||||
// Start audio transmission via WebRTC
|
||||
val result = webRTCManager.startAudioTransmission(connection)
|
||||
when (result) {
|
||||
is Result.Success -> {
|
||||
isTransmitting = true
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop voice transmission (PTT release).
|
||||
*/
|
||||
suspend fun stopTransmission(channelId: String): Result<Unit> {
|
||||
return try {
|
||||
val connection = peerConnection ?: return Result.Error(
|
||||
IllegalStateException("No active peer connection")
|
||||
)
|
||||
|
||||
// Stop audio transmission via WebRTC
|
||||
val result = webRTCManager.stopAudioTransmission(connection)
|
||||
when (result) {
|
||||
is Result.Success -> {
|
||||
isTransmitting = false
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
result
|
||||
} catch (e: Exception) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection state.
|
||||
*/
|
||||
sealed class ConnectionState {
|
||||
object Disconnected : ConnectionState()
|
||||
data class Connecting(val channelId: String) : ConnectionState()
|
||||
data class Connected(val channelId: String) : ConnectionState()
|
||||
data class Error(val message: String) : ConnectionState()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.smoa.modules.communications.domain
|
||||
|
||||
/**
|
||||
* WebRTC configuration for STUN/TURN servers and signaling.
|
||||
*/
|
||||
data class WebRTCConfig(
|
||||
val stunServers: List<StunServer>,
|
||||
val turnServers: List<TurnServer>,
|
||||
val signalingServerUrl: String,
|
||||
val iceCandidatePoolSize: Int = 10
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* Default configuration with public STUN servers.
|
||||
* In production, use organization-specific STUN/TURN servers.
|
||||
*/
|
||||
fun default(): WebRTCConfig {
|
||||
return WebRTCConfig(
|
||||
stunServers = listOf(
|
||||
StunServer("stun:stun.l.google.com:19302"),
|
||||
StunServer("stun:stun1.l.google.com:19302")
|
||||
),
|
||||
turnServers = emptyList(), // TURN servers should be configured per deployment
|
||||
signalingServerUrl = "", // Should be configured per deployment
|
||||
iceCandidatePoolSize = 10
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* STUN server configuration.
|
||||
*/
|
||||
data class StunServer(
|
||||
val url: String
|
||||
)
|
||||
|
||||
/**
|
||||
* TURN server configuration.
|
||||
*/
|
||||
data class TurnServer(
|
||||
val url: String,
|
||||
val username: String? = null,
|
||||
val credential: String? = null
|
||||
)
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package com.smoa.modules.communications.domain
|
||||
|
||||
import android.content.Context
|
||||
import com.smoa.core.common.Result
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* WebRTC Manager for voice and video communication.
|
||||
* Provides WebRTC peer connection management for Communications and Meetings modules.
|
||||
*/
|
||||
@Singleton
|
||||
class WebRTCManager @Inject constructor(
|
||||
private val context: Context
|
||||
) {
|
||||
private val config = WebRTCConfig.default()
|
||||
private val peerConnections = mutableMapOf<String, WebRTCPeerConnection>()
|
||||
private val _connectionState = MutableStateFlow<WebRTCConnectionState>(WebRTCConnectionState.Disconnected)
|
||||
val connectionState: StateFlow<WebRTCConnectionState> = _connectionState.asStateFlow()
|
||||
|
||||
/**
|
||||
* Initialize WebRTC peer connection.
|
||||
*/
|
||||
suspend fun initializePeerConnection(
|
||||
channelId: String,
|
||||
isAudioOnly: Boolean = false
|
||||
): Result<WebRTCPeerConnection> {
|
||||
return try {
|
||||
_connectionState.value = WebRTCConnectionState.Connecting(channelId)
|
||||
|
||||
// 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
|
||||
|
||||
val peerConnection = WebRTCPeerConnection(
|
||||
channelId = channelId,
|
||||
isAudioOnly = isAudioOnly,
|
||||
config = rtcConfig
|
||||
)
|
||||
|
||||
peerConnections[channelId] = peerConnection
|
||||
|
||||
_connectionState.value = WebRTCConnectionState.Connected(channelId)
|
||||
Result.Success(peerConnection)
|
||||
} catch (e: Exception) {
|
||||
_connectionState.value = WebRTCConnectionState.Error(e.message ?: "Unknown error")
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create RTC configuration with STUN/TURN servers.
|
||||
*/
|
||||
private fun createRTCConfiguration(): RTCConfiguration {
|
||||
val iceServers = mutableListOf<IceServer>()
|
||||
|
||||
// Add STUN servers
|
||||
config.stunServers.forEach { stunServer ->
|
||||
iceServers.add(IceServer(stunServer.url))
|
||||
}
|
||||
|
||||
// Add TURN servers
|
||||
config.turnServers.forEach { turnServer ->
|
||||
iceServers.add(
|
||||
IceServer(
|
||||
url = turnServer.url,
|
||||
username = turnServer.username,
|
||||
credential = turnServer.credential
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return RTCConfiguration(
|
||||
iceServers = iceServers,
|
||||
iceCandidatePoolSize = config.iceCandidatePoolSize
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close peer connection.
|
||||
*/
|
||||
suspend fun closePeerConnection(peerConnection: WebRTCPeerConnection): Result<Unit> {
|
||||
return try {
|
||||
// Stop all tracks
|
||||
if (peerConnection.isAudioActive) {
|
||||
stopAudioTransmission(peerConnection)
|
||||
}
|
||||
if (peerConnection.isVideoActive) {
|
||||
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)
|
||||
} catch (e: Exception) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start audio capture and transmission.
|
||||
*/
|
||||
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
|
||||
Result.Success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop audio transmission.
|
||||
*/
|
||||
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) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start video capture and transmission.
|
||||
*/
|
||||
suspend fun startVideoTransmission(peerConnection: WebRTCPeerConnection): Result<Unit> {
|
||||
return try {
|
||||
if (peerConnection.isAudioOnly) {
|
||||
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
|
||||
Result.Success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop video transmission.
|
||||
*/
|
||||
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) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WebRTC peer connection wrapper.
|
||||
*/
|
||||
data class WebRTCPeerConnection(
|
||||
val channelId: String,
|
||||
val isAudioOnly: Boolean = false,
|
||||
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
|
||||
)
|
||||
|
||||
/**
|
||||
* RTC configuration for peer connections.
|
||||
*/
|
||||
data class RTCConfiguration(
|
||||
val iceServers: List<IceServer>,
|
||||
val iceCandidatePoolSize: Int = 10
|
||||
)
|
||||
|
||||
/**
|
||||
* ICE server configuration.
|
||||
*/
|
||||
data class IceServer(
|
||||
val url: String,
|
||||
val username: String? = null,
|
||||
val credential: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* WebRTC connection state.
|
||||
*/
|
||||
sealed class WebRTCConnectionState {
|
||||
object Disconnected : WebRTCConnectionState()
|
||||
data class Connecting(val channelId: String) : WebRTCConnectionState()
|
||||
data class Connected(val channelId: String) : WebRTCConnectionState()
|
||||
data class Error(val message: String) : WebRTCConnectionState()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
package com.smoa.modules.communications.ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.smoa.core.auth.RBACFramework
|
||||
import com.smoa.modules.communications.domain.Channel
|
||||
import com.smoa.modules.communications.domain.CommunicationsService
|
||||
|
||||
/**
|
||||
* Communications screen with channel list and PTT controls.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CommunicationsScreen(
|
||||
communicationsService: CommunicationsService,
|
||||
userRole: RBACFramework.Role,
|
||||
userUnit: String?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var channels by remember { mutableStateOf<List<Channel>>(emptyList()) }
|
||||
var currentChannel by remember { mutableStateOf<Channel?>(null) }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val isPTTActive by communicationsService.isPTTActive.collectAsState()
|
||||
|
||||
// Load available channels
|
||||
LaunchedEffect(userRole, userUnit) {
|
||||
isLoading = true
|
||||
errorMessage = null
|
||||
try {
|
||||
channels = communicationsService.getAvailableChannels(userRole, userUnit)
|
||||
} catch (e: Exception) {
|
||||
errorMessage = e.message
|
||||
} finally {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
// Observe current channel
|
||||
LaunchedEffect(Unit) {
|
||||
communicationsService.currentChannel.collect { channel ->
|
||||
currentChannel = channel
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Unit Communications",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Current channel indicator
|
||||
currentChannel?.let { channel ->
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Current Channel: ${channel.name}",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
channel.description?.let { desc ->
|
||||
Text(
|
||||
text = desc,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// Error message
|
||||
errorMessage?.let { error ->
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// Channel list
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(channels) { channel ->
|
||||
ChannelCard(
|
||||
channel = channel,
|
||||
isActive = currentChannel?.id == channel.id,
|
||||
onClick = {
|
||||
// Join channel
|
||||
// This would need to be in a coroutine scope
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// PTT Button
|
||||
Button(
|
||||
onClick = {
|
||||
if (isPTTActive) {
|
||||
// Stop PTT
|
||||
} else {
|
||||
// Start PTT
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(64.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (isPTTActive) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
}
|
||||
),
|
||||
enabled = currentChannel != null
|
||||
) {
|
||||
Text(
|
||||
text = if (isPTTActive) "RELEASE" else "PUSH TO TALK",
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel card.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChannelCard(
|
||||
channel: Channel,
|
||||
isActive: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isActive) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
}
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = channel.name,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
channel.description?.let { desc ->
|
||||
Text(
|
||||
text = desc,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (isActive) {
|
||||
Text(
|
||||
text = "Connected",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
60
modules/credentials/build.gradle.kts
Normal file
60
modules/credentials/build.gradle.kts
Normal file
@@ -0,0 +1,60 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("kotlin-kapt")
|
||||
id("dagger.hilt.android.plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.smoa.modules.credentials"
|
||||
compileSdk = AppConfig.compileSdk
|
||||
|
||||
defaultConfig {
|
||||
minSdk = AppConfig.minSdk
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:common"))
|
||||
implementation(project(":core:auth"))
|
||||
implementation(project(":core:security"))
|
||||
implementation(project(":core:barcode"))
|
||||
|
||||
implementation(platform(Dependencies.composeBom))
|
||||
implementation(Dependencies.composeUi)
|
||||
implementation(Dependencies.composeUiGraphics)
|
||||
implementation(Dependencies.composeMaterial3)
|
||||
implementation(Dependencies.androidxCoreKtx)
|
||||
implementation(Dependencies.androidxLifecycleRuntimeKtx)
|
||||
|
||||
implementation(Dependencies.hiltAndroid)
|
||||
kapt(Dependencies.hiltAndroidCompiler)
|
||||
|
||||
implementation(Dependencies.roomRuntime)
|
||||
implementation(Dependencies.roomKtx)
|
||||
kapt(Dependencies.roomCompiler)
|
||||
|
||||
// Barcode generation
|
||||
implementation(Dependencies.zxingCore)
|
||||
|
||||
implementation(Dependencies.coroutinesCore)
|
||||
implementation(Dependencies.coroutinesAndroid)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.smoa.modules.credentials
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Credentials module - Secure presentation of government-issued credentials.
|
||||
*/
|
||||
@Composable
|
||||
fun CredentialsModule(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.smoa.modules.credentials
|
||||
|
||||
import com.smoa.core.barcode.BarcodeEncoder
|
||||
import com.smoa.core.barcode.formats.AAMVACredential
|
||||
import com.smoa.core.barcode.formats.ICAO9303Credential
|
||||
import com.smoa.core.barcode.formats.MILSTD129Credential
|
||||
import com.smoa.core.common.Result
|
||||
import com.google.zxing.common.BitMatrix
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Repository for credentials management with barcode generation.
|
||||
*/
|
||||
@Singleton
|
||||
class CredentialsRepository @Inject constructor(
|
||||
private val barcodeEncoder: BarcodeEncoder
|
||||
) {
|
||||
|
||||
/**
|
||||
* Generate AAMVA credential barcode.
|
||||
*/
|
||||
suspend fun generateAAMVABarcode(credential: AAMVACredential): Result<BitMatrix> {
|
||||
return barcodeEncoder.encodeAAMVA(credential)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate ICAO 9303 credential barcode.
|
||||
*/
|
||||
suspend fun generateICAO9303Barcode(credential: ICAO9303Credential): Result<BitMatrix> {
|
||||
return barcodeEncoder.encodeICAO9303(credential)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate MIL-STD-129 credential barcode.
|
||||
*/
|
||||
suspend fun generateMILSTD129Barcode(credential: MILSTD129Credential): Result<BitMatrix> {
|
||||
return barcodeEncoder.encodeMILSTD129(credential)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.smoa.modules.credentials.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.smoa.core.barcode.ui.BarcodeDisplay
|
||||
import com.google.zxing.common.BitMatrix
|
||||
|
||||
/**
|
||||
* Display credential with PDF417 barcode.
|
||||
*/
|
||||
@Composable
|
||||
fun BarcodeCredentialDisplay(
|
||||
bitMatrix: BitMatrix,
|
||||
credentialTitle: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = credentialTitle,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
|
||||
BarcodeDisplay(
|
||||
bitMatrix = bitMatrix,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
62
modules/directory/build.gradle.kts
Normal file
62
modules/directory/build.gradle.kts
Normal file
@@ -0,0 +1,62 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("kotlin-kapt")
|
||||
id("dagger.hilt.android.plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.smoa.modules.directory"
|
||||
compileSdk = AppConfig.compileSdk
|
||||
|
||||
defaultConfig {
|
||||
minSdk = AppConfig.minSdk
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:common"))
|
||||
implementation(project(":core:auth"))
|
||||
implementation(project(":core:security"))
|
||||
|
||||
implementation(platform(Dependencies.composeBom))
|
||||
implementation(Dependencies.composeUi)
|
||||
implementation(Dependencies.composeUiGraphics)
|
||||
implementation(Dependencies.composeMaterial3)
|
||||
implementation(Dependencies.androidxCoreKtx)
|
||||
implementation(Dependencies.androidxLifecycleRuntimeKtx)
|
||||
|
||||
implementation(Dependencies.hiltAndroid)
|
||||
kapt(Dependencies.hiltAndroidCompiler)
|
||||
|
||||
implementation(Dependencies.roomRuntime)
|
||||
implementation(Dependencies.roomKtx)
|
||||
kapt(Dependencies.roomCompiler)
|
||||
|
||||
// Database Encryption
|
||||
implementation(Dependencies.sqlcipher)
|
||||
|
||||
// Testing
|
||||
testImplementation(Dependencies.junit)
|
||||
testImplementation(Dependencies.mockk)
|
||||
testImplementation(Dependencies.coroutinesTest)
|
||||
testImplementation(Dependencies.truth)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.smoa.modules.directory
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.smoa.core.auth.RBACFramework
|
||||
import com.smoa.modules.directory.domain.DirectoryEntry
|
||||
import com.smoa.modules.directory.domain.DirectoryService
|
||||
import com.smoa.modules.directory.ui.DirectoryListScreen
|
||||
|
||||
/**
|
||||
* Directory module - Controlled access to internal routing and contact information.
|
||||
*/
|
||||
@Composable
|
||||
fun DirectoryModule(
|
||||
directoryService: DirectoryService,
|
||||
userRole: RBACFramework.Role,
|
||||
userUnit: String?,
|
||||
onEntryClick: (DirectoryEntry) -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
DirectoryListScreen(
|
||||
directoryService = directoryService,
|
||||
userRole = userRole,
|
||||
userUnit = userUnit,
|
||||
onEntryClick = onEntryClick,
|
||||
modifier = modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.smoa.modules.directory.data
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Update
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Data Access Object for directory entries.
|
||||
*/
|
||||
@Dao
|
||||
interface DirectoryDao {
|
||||
/**
|
||||
* Get all directory entries.
|
||||
*/
|
||||
@Query("SELECT * FROM directory_entries ORDER BY name ASC")
|
||||
fun observeAllEntries(): Flow<List<DirectoryEntity>>
|
||||
|
||||
/**
|
||||
* Get directory entry by ID.
|
||||
*/
|
||||
@Query("SELECT * FROM directory_entries WHERE id = :entryId")
|
||||
suspend fun getEntryById(entryId: String): DirectoryEntity?
|
||||
|
||||
/**
|
||||
* Search directory entries by name, title, or unit.
|
||||
*/
|
||||
@Query("""
|
||||
SELECT * FROM directory_entries
|
||||
WHERE name LIKE :query
|
||||
OR title LIKE :query
|
||||
OR unit LIKE :query
|
||||
ORDER BY name ASC
|
||||
""")
|
||||
suspend fun searchDirectory(query: String): List<DirectoryEntity>
|
||||
|
||||
/**
|
||||
* Get directory entries by unit.
|
||||
*/
|
||||
@Query("SELECT * FROM directory_entries WHERE unit = :unit ORDER BY name ASC")
|
||||
suspend fun getEntriesByUnit(unit: String): List<DirectoryEntity>
|
||||
|
||||
/**
|
||||
* Insert or update directory entry.
|
||||
*/
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertEntry(entry: DirectoryEntity)
|
||||
|
||||
/**
|
||||
* Update directory entry.
|
||||
*/
|
||||
@Update
|
||||
suspend fun updateEntry(entry: DirectoryEntity)
|
||||
|
||||
/**
|
||||
* Delete directory entry.
|
||||
*/
|
||||
@Query("DELETE FROM directory_entries WHERE id = :entryId")
|
||||
suspend fun deleteEntry(entryId: String)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.smoa.modules.directory.data
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
|
||||
/**
|
||||
* Directory database.
|
||||
*/
|
||||
@Database(
|
||||
entities = [DirectoryEntity::class],
|
||||
version = 1,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class DirectoryDatabase : RoomDatabase() {
|
||||
abstract fun directoryDao(): DirectoryDao
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.smoa.modules.directory.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 DirectoryDatabaseModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDirectoryDatabase(
|
||||
@ApplicationContext context: Context,
|
||||
encryptedDatabaseHelper: EncryptedDatabaseHelper
|
||||
): DirectoryDatabase {
|
||||
val factory = encryptedDatabaseHelper.createOpenHelperFactory("directory_database")
|
||||
|
||||
return Room.databaseBuilder(
|
||||
context,
|
||||
DirectoryDatabase::class.java,
|
||||
"directory_database"
|
||||
)
|
||||
.openHelperFactory(factory)
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideDirectoryDao(database: DirectoryDatabase): DirectoryDao {
|
||||
return database.directoryDao()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.smoa.modules.directory.data
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import com.smoa.modules.directory.domain.DirectoryEntry
|
||||
|
||||
/**
|
||||
* Directory entity for Room database.
|
||||
*/
|
||||
@Entity(tableName = "directory_entries")
|
||||
data class DirectoryEntity(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
val name: String,
|
||||
val title: String?,
|
||||
val unit: String,
|
||||
val phoneNumber: String?,
|
||||
val extension: String?,
|
||||
val email: String?,
|
||||
val secureRoutingId: String?,
|
||||
val role: String?,
|
||||
val clearanceLevel: String?,
|
||||
val lastUpdated: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
/**
|
||||
* Convert entity to domain model.
|
||||
*/
|
||||
fun DirectoryEntity.toDomain(): com.smoa.modules.directory.domain.DirectoryEntry {
|
||||
return com.smoa.modules.directory.domain.DirectoryEntry(
|
||||
id = id,
|
||||
name = name,
|
||||
title = title,
|
||||
unit = unit,
|
||||
phoneNumber = phoneNumber,
|
||||
extension = extension,
|
||||
email = email,
|
||||
secureRoutingId = secureRoutingId,
|
||||
role = role,
|
||||
clearanceLevel = clearanceLevel,
|
||||
lastUpdated = lastUpdated
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert domain model to entity.
|
||||
*/
|
||||
fun com.smoa.modules.directory.domain.DirectoryEntry.toEntity(): DirectoryEntity {
|
||||
return DirectoryEntity(
|
||||
id = id,
|
||||
name = name,
|
||||
title = title,
|
||||
unit = unit,
|
||||
phoneNumber = phoneNumber,
|
||||
extension = extension,
|
||||
email = email,
|
||||
secureRoutingId = secureRoutingId,
|
||||
role = role,
|
||||
clearanceLevel = clearanceLevel,
|
||||
lastUpdated = lastUpdated
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.smoa.modules.directory.di
|
||||
|
||||
import com.smoa.core.auth.RBACFramework
|
||||
import com.smoa.modules.directory.data.DirectoryDao
|
||||
import com.smoa.modules.directory.domain.DirectoryService
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object DirectoryModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDirectoryService(
|
||||
directoryDao: DirectoryDao,
|
||||
rbacFramework: RBACFramework
|
||||
): DirectoryService {
|
||||
return DirectoryService(directoryDao, rbacFramework)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package com.smoa.modules.directory.domain
|
||||
|
||||
import com.smoa.core.auth.RBACFramework
|
||||
import com.smoa.core.common.Result
|
||||
import com.smoa.modules.directory.data.DirectoryDao
|
||||
import com.smoa.modules.directory.data.DirectoryEntity
|
||||
import com.smoa.modules.directory.data.toDomain
|
||||
import com.smoa.modules.directory.data.toEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Directory service for managing internal directory and contact information.
|
||||
* Enforces unit-scoped and role-scoped directory views.
|
||||
*/
|
||||
@Singleton
|
||||
class DirectoryService @Inject constructor(
|
||||
private val directoryDao: DirectoryDao,
|
||||
private val rbacFramework: RBACFramework
|
||||
) {
|
||||
/**
|
||||
* Search directory entries.
|
||||
* Results are filtered by user's role and unit scope.
|
||||
*/
|
||||
suspend fun searchDirectory(
|
||||
query: String,
|
||||
userRole: RBACFramework.Role,
|
||||
userUnit: String?
|
||||
): List<DirectoryEntry> {
|
||||
val entries = directoryDao.searchDirectory("%$query%")
|
||||
|
||||
// Filter by role and unit scope
|
||||
val filtered = entries.filter { entry ->
|
||||
// Check if user has permission to view this entry
|
||||
hasAccessToEntry(entry, userRole, userUnit)
|
||||
}
|
||||
return filtered.map { entry: DirectoryEntity -> entry.toDomain() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get directory entry by ID.
|
||||
*/
|
||||
suspend fun getDirectoryEntry(
|
||||
entryId: String,
|
||||
userRole: RBACFramework.Role,
|
||||
userUnit: String?
|
||||
): DirectoryEntry? {
|
||||
val entity = directoryDao.getEntryById(entryId) ?: return null
|
||||
|
||||
// Check access
|
||||
if (!hasAccessToEntry(entity, userRole, userUnit)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return entity.toDomain()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all directory entries for a unit.
|
||||
*/
|
||||
suspend fun getDirectoryEntriesByUnit(
|
||||
unit: String,
|
||||
userRole: RBACFramework.Role,
|
||||
userUnit: String?
|
||||
): List<DirectoryEntry> {
|
||||
// Check if user has access to this unit
|
||||
if (userUnit != null && userUnit != unit && userRole != RBACFramework.Role.ADMIN) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return directoryDao.getEntriesByUnit(unit).map { it.toDomain() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get directory entries observable (for UI).
|
||||
*/
|
||||
fun observeDirectoryEntries(
|
||||
userRole: RBACFramework.Role,
|
||||
userUnit: String?
|
||||
): Flow<List<DirectoryEntry>> {
|
||||
return directoryDao.observeAllEntries()
|
||||
.map { entities ->
|
||||
entities
|
||||
.filter { hasAccessToEntry(it, userRole, userUnit) }
|
||||
.map { it.toDomain() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has access to a directory entry.
|
||||
*/
|
||||
private fun hasAccessToEntry(
|
||||
entry: DirectoryEntity,
|
||||
userRole: RBACFramework.Role,
|
||||
userUnit: String?
|
||||
): Boolean {
|
||||
// Admins can see all entries
|
||||
if (userRole == RBACFramework.Role.ADMIN) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check unit scope
|
||||
if (userUnit != null && entry.unit != userUnit) {
|
||||
// User can only see entries from their unit
|
||||
return false
|
||||
}
|
||||
|
||||
// Check role permissions
|
||||
return rbacFramework.hasPermission(userRole, RBACFramework.Permission.VIEW_DIRECTORY)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update directory entry (admin only).
|
||||
*/
|
||||
suspend fun upsertDirectoryEntry(
|
||||
entry: DirectoryEntry,
|
||||
userRole: RBACFramework.Role
|
||||
): Result<DirectoryEntry> {
|
||||
if (userRole != RBACFramework.Role.ADMIN) {
|
||||
return Result.Error(SecurityException("Only administrators can modify directory entries"))
|
||||
}
|
||||
|
||||
return try {
|
||||
val entity = entry.toEntity()
|
||||
directoryDao.upsertEntry(entity)
|
||||
Result.Success(entry)
|
||||
} catch (e: Exception) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete directory entry (admin only).
|
||||
*/
|
||||
suspend fun deleteDirectoryEntry(
|
||||
entryId: String,
|
||||
userRole: RBACFramework.Role
|
||||
): Result<Unit> {
|
||||
if (userRole != RBACFramework.Role.ADMIN) {
|
||||
return Result.Error(SecurityException("Only administrators can delete directory entries"))
|
||||
}
|
||||
|
||||
return try {
|
||||
directoryDao.deleteEntry(entryId)
|
||||
Result.Success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Directory entry domain model.
|
||||
*/
|
||||
data class DirectoryEntry(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val title: String?,
|
||||
val unit: String,
|
||||
val phoneNumber: String?,
|
||||
val extension: String?,
|
||||
val email: String?,
|
||||
val secureRoutingId: String?,
|
||||
val role: String?,
|
||||
val clearanceLevel: String?,
|
||||
val lastUpdated: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package com.smoa.modules.directory.ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.smoa.modules.directory.domain.DirectoryEntry
|
||||
import com.smoa.modules.directory.domain.DirectoryService
|
||||
|
||||
/**
|
||||
* Directory list screen with search functionality.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DirectoryListScreen(
|
||||
directoryService: DirectoryService,
|
||||
userRole: com.smoa.core.auth.RBACFramework.Role,
|
||||
userUnit: String?,
|
||||
onEntryClick: (DirectoryEntry) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
var directoryEntries by remember { mutableStateOf<List<DirectoryEntry>>(emptyList()) }
|
||||
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) {
|
||||
if (searchQuery.isBlank()) {
|
||||
// Show all entries when search is empty
|
||||
directoryService.observeDirectoryEntries(userRole, userUnit)
|
||||
.collect { entries ->
|
||||
directoryEntries = entries
|
||||
}
|
||||
} else {
|
||||
isLoading = true
|
||||
errorMessage = null
|
||||
try {
|
||||
directoryEntries = directoryService.searchDirectory(searchQuery, userRole, userUnit)
|
||||
} catch (e: Exception) {
|
||||
errorMessage = e.message
|
||||
} finally {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// Search bar
|
||||
OutlinedTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
label = { Text("Search directory") },
|
||||
placeholder = { Text("Name, title, or unit") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Error message
|
||||
errorMessage?.let { error ->
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// Loading indicator
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
// Directory entries list
|
||||
if (directoryEntries.isEmpty() && !isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = if (searchQuery.isBlank()) "No directory entries" else "No results found",
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(directoryEntries) { entry ->
|
||||
DirectoryEntryCard(
|
||||
entry = entry,
|
||||
onClick = { onEntryClick(entry) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Directory entry card.
|
||||
*/
|
||||
@Composable
|
||||
fun DirectoryEntryCard(
|
||||
entry: DirectoryEntry,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = entry.name,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
entry.title?.let { title ->
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = entry.unit,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
entry.phoneNumber?.let { phone ->
|
||||
Text(
|
||||
text = phone,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.smoa.modules.directory.domain
|
||||
|
||||
import com.smoa.core.auth.RBACFramework
|
||||
import com.smoa.modules.directory.data.DirectoryDao
|
||||
import com.smoa.modules.directory.data.DirectoryEntity
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for DirectoryService.
|
||||
*/
|
||||
class DirectoryServiceTest {
|
||||
private val directoryDao = mockk<DirectoryDao>(relaxed = true)
|
||||
private val rbacFramework = mockk<RBACFramework>(relaxed = true)
|
||||
private val directoryService = DirectoryService(directoryDao, rbacFramework)
|
||||
|
||||
@Test
|
||||
fun `searchDirectory should filter by role and unit`() = runTest {
|
||||
// Given
|
||||
val query = "test"
|
||||
val userRole = RBACFramework.Role.OPERATOR
|
||||
val userUnit = "Unit1"
|
||||
|
||||
val entity1 = DirectoryEntity(
|
||||
id = "1",
|
||||
name = "Test User 1",
|
||||
title = "Officer",
|
||||
unit = "Unit1",
|
||||
phoneNumber = "123-456-7890",
|
||||
extension = null,
|
||||
email = null,
|
||||
secureRoutingId = null,
|
||||
role = null,
|
||||
clearanceLevel = null
|
||||
)
|
||||
|
||||
val entity2 = DirectoryEntity(
|
||||
id = "2",
|
||||
name = "Test User 2",
|
||||
title = "Officer",
|
||||
unit = "Unit2",
|
||||
phoneNumber = "123-456-7891",
|
||||
extension = null,
|
||||
email = null,
|
||||
secureRoutingId = null,
|
||||
role = null,
|
||||
clearanceLevel = null
|
||||
)
|
||||
|
||||
coEvery { directoryDao.searchDirectory("%$query%") } returns listOf(entity1, entity2)
|
||||
every { rbacFramework.hasPermission(userRole, RBACFramework.Permission.VIEW_DIRECTORY) } returns true
|
||||
|
||||
// When
|
||||
val result = directoryService.searchDirectory(query, userRole, userUnit)
|
||||
|
||||
// Then
|
||||
assertEquals(1, result.size)
|
||||
assertEquals("Test User 1", result[0].name)
|
||||
assertEquals("Unit1", result[0].unit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getDirectoryEntry should return null for unauthorized access`() = runTest {
|
||||
// Given
|
||||
val entryId = "1"
|
||||
val userRole = RBACFramework.Role.VIEWER
|
||||
val userUnit = "Unit1"
|
||||
|
||||
val entity = DirectoryEntity(
|
||||
id = entryId,
|
||||
name = "Test User",
|
||||
title = "Officer",
|
||||
unit = "Unit2", // Different unit
|
||||
phoneNumber = null,
|
||||
extension = null,
|
||||
email = null,
|
||||
secureRoutingId = null,
|
||||
role = null,
|
||||
clearanceLevel = null
|
||||
)
|
||||
|
||||
coEvery { directoryDao.getEntryById(entryId) } returns entity
|
||||
every { rbacFramework.hasPermission(userRole, RBACFramework.Permission.VIEW_DIRECTORY) } returns true
|
||||
|
||||
// When
|
||||
val result = directoryService.getDirectoryEntry(entryId, userRole, userUnit)
|
||||
|
||||
// Then
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `upsertDirectoryEntry should fail for non-admin users`() = runTest {
|
||||
// Given
|
||||
val entry = DirectoryEntry(
|
||||
id = "1",
|
||||
name = "Test User",
|
||||
title = "Officer",
|
||||
unit = "Unit1",
|
||||
phoneNumber = null,
|
||||
extension = null,
|
||||
email = null,
|
||||
secureRoutingId = null,
|
||||
role = null,
|
||||
clearanceLevel = null
|
||||
)
|
||||
val userRole = RBACFramework.Role.OPERATOR
|
||||
|
||||
// When
|
||||
val result = directoryService.upsertDirectoryEntry(entry, userRole)
|
||||
|
||||
// Then
|
||||
assertTrue(result.isFailure)
|
||||
assertTrue(result.exceptionOrNull() is SecurityException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `upsertDirectoryEntry should succeed for admin users`() = runTest {
|
||||
// Given
|
||||
val entry = DirectoryEntry(
|
||||
id = "1",
|
||||
name = "Test User",
|
||||
title = "Officer",
|
||||
unit = "Unit1",
|
||||
phoneNumber = null,
|
||||
extension = null,
|
||||
email = null,
|
||||
secureRoutingId = null,
|
||||
role = null,
|
||||
clearanceLevel = null
|
||||
)
|
||||
val userRole = RBACFramework.Role.ADMIN
|
||||
|
||||
coEvery { directoryDao.upsertEntry(any()) } returns Unit
|
||||
|
||||
// When
|
||||
val result = directoryService.upsertDirectoryEntry(entry, userRole)
|
||||
|
||||
// Then
|
||||
assertTrue(result.isSuccess)
|
||||
verify { directoryDao.upsertEntry(any()) }
|
||||
}
|
||||
}
|
||||
|
||||
59
modules/evidence/build.gradle.kts
Normal file
59
modules/evidence/build.gradle.kts
Normal file
@@ -0,0 +1,59 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("kotlin-kapt")
|
||||
id("dagger.hilt.android.plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.smoa.modules.evidence"
|
||||
compileSdk = AppConfig.compileSdk
|
||||
|
||||
defaultConfig {
|
||||
minSdk = AppConfig.minSdk
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:common"))
|
||||
implementation(project(":core:auth"))
|
||||
implementation(project(":core:security"))
|
||||
|
||||
implementation(platform(Dependencies.composeBom))
|
||||
implementation(Dependencies.composeUi)
|
||||
implementation(Dependencies.composeUiGraphics)
|
||||
implementation(Dependencies.composeMaterial3)
|
||||
implementation(Dependencies.androidxCoreKtx)
|
||||
implementation(Dependencies.androidxLifecycleRuntimeKtx)
|
||||
|
||||
implementation(Dependencies.hiltAndroid)
|
||||
kapt(Dependencies.hiltAndroidCompiler)
|
||||
|
||||
implementation(Dependencies.roomRuntime)
|
||||
implementation(Dependencies.roomKtx)
|
||||
kapt(Dependencies.roomCompiler)
|
||||
|
||||
// Database Encryption
|
||||
implementation(Dependencies.sqlcipher)
|
||||
|
||||
implementation(Dependencies.coroutinesCore)
|
||||
implementation(Dependencies.coroutinesAndroid)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.smoa.modules.evidence.data
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.TypeConverters
|
||||
import java.util.Date
|
||||
|
||||
@Entity(
|
||||
tableName = "custody_transfers",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = EvidenceEntity::class,
|
||||
parentColumns = ["evidenceId"],
|
||||
childColumns = ["evidenceId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
]
|
||||
)
|
||||
@TypeConverters(EvidenceConverters::class)
|
||||
data class CustodyTransferEntity(
|
||||
@PrimaryKey
|
||||
val transferId: String,
|
||||
val evidenceId: String,
|
||||
val timestamp: Date,
|
||||
val fromCustodian: String,
|
||||
val toCustodian: String,
|
||||
val reason: String,
|
||||
val evidenceCondition: String,
|
||||
val signatureData: ByteArray?,
|
||||
val notes: String?
|
||||
)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.smoa.modules.evidence.data
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import com.smoa.modules.evidence.domain.EvidenceType
|
||||
import java.util.Date
|
||||
|
||||
class EvidenceConverters {
|
||||
@TypeConverter
|
||||
fun fromEvidenceType(value: EvidenceType): String = value.name
|
||||
|
||||
@TypeConverter
|
||||
fun toEvidenceType(value: String): EvidenceType = EvidenceType.valueOf(value)
|
||||
|
||||
@TypeConverter
|
||||
fun fromTimestamp(value: Long?): Date? = value?.let { Date(it) }
|
||||
|
||||
@TypeConverter
|
||||
fun dateToTimestamp(date: Date?): Long? = date?.time
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.smoa.modules.evidence.data
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Relation
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Update
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface EvidenceDao {
|
||||
@Query("SELECT * FROM evidence ORDER BY collectionDate DESC")
|
||||
fun getAllEvidence(): Flow<List<EvidenceEntity>>
|
||||
|
||||
@Query("SELECT * FROM evidence WHERE evidenceId = :evidenceId")
|
||||
suspend fun getEvidenceById(evidenceId: String): EvidenceEntity?
|
||||
|
||||
@Query("SELECT * FROM evidence WHERE caseNumber = :caseNumber ORDER BY collectionDate DESC")
|
||||
fun getEvidenceByCase(caseNumber: String): Flow<List<EvidenceEntity>>
|
||||
|
||||
@Query("SELECT * FROM evidence WHERE currentCustodian = :custodian ORDER BY collectionDate DESC")
|
||||
fun getEvidenceByCustodian(custodian: String): Flow<List<EvidenceEntity>>
|
||||
|
||||
@Query("SELECT * FROM evidence WHERE description LIKE :query OR evidenceId LIKE :query ORDER BY collectionDate DESC")
|
||||
fun searchEvidence(query: String): Flow<List<EvidenceEntity>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertEvidence(evidence: EvidenceEntity)
|
||||
|
||||
@Update
|
||||
suspend fun updateEvidence(evidence: EvidenceEntity)
|
||||
|
||||
@Query("DELETE FROM evidence WHERE evidenceId = :evidenceId")
|
||||
suspend fun deleteEvidence(evidenceId: String)
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface CustodyTransferDao {
|
||||
@Query("SELECT * FROM custody_transfers WHERE evidenceId = :evidenceId ORDER BY timestamp ASC")
|
||||
fun getChainOfCustody(evidenceId: String): Flow<List<CustodyTransferEntity>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertTransfer(transfer: CustodyTransferEntity)
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM evidence WHERE evidenceId = :evidenceId")
|
||||
suspend fun getEvidenceWithChain(evidenceId: String): EvidenceWithChain?
|
||||
}
|
||||
|
||||
data class EvidenceWithChain(
|
||||
@Embedded
|
||||
val evidence: EvidenceEntity,
|
||||
@Relation(
|
||||
parentColumn = "evidenceId",
|
||||
entityColumn = "evidenceId"
|
||||
)
|
||||
val transfers: List<CustodyTransferEntity>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.smoa.modules.evidence.data
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
|
||||
@Database(
|
||||
entities = [EvidenceEntity::class, CustodyTransferEntity::class],
|
||||
version = 1,
|
||||
exportSchema = false
|
||||
)
|
||||
@TypeConverters(EvidenceConverters::class)
|
||||
abstract class EvidenceDatabase : RoomDatabase() {
|
||||
abstract fun evidenceDao(): EvidenceDao
|
||||
abstract fun custodyTransferDao(): CustodyTransferDao
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.smoa.modules.evidence.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
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 EvidenceDatabaseModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideEvidenceDatabase(
|
||||
@ApplicationContext context: Context,
|
||||
encryptedDatabaseHelper: com.smoa.core.security.EncryptedDatabaseHelper
|
||||
): EvidenceDatabase {
|
||||
val factory = encryptedDatabaseHelper.createOpenHelperFactory("evidence_database")
|
||||
|
||||
return Room.databaseBuilder(
|
||||
context,
|
||||
EvidenceDatabase::class.java,
|
||||
"evidence_database"
|
||||
)
|
||||
.openHelperFactory(factory)
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideEvidenceDao(database: EvidenceDatabase): EvidenceDao {
|
||||
return database.evidenceDao()
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideCustodyTransferDao(database: EvidenceDatabase): CustodyTransferDao {
|
||||
return database.custodyTransferDao()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.smoa.modules.evidence.data
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.TypeConverters
|
||||
import com.smoa.modules.evidence.domain.EvidenceType
|
||||
import java.util.Date
|
||||
|
||||
@Entity(tableName = "evidence")
|
||||
@TypeConverters(EvidenceConverters::class)
|
||||
data class EvidenceEntity(
|
||||
@PrimaryKey
|
||||
val evidenceId: String,
|
||||
val caseNumber: String,
|
||||
val description: String,
|
||||
val evidenceType: EvidenceType,
|
||||
val collectionDate: Date,
|
||||
val collectionLocation: String,
|
||||
val collectionMethod: String,
|
||||
val collectedBy: String,
|
||||
val currentCustodian: String,
|
||||
val storageLocation: String?,
|
||||
val createdAt: Date,
|
||||
val updatedAt: Date
|
||||
)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.smoa.modules.evidence.domain
|
||||
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Evidence data model per NIST SP 800-88.
|
||||
*/
|
||||
data class Evidence(
|
||||
val evidenceId: String,
|
||||
val caseNumber: String,
|
||||
val description: String,
|
||||
val evidenceType: EvidenceType,
|
||||
val collectionDate: Date,
|
||||
val collectionLocation: String,
|
||||
val collectionMethod: String,
|
||||
val collectedBy: String,
|
||||
val currentCustodian: String,
|
||||
val storageLocation: String?,
|
||||
val chainOfCustody: List<CustodyTransfer>,
|
||||
val metadata: EvidenceMetadata
|
||||
)
|
||||
|
||||
enum class EvidenceType {
|
||||
PHYSICAL,
|
||||
DIGITAL,
|
||||
BIOLOGICAL,
|
||||
CHEMICAL,
|
||||
FIREARM,
|
||||
DOCUMENT
|
||||
}
|
||||
|
||||
data class CustodyTransfer(
|
||||
val transferId: String,
|
||||
val timestamp: Date,
|
||||
val fromCustodian: String,
|
||||
val toCustodian: String,
|
||||
val reason: String,
|
||||
val evidenceCondition: String,
|
||||
val signature: DigitalSignature,
|
||||
val notes: String?
|
||||
)
|
||||
|
||||
data class DigitalSignature(
|
||||
val signatureId: String,
|
||||
val signerId: String,
|
||||
val signerName: String,
|
||||
val signatureDate: Date,
|
||||
val signatureData: ByteArray
|
||||
)
|
||||
|
||||
data class EvidenceMetadata(
|
||||
val tags: List<String> = emptyList(),
|
||||
val photos: List<String> = emptyList(),
|
||||
val documents: List<String> = emptyList()
|
||||
)
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.smoa.modules.evidence.domain
|
||||
|
||||
import com.smoa.modules.evidence.data.CustodyTransferDao
|
||||
import com.smoa.modules.evidence.data.EvidenceDao
|
||||
import com.smoa.modules.evidence.data.EvidenceEntity
|
||||
import com.smoa.modules.evidence.data.CustodyTransferEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class EvidenceRepository @Inject constructor(
|
||||
private val evidenceDao: EvidenceDao,
|
||||
private val custodyTransferDao: CustodyTransferDao
|
||||
) {
|
||||
|
||||
fun getAllEvidence(): Flow<List<Evidence>> {
|
||||
return evidenceDao.getAllEvidence().map { entities ->
|
||||
entities.map { it.toDomain(emptyList()) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getEvidenceById(evidenceId: String): Evidence? {
|
||||
val entity = evidenceDao.getEvidenceById(evidenceId) ?: return null
|
||||
val transfers = custodyTransferDao.getChainOfCustody(evidenceId)
|
||||
// Convert Flow to List (simplified - in production use proper async handling)
|
||||
return entity.toDomain(emptyList()) // Will need to load transfers separately
|
||||
}
|
||||
|
||||
fun getEvidenceByCase(caseNumber: String): Flow<List<Evidence>> {
|
||||
return evidenceDao.getEvidenceByCase(caseNumber).map { entities ->
|
||||
entities.map { it.toDomain(emptyList()) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun insertEvidence(evidence: Evidence) {
|
||||
evidenceDao.insertEvidence(evidence.toEntity())
|
||||
}
|
||||
|
||||
suspend fun addCustodyTransfer(transfer: CustodyTransfer) {
|
||||
custodyTransferDao.insertTransfer(transfer.toEntity())
|
||||
}
|
||||
|
||||
fun getChainOfCustody(evidenceId: String): Flow<List<CustodyTransfer>> {
|
||||
return custodyTransferDao.getChainOfCustody(evidenceId).map { entities ->
|
||||
entities.map { it.toDomain() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun EvidenceEntity.toDomain(transfers: List<CustodyTransfer>): Evidence {
|
||||
return Evidence(
|
||||
evidenceId = evidenceId,
|
||||
caseNumber = caseNumber,
|
||||
description = description,
|
||||
evidenceType = evidenceType,
|
||||
collectionDate = collectionDate,
|
||||
collectionLocation = collectionLocation,
|
||||
collectionMethod = collectionMethod,
|
||||
collectedBy = collectedBy,
|
||||
currentCustodian = currentCustodian,
|
||||
storageLocation = storageLocation,
|
||||
chainOfCustody = transfers,
|
||||
metadata = EvidenceMetadata()
|
||||
)
|
||||
}
|
||||
|
||||
private fun Evidence.toEntity(): EvidenceEntity {
|
||||
return EvidenceEntity(
|
||||
evidenceId = evidenceId,
|
||||
caseNumber = caseNumber,
|
||||
description = description,
|
||||
evidenceType = evidenceType,
|
||||
collectionDate = collectionDate,
|
||||
collectionLocation = collectionLocation,
|
||||
collectionMethod = collectionMethod,
|
||||
collectedBy = collectedBy,
|
||||
currentCustodian = currentCustodian,
|
||||
storageLocation = storageLocation,
|
||||
createdAt = Date(),
|
||||
updatedAt = Date()
|
||||
)
|
||||
}
|
||||
|
||||
private fun CustodyTransferEntity.toDomain(): CustodyTransfer {
|
||||
return CustodyTransfer(
|
||||
transferId = transferId,
|
||||
timestamp = timestamp,
|
||||
fromCustodian = fromCustodian,
|
||||
toCustodian = toCustodian,
|
||||
reason = reason,
|
||||
evidenceCondition = evidenceCondition,
|
||||
signature = DigitalSignature(
|
||||
signatureId = transferId,
|
||||
signerId = fromCustodian,
|
||||
signerName = fromCustodian,
|
||||
signatureDate = timestamp,
|
||||
signatureData = signatureData ?: ByteArray(0)
|
||||
),
|
||||
notes = notes
|
||||
)
|
||||
}
|
||||
|
||||
private fun CustodyTransfer.toEntity(): CustodyTransferEntity {
|
||||
return CustodyTransferEntity(
|
||||
transferId = transferId,
|
||||
evidenceId = "", // Should be set by caller
|
||||
timestamp = timestamp,
|
||||
fromCustodian = fromCustodian,
|
||||
toCustodian = toCustodian,
|
||||
reason = reason,
|
||||
evidenceCondition = evidenceCondition,
|
||||
signatureData = signature.signatureData,
|
||||
notes = notes
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.smoa.modules.evidence.domain
|
||||
|
||||
import com.smoa.core.security.AuditLogger
|
||||
import com.smoa.core.security.AuditEventType
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class EvidenceService @Inject constructor(
|
||||
private val repository: EvidenceRepository,
|
||||
private val auditLogger: AuditLogger
|
||||
) {
|
||||
|
||||
suspend fun createEvidence(
|
||||
caseNumber: String,
|
||||
description: String,
|
||||
evidenceType: EvidenceType,
|
||||
collectionLocation: String,
|
||||
collectionMethod: String,
|
||||
collectedBy: String,
|
||||
metadata: EvidenceMetadata = EvidenceMetadata()
|
||||
): Result<Evidence> {
|
||||
return try {
|
||||
val evidence = Evidence(
|
||||
evidenceId = UUID.randomUUID().toString(),
|
||||
caseNumber = caseNumber,
|
||||
description = description,
|
||||
evidenceType = evidenceType,
|
||||
collectionDate = Date(),
|
||||
collectionLocation = collectionLocation,
|
||||
collectionMethod = collectionMethod,
|
||||
collectedBy = collectedBy,
|
||||
currentCustodian = collectedBy,
|
||||
storageLocation = null,
|
||||
chainOfCustody = emptyList(),
|
||||
metadata = metadata
|
||||
)
|
||||
|
||||
repository.insertEvidence(evidence)
|
||||
auditLogger.logEvent(
|
||||
AuditEventType.CREDENTIAL_ACCESS,
|
||||
userId = collectedBy,
|
||||
module = "evidence",
|
||||
details = "Evidence created: ${evidence.evidenceId}"
|
||||
)
|
||||
|
||||
Result.success(evidence)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun transferCustody(
|
||||
evidenceId: String,
|
||||
fromCustodian: String,
|
||||
toCustodian: String,
|
||||
reason: String,
|
||||
evidenceCondition: String,
|
||||
signature: DigitalSignature,
|
||||
notes: String?
|
||||
): Result<CustodyTransfer> {
|
||||
return try {
|
||||
val transfer = CustodyTransfer(
|
||||
transferId = UUID.randomUUID().toString(),
|
||||
timestamp = Date(),
|
||||
fromCustodian = fromCustodian,
|
||||
toCustodian = toCustodian,
|
||||
reason = reason,
|
||||
evidenceCondition = evidenceCondition,
|
||||
signature = signature,
|
||||
notes = notes
|
||||
)
|
||||
|
||||
// In production, update evidence currentCustodian
|
||||
repository.addCustodyTransfer(transfer)
|
||||
auditLogger.logEvent(
|
||||
AuditEventType.CREDENTIAL_ACCESS,
|
||||
userId = fromCustodian,
|
||||
module = "evidence",
|
||||
details = "Custody transferred: $evidenceId from $fromCustodian to $toCustodian"
|
||||
)
|
||||
|
||||
Result.success(transfer)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun getAllEvidence(): Flow<List<Evidence>> = repository.getAllEvidence()
|
||||
fun getEvidenceByCase(caseNumber: String): Flow<List<Evidence>> = repository.getEvidenceByCase(caseNumber)
|
||||
fun getChainOfCustody(evidenceId: String): Flow<List<CustodyTransfer>> = repository.getChainOfCustody(evidenceId)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.smoa.modules.evidence.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun ChainOfCustodyScreen(
|
||||
evidenceId: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Chain of Custody",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.smoa.modules.evidence.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun EvidenceListScreen(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Evidence",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.smoa.modules.evidence.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun EvidenceModule(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Evidence Chain of Custody",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
50
modules/intelligence/build.gradle.kts
Normal file
50
modules/intelligence/build.gradle.kts
Normal file
@@ -0,0 +1,50 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("kotlin-kapt")
|
||||
id("dagger.hilt.android.plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.smoa.modules.intelligence"
|
||||
compileSdk = AppConfig.compileSdk
|
||||
|
||||
defaultConfig {
|
||||
minSdk = AppConfig.minSdk
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:common"))
|
||||
implementation(project(":core:auth"))
|
||||
implementation(project(":core:security"))
|
||||
|
||||
implementation(platform(Dependencies.composeBom))
|
||||
implementation(Dependencies.composeUi)
|
||||
implementation(Dependencies.composeMaterial3)
|
||||
implementation(Dependencies.androidxCoreKtx)
|
||||
|
||||
implementation(Dependencies.hiltAndroid)
|
||||
kapt(Dependencies.hiltAndroidCompiler)
|
||||
|
||||
implementation(Dependencies.coroutinesCore)
|
||||
implementation(Dependencies.coroutinesAndroid)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.smoa.modules.intelligence
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun IntelligenceModule(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Intelligence Operations",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.smoa.modules.intelligence.domain
|
||||
|
||||
/**
|
||||
* Compartmented access control framework for intelligence operations.
|
||||
*/
|
||||
data class Compartment(
|
||||
val compartmentId: String,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val accessLevel: AccessLevel,
|
||||
val controllingAgency: String,
|
||||
val authorizedPersonnel: List<String>
|
||||
)
|
||||
|
||||
enum class AccessLevel {
|
||||
UNCLASSIFIED,
|
||||
CONFIDENTIAL,
|
||||
SECRET,
|
||||
TOP_SECRET,
|
||||
TS_SCI // Top Secret - Sensitive Compartmented Information
|
||||
}
|
||||
|
||||
/**
|
||||
* Need-to-know enforcement.
|
||||
*/
|
||||
data class NeedToKnow(
|
||||
val compartmentId: String,
|
||||
val userId: String,
|
||||
val justification: String,
|
||||
val authorizedBy: String,
|
||||
val authorizationDate: java.util.Date,
|
||||
val expirationDate: java.util.Date?
|
||||
)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.smoa.modules.intelligence.domain
|
||||
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Compartment manager for multi-level security (MLS) system.
|
||||
*/
|
||||
@Singleton
|
||||
class CompartmentManager @Inject constructor() {
|
||||
|
||||
private val compartments = mutableMapOf<String, Compartment>()
|
||||
private val userCompartments = mutableMapOf<String, Set<String>>()
|
||||
private val needToKnowRecords = mutableMapOf<String, List<NeedToKnow>>()
|
||||
|
||||
/**
|
||||
* Register a compartment.
|
||||
*/
|
||||
fun registerCompartment(compartment: Compartment) {
|
||||
compartments[compartment.compartmentId] = compartment
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has access to compartment.
|
||||
*/
|
||||
fun hasAccess(userId: String, compartmentId: String): Boolean {
|
||||
val userComps = userCompartments[userId] ?: return false
|
||||
return userComps.contains(compartmentId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check need-to-know for compartment access.
|
||||
*/
|
||||
fun hasNeedToKnow(userId: String, compartmentId: String): Boolean {
|
||||
val records = needToKnowRecords[userId] ?: return false
|
||||
val now = java.util.Date()
|
||||
return records.any {
|
||||
it.compartmentId == compartmentId &&
|
||||
it.expirationDate?.after(now) != false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant compartment access to user.
|
||||
*/
|
||||
fun grantAccess(userId: String, compartmentId: String) {
|
||||
val current = userCompartments[userId] ?: emptySet()
|
||||
userCompartments[userId] = current + compartmentId
|
||||
}
|
||||
|
||||
/**
|
||||
* Add need-to-know authorization.
|
||||
*/
|
||||
fun addNeedToKnow(needToKnow: NeedToKnow) {
|
||||
val current = needToKnowRecords[needToKnow.userId] ?: emptyList()
|
||||
needToKnowRecords[needToKnow.userId] = current + needToKnow
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.smoa.modules.intelligence.domain
|
||||
|
||||
import com.smoa.core.security.AuditLogger
|
||||
import com.smoa.core.security.AuditEventType
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Intelligence operations service.
|
||||
*/
|
||||
@Singleton
|
||||
class IntelligenceService @Inject constructor(
|
||||
private val compartmentManager: CompartmentManager,
|
||||
private val auditLogger: AuditLogger
|
||||
) {
|
||||
|
||||
/**
|
||||
* Register compartment.
|
||||
*/
|
||||
suspend fun registerCompartment(compartment: Compartment) {
|
||||
compartmentManager.registerCompartment(compartment)
|
||||
auditLogger.logEvent(
|
||||
AuditEventType.POLICY_UPDATE,
|
||||
userId = compartment.controllingAgency,
|
||||
module = "intelligence",
|
||||
details = "Compartment registered: ${compartment.compartmentId}"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check compartment access.
|
||||
*/
|
||||
fun checkCompartmentAccess(userId: String, compartmentId: String): Boolean {
|
||||
return compartmentManager.hasAccess(userId, compartmentId) &&
|
||||
compartmentManager.hasNeedToKnow(userId, compartmentId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create protected source.
|
||||
*/
|
||||
suspend fun createProtectedSource(
|
||||
sourceType: SourceType,
|
||||
codename: String?,
|
||||
description: String,
|
||||
protectionLevel: ProtectionLevel,
|
||||
authorizedHandlers: List<String>
|
||||
): Result<ProtectedSource> {
|
||||
return try {
|
||||
val source = ProtectedSource(
|
||||
sourceId = UUID.randomUUID().toString(),
|
||||
sourceType = sourceType,
|
||||
codename = codename,
|
||||
description = description,
|
||||
protectionLevel = protectionLevel,
|
||||
authorizedHandlers = authorizedHandlers,
|
||||
creationDate = Date(),
|
||||
lastAccessDate = null
|
||||
)
|
||||
|
||||
auditLogger.logEvent(
|
||||
AuditEventType.CREDENTIAL_ACCESS,
|
||||
userId = authorizedHandlers.firstOrNull() ?: "system",
|
||||
module = "intelligence",
|
||||
details = "Protected source created: ${source.sourceId}"
|
||||
)
|
||||
|
||||
Result.success(source)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.smoa.modules.intelligence.domain
|
||||
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Source protection framework for intelligence operations.
|
||||
*/
|
||||
data class ProtectedSource(
|
||||
val sourceId: String,
|
||||
val sourceType: SourceType,
|
||||
val codename: String?,
|
||||
val description: String,
|
||||
val protectionLevel: ProtectionLevel,
|
||||
val authorizedHandlers: List<String>,
|
||||
val creationDate: Date,
|
||||
val lastAccessDate: Date?
|
||||
)
|
||||
|
||||
enum class SourceType {
|
||||
HUMAN_INTELLIGENCE,
|
||||
SIGNALS_INTELLIGENCE,
|
||||
IMAGERY_INTELLIGENCE,
|
||||
OPEN_SOURCE,
|
||||
OTHER
|
||||
}
|
||||
|
||||
enum class ProtectionLevel {
|
||||
ROUTINE,
|
||||
SENSITIVE,
|
||||
HIGHLY_SENSITIVE,
|
||||
CRITICAL
|
||||
}
|
||||
|
||||
data class SourceHandlingRecord(
|
||||
val recordId: String,
|
||||
val sourceId: String,
|
||||
val handlerId: String,
|
||||
val action: HandlingAction,
|
||||
val timestamp: Date,
|
||||
val notes: String?
|
||||
)
|
||||
|
||||
enum class HandlingAction {
|
||||
ACCESSED,
|
||||
MODIFIED,
|
||||
SHARED,
|
||||
ARCHIVED,
|
||||
DESTROYED
|
||||
}
|
||||
|
||||
52
modules/judicial/build.gradle.kts
Normal file
52
modules/judicial/build.gradle.kts
Normal file
@@ -0,0 +1,52 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("kotlin-kapt")
|
||||
id("dagger.hilt.android.plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.smoa.modules.judicial"
|
||||
compileSdk = AppConfig.compileSdk
|
||||
|
||||
defaultConfig {
|
||||
minSdk = AppConfig.minSdk
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:common"))
|
||||
implementation(project(":core:auth"))
|
||||
implementation(project(":core:security"))
|
||||
implementation(project(":modules:orders"))
|
||||
implementation(project(":modules:evidence"))
|
||||
|
||||
implementation(platform(Dependencies.composeBom))
|
||||
implementation(Dependencies.composeUi)
|
||||
implementation(Dependencies.composeMaterial3)
|
||||
implementation(Dependencies.androidxCoreKtx)
|
||||
|
||||
implementation(Dependencies.hiltAndroid)
|
||||
kapt(Dependencies.hiltAndroidCompiler)
|
||||
|
||||
implementation(Dependencies.coroutinesCore)
|
||||
implementation(Dependencies.coroutinesAndroid)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.smoa.modules.judicial
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun JudicialModule(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Judicial Operations",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.smoa.modules.judicial.domain
|
||||
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Case file data model for judicial case management.
|
||||
*/
|
||||
data class CaseFile(
|
||||
val caseId: String,
|
||||
val caseNumber: String,
|
||||
val courtName: String,
|
||||
val caseType: CaseType,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val filingDate: Date,
|
||||
val status: CaseStatus,
|
||||
val parties: List<Party>,
|
||||
val documents: List<CaseDocument>,
|
||||
val orders: List<String>, // Order IDs
|
||||
val docketEntries: List<DocketEntry>
|
||||
)
|
||||
|
||||
enum class CaseType {
|
||||
CRIMINAL,
|
||||
CIVIL,
|
||||
FAMILY,
|
||||
PROBATE,
|
||||
TRAFFIC
|
||||
}
|
||||
|
||||
enum class CaseStatus {
|
||||
OPEN,
|
||||
CLOSED,
|
||||
APPEALED,
|
||||
PENDING
|
||||
}
|
||||
|
||||
data class Party(
|
||||
val partyId: String,
|
||||
val name: String,
|
||||
val role: PartyRole,
|
||||
val contactInfo: ContactInfo
|
||||
)
|
||||
|
||||
enum class PartyRole {
|
||||
PLAINTIFF,
|
||||
DEFENDANT,
|
||||
WITNESS,
|
||||
ATTORNEY
|
||||
}
|
||||
|
||||
data class ContactInfo(
|
||||
val address: String?,
|
||||
val phone: String?,
|
||||
val email: String?
|
||||
)
|
||||
|
||||
data class CaseDocument(
|
||||
val documentId: String,
|
||||
val fileName: String,
|
||||
val documentType: DocumentType,
|
||||
val uploadDate: Date,
|
||||
val uploadedBy: String
|
||||
)
|
||||
|
||||
enum class DocumentType {
|
||||
COMPLAINT,
|
||||
MOTION,
|
||||
BRIEF,
|
||||
EVIDENCE,
|
||||
ORDER,
|
||||
OTHER
|
||||
}
|
||||
|
||||
data class DocketEntry(
|
||||
val entryId: String,
|
||||
val entryDate: Date,
|
||||
val description: String,
|
||||
val entryType: DocketEntryType
|
||||
)
|
||||
|
||||
enum class DocketEntryType {
|
||||
FILING,
|
||||
HEARING,
|
||||
ORDER,
|
||||
JUDGMENT
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.smoa.modules.judicial.domain
|
||||
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Court order data model.
|
||||
*/
|
||||
data class CourtOrder(
|
||||
val orderId: String,
|
||||
val courtName: String,
|
||||
val caseNumber: String,
|
||||
val orderType: CourtOrderType,
|
||||
val title: String,
|
||||
val content: String,
|
||||
val judgeName: String,
|
||||
val issuedDate: Date,
|
||||
val effectiveDate: Date,
|
||||
val expirationDate: Date?,
|
||||
val status: OrderStatus,
|
||||
val executionStatus: ExecutionStatus,
|
||||
val signatures: List<DigitalSignature>
|
||||
)
|
||||
|
||||
enum class CourtOrderType {
|
||||
SEARCH_WARRANT,
|
||||
ARREST_WARRANT,
|
||||
SUBPOENA,
|
||||
RESTRAINING_ORDER,
|
||||
COURT_ORDER
|
||||
}
|
||||
|
||||
enum class OrderStatus {
|
||||
DRAFT,
|
||||
SIGNED,
|
||||
ISSUED,
|
||||
EXECUTED,
|
||||
EXPIRED,
|
||||
REVOKED
|
||||
}
|
||||
|
||||
enum class ExecutionStatus {
|
||||
NOT_EXECUTED,
|
||||
EXECUTING,
|
||||
EXECUTED,
|
||||
FAILED
|
||||
}
|
||||
|
||||
data class DigitalSignature(
|
||||
val signatureId: String,
|
||||
val signerId: String,
|
||||
val signatureDate: Date,
|
||||
val signatureData: ByteArray
|
||||
)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.smoa.modules.judicial.domain
|
||||
|
||||
import com.smoa.core.security.AuditLogger
|
||||
import com.smoa.core.security.AuditEventType
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Judicial operations service.
|
||||
*/
|
||||
@Singleton
|
||||
class JudicialService @Inject constructor(
|
||||
private val auditLogger: AuditLogger
|
||||
) {
|
||||
|
||||
/**
|
||||
* Create court order.
|
||||
*/
|
||||
suspend fun createCourtOrder(
|
||||
courtName: String,
|
||||
caseNumber: String,
|
||||
orderType: CourtOrderType,
|
||||
title: String,
|
||||
content: String,
|
||||
judgeName: String,
|
||||
effectiveDate: Date,
|
||||
expirationDate: Date?
|
||||
): Result<CourtOrder> {
|
||||
return try {
|
||||
val order = CourtOrder(
|
||||
orderId = UUID.randomUUID().toString(),
|
||||
courtName = courtName,
|
||||
caseNumber = caseNumber,
|
||||
orderType = orderType,
|
||||
title = title,
|
||||
content = content,
|
||||
judgeName = judgeName,
|
||||
issuedDate = Date(),
|
||||
effectiveDate = effectiveDate,
|
||||
expirationDate = expirationDate,
|
||||
status = OrderStatus.DRAFT,
|
||||
executionStatus = ExecutionStatus.NOT_EXECUTED,
|
||||
signatures = emptyList()
|
||||
)
|
||||
|
||||
auditLogger.logEvent(
|
||||
AuditEventType.POLICY_UPDATE,
|
||||
userId = judgeName,
|
||||
module = "judicial",
|
||||
details = "Court order created: ${order.orderId}"
|
||||
)
|
||||
|
||||
Result.success(order)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create case file.
|
||||
*/
|
||||
suspend fun createCaseFile(
|
||||
caseNumber: String,
|
||||
courtName: String,
|
||||
caseType: CaseType,
|
||||
title: String,
|
||||
description: String
|
||||
): Result<CaseFile> {
|
||||
return try {
|
||||
val caseFile = CaseFile(
|
||||
caseId = UUID.randomUUID().toString(),
|
||||
caseNumber = caseNumber,
|
||||
courtName = courtName,
|
||||
caseType = caseType,
|
||||
title = title,
|
||||
description = description,
|
||||
filingDate = Date(),
|
||||
status = CaseStatus.OPEN,
|
||||
parties = emptyList(),
|
||||
documents = emptyList(),
|
||||
orders = emptyList(),
|
||||
docketEntries = emptyList()
|
||||
)
|
||||
|
||||
Result.success(caseFile)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create subpoena.
|
||||
*/
|
||||
suspend fun createSubpoena(
|
||||
caseNumber: String,
|
||||
courtName: String,
|
||||
subpoenaType: SubpoenaType,
|
||||
recipientName: String,
|
||||
recipientAddress: String,
|
||||
description: String,
|
||||
requestedDate: Date,
|
||||
issuedBy: String
|
||||
): Result<Subpoena> {
|
||||
return try {
|
||||
val subpoena = Subpoena(
|
||||
subpoenaId = UUID.randomUUID().toString(),
|
||||
caseNumber = caseNumber,
|
||||
courtName = courtName,
|
||||
subpoenaType = subpoenaType,
|
||||
recipientName = recipientName,
|
||||
recipientAddress = recipientAddress,
|
||||
recipientContact = "",
|
||||
description = description,
|
||||
requestedDate = requestedDate,
|
||||
serviceDate = null,
|
||||
status = SubpoenaStatus.DRAFT,
|
||||
servedBy = null,
|
||||
issuedBy = issuedBy,
|
||||
issueDate = Date()
|
||||
)
|
||||
|
||||
Result.success(subpoena)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.smoa.modules.judicial.domain
|
||||
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Subpoena data model.
|
||||
*/
|
||||
data class Subpoena(
|
||||
val subpoenaId: String,
|
||||
val caseNumber: String,
|
||||
val courtName: String,
|
||||
val subpoenaType: SubpoenaType,
|
||||
val recipientName: String,
|
||||
val recipientAddress: String,
|
||||
val recipientContact: String,
|
||||
val description: String,
|
||||
val requestedDate: Date,
|
||||
val serviceDate: Date?,
|
||||
val status: SubpoenaStatus,
|
||||
val servedBy: String?,
|
||||
val issuedBy: String,
|
||||
val issueDate: Date
|
||||
)
|
||||
|
||||
enum class SubpoenaType {
|
||||
DUCES_TECUM, // Produce documents
|
||||
AD_TESTIFICANDUM, // Testify
|
||||
DEPOSITION
|
||||
}
|
||||
|
||||
enum class SubpoenaStatus {
|
||||
DRAFT,
|
||||
ISSUED,
|
||||
SERVED,
|
||||
COMPLIED,
|
||||
QUASHED
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.smoa.modules.judicial.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun CourtOrderScreen(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Court Order",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
58
modules/meetings/build.gradle.kts
Normal file
58
modules/meetings/build.gradle.kts
Normal file
@@ -0,0 +1,58 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("kotlin-kapt")
|
||||
id("dagger.hilt.android.plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.smoa.modules.meetings"
|
||||
compileSdk = AppConfig.compileSdk
|
||||
|
||||
defaultConfig {
|
||||
minSdk = AppConfig.minSdk
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:common"))
|
||||
implementation(project(":core:auth"))
|
||||
implementation(project(":core:security"))
|
||||
implementation(project(":modules:communications"))
|
||||
|
||||
implementation(platform(Dependencies.composeBom))
|
||||
implementation(Dependencies.composeUi)
|
||||
implementation(Dependencies.composeUiGraphics)
|
||||
implementation(Dependencies.composeMaterial3)
|
||||
implementation(Dependencies.androidxCoreKtx)
|
||||
implementation(Dependencies.androidxLifecycleRuntimeKtx)
|
||||
|
||||
implementation(Dependencies.hiltAndroid)
|
||||
kapt(Dependencies.hiltAndroidCompiler)
|
||||
|
||||
implementation(Dependencies.retrofit)
|
||||
implementation(Dependencies.okHttp)
|
||||
|
||||
// WebRTC - TODO: Configure WebRTC dependency
|
||||
// WebRTC library needs to be built from source or obtained separately
|
||||
// Uncomment when WebRTC is available:
|
||||
// implementation(Dependencies.webrtc)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.smoa.modules.meetings
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.smoa.core.auth.RBACFramework
|
||||
import com.smoa.modules.meetings.domain.MeetingsService
|
||||
import com.smoa.modules.meetings.ui.MeetingsScreen
|
||||
|
||||
/**
|
||||
* Meetings module - Encrypted coordination for meetings, briefings, and conferences.
|
||||
*/
|
||||
@Composable
|
||||
fun MeetingsModule(
|
||||
meetingsService: MeetingsService,
|
||||
userRole: RBACFramework.Role,
|
||||
userId: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
MeetingsScreen(
|
||||
meetingsService = meetingsService,
|
||||
userRole = userRole,
|
||||
userId = userId,
|
||||
modifier = modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.smoa.modules.meetings.domain
|
||||
|
||||
import com.smoa.core.auth.RBACFramework
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Meeting room manager.
|
||||
* Manages meeting rooms and access control.
|
||||
*/
|
||||
@Singleton
|
||||
class MeetingRoomManager @Inject constructor() {
|
||||
private val meetings = mutableMapOf<String, MeetingRoom>()
|
||||
|
||||
/**
|
||||
* Get meeting by ID.
|
||||
*/
|
||||
fun getMeeting(meetingId: String): MeetingRoom? {
|
||||
return meetings[meetingId]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available meetings for user based on role.
|
||||
*/
|
||||
fun getAvailableMeetings(userRole: RBACFramework.Role): List<MeetingRoom> {
|
||||
return meetings.values.filter { meeting ->
|
||||
hasAccess(meeting, userRole)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has access to meeting.
|
||||
*/
|
||||
fun hasAccess(meeting: MeetingRoom, userRole: RBACFramework.Role): Boolean {
|
||||
// Admins can access all meetings
|
||||
if (userRole == RBACFramework.Role.ADMIN) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check role authorization
|
||||
return meeting.allowedRoles.contains(userRole)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new meeting room.
|
||||
*/
|
||||
fun createMeeting(
|
||||
name: String,
|
||||
description: String?,
|
||||
hostId: String,
|
||||
allowedRoles: Set<RBACFramework.Role>
|
||||
): MeetingRoom {
|
||||
val meeting = MeetingRoom(
|
||||
id = UUID.randomUUID().toString(),
|
||||
name = name,
|
||||
description = description,
|
||||
hostId = hostId,
|
||||
allowedRoles = allowedRoles,
|
||||
allowScreenSharing = false, // Default: disabled
|
||||
allowFileTransfer = false, // Default: disabled
|
||||
allowExternalParticipants = false // Default: disabled per spec
|
||||
)
|
||||
|
||||
meetings[meeting.id] = meeting
|
||||
return meeting
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a meeting room.
|
||||
*/
|
||||
fun removeMeeting(meetingId: String) {
|
||||
meetings.remove(meetingId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Meeting room.
|
||||
*/
|
||||
data class MeetingRoom(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String?,
|
||||
val hostId: String,
|
||||
val allowedRoles: Set<RBACFramework.Role>,
|
||||
val allowScreenSharing: Boolean,
|
||||
val allowFileTransfer: Boolean,
|
||||
val allowExternalParticipants: Boolean
|
||||
)
|
||||
|
||||
/**
|
||||
* Meeting participant.
|
||||
*/
|
||||
data class Participant(
|
||||
val userId: String,
|
||||
val userName: String,
|
||||
val role: RBACFramework.Role,
|
||||
val joinedAt: Date,
|
||||
val isHost: Boolean = false
|
||||
)
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package com.smoa.modules.meetings.domain
|
||||
|
||||
import com.smoa.core.auth.RBACFramework
|
||||
import com.smoa.core.common.Result
|
||||
import com.smoa.core.security.AuditLogger
|
||||
import com.smoa.core.security.AuditEventType
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Meetings service for secure audio and video conferencing.
|
||||
* Supports role-restricted meeting room access and identity-verified participant entry.
|
||||
*/
|
||||
@Singleton
|
||||
class MeetingsService @Inject constructor(
|
||||
private val meetingRoomManager: MeetingRoomManager,
|
||||
private val videoTransport: VideoTransport,
|
||||
private val auditLogger: AuditLogger,
|
||||
private val rbacFramework: RBACFramework
|
||||
) {
|
||||
private val _currentMeeting = MutableStateFlow<MeetingRoom?>(null)
|
||||
val currentMeeting: StateFlow<MeetingRoom?> = _currentMeeting.asStateFlow()
|
||||
|
||||
private val _participants = MutableStateFlow<List<Participant>>(emptyList())
|
||||
val participants: StateFlow<List<Participant>> = _participants.asStateFlow()
|
||||
|
||||
/**
|
||||
* Join a meeting room.
|
||||
*/
|
||||
suspend fun joinMeeting(
|
||||
meetingId: String,
|
||||
userRole: RBACFramework.Role,
|
||||
userId: String,
|
||||
requireStepUpAuth: Boolean = true
|
||||
): Result<MeetingRoom> {
|
||||
val meeting = meetingRoomManager.getMeeting(meetingId) ?: return Result.Error(
|
||||
IllegalArgumentException("Meeting not found: $meetingId")
|
||||
)
|
||||
|
||||
// Check authorization
|
||||
if (!meetingRoomManager.hasAccess(meeting, userRole)) {
|
||||
return Result.Error(SecurityException("Access denied to meeting: $meetingId"))
|
||||
}
|
||||
|
||||
// Step-up authentication required for joining
|
||||
if (requireStepUpAuth) {
|
||||
// This would trigger step-up authentication in UI
|
||||
// For now, we assume it's handled by the caller
|
||||
}
|
||||
|
||||
// Join meeting via video transport
|
||||
val joinResult = videoTransport.joinMeeting(meetingId, userId)
|
||||
return when (joinResult) {
|
||||
is Result.Success -> {
|
||||
_currentMeeting.value = meeting
|
||||
val participant = Participant(
|
||||
userId = userId,
|
||||
userName = userId, // Would be resolved from user service
|
||||
role = userRole,
|
||||
joinedAt = Date(),
|
||||
isHost = meeting.hostId == userId
|
||||
)
|
||||
_participants.value = _participants.value + participant
|
||||
|
||||
auditLogger.logEvent(
|
||||
AuditEventType.MEETING_JOINED,
|
||||
mapOf(
|
||||
"meetingId" to meetingId,
|
||||
"userId" to userId,
|
||||
"timestamp" to Date().toString()
|
||||
)
|
||||
)
|
||||
Result.Success(meeting)
|
||||
}
|
||||
is Result.Error -> joinResult
|
||||
is Result.Loading -> Result.Error(Exception("Unexpected loading state"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave current meeting.
|
||||
*/
|
||||
suspend fun leaveMeeting(): Result<Unit> {
|
||||
val meeting = _currentMeeting.value ?: return Result.Error(
|
||||
IllegalStateException("Not in any meeting")
|
||||
)
|
||||
|
||||
val result = videoTransport.leaveMeeting(meeting.id)
|
||||
when (result) {
|
||||
is Result.Success -> {
|
||||
_currentMeeting.value = null
|
||||
_participants.value = emptyList()
|
||||
|
||||
auditLogger.logEvent(
|
||||
AuditEventType.MEETING_LEFT,
|
||||
mapOf(
|
||||
"meetingId" to meeting.id,
|
||||
"timestamp" to Date().toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new meeting room.
|
||||
*/
|
||||
suspend fun createMeeting(
|
||||
name: String,
|
||||
description: String?,
|
||||
hostId: String,
|
||||
userRole: RBACFramework.Role,
|
||||
allowedRoles: Set<RBACFramework.Role>? = null
|
||||
): Result<MeetingRoom> {
|
||||
// Only operators and admins can create meetings
|
||||
if (userRole != RBACFramework.Role.OPERATOR && userRole != RBACFramework.Role.ADMIN) {
|
||||
return Result.Error(SecurityException("Only operators and admins can create meetings"))
|
||||
}
|
||||
|
||||
val meeting = meetingRoomManager.createMeeting(
|
||||
name = name,
|
||||
description = description,
|
||||
hostId = hostId,
|
||||
allowedRoles = allowedRoles ?: setOf(RBACFramework.Role.OPERATOR, RBACFramework.Role.ADMIN)
|
||||
)
|
||||
|
||||
auditLogger.logEvent(
|
||||
AuditEventType.MEETING_CREATED,
|
||||
mapOf(
|
||||
"meetingId" to meeting.id,
|
||||
"hostId" to hostId,
|
||||
"timestamp" to Date().toString()
|
||||
)
|
||||
)
|
||||
|
||||
return Result.Success(meeting)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available meetings for user.
|
||||
*/
|
||||
suspend fun getAvailableMeetings(userRole: RBACFramework.Role): List<MeetingRoom> {
|
||||
return meetingRoomManager.getAvailableMeetings(userRole)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle screen sharing (policy-controlled).
|
||||
*/
|
||||
suspend fun toggleScreenSharing(enabled: Boolean): Result<Unit> {
|
||||
val meeting = _currentMeeting.value ?: return Result.Error(
|
||||
IllegalStateException("Not in any meeting")
|
||||
)
|
||||
|
||||
// Check if screen sharing is allowed by policy
|
||||
if (!meeting.allowScreenSharing) {
|
||||
return Result.Error(SecurityException("Screen sharing not allowed in this meeting"))
|
||||
}
|
||||
|
||||
return videoTransport.toggleScreenSharing(enabled)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle file transfer (policy-controlled).
|
||||
*/
|
||||
suspend fun toggleFileTransfer(enabled: Boolean): Result<Unit> {
|
||||
val meeting = _currentMeeting.value ?: return Result.Error(
|
||||
IllegalStateException("Not in any meeting")
|
||||
)
|
||||
|
||||
// Check if file transfer is allowed by policy
|
||||
if (!meeting.allowFileTransfer) {
|
||||
return Result.Error(SecurityException("File transfer not allowed in this meeting"))
|
||||
}
|
||||
|
||||
return videoTransport.toggleFileTransfer(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.smoa.modules.meetings.domain
|
||||
|
||||
import com.smoa.core.common.Result
|
||||
import com.smoa.modules.communications.domain.WebRTCPeerConnection
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Video transport for encrypted video conferencing.
|
||||
* Uses WebRTC for peer-to-peer encrypted audio/video transmission.
|
||||
*/
|
||||
@Singleton
|
||||
class VideoTransport @Inject constructor(
|
||||
private val webRTCManager: com.smoa.modules.communications.domain.WebRTCManager
|
||||
) {
|
||||
private val _connectionState = MutableStateFlow<MeetingConnectionState>(MeetingConnectionState.Disconnected)
|
||||
val connectionState: StateFlow<MeetingConnectionState> = _connectionState.asStateFlow()
|
||||
|
||||
private var currentMeetingId: String? = null
|
||||
private var isScreenSharing = false
|
||||
private var isFileTransferEnabled = false
|
||||
private var peerConnection: WebRTCPeerConnection? = null
|
||||
|
||||
/**
|
||||
* Join a meeting.
|
||||
*/
|
||||
suspend fun joinMeeting(meetingId: String, userId: String): Result<Unit> {
|
||||
return try {
|
||||
_connectionState.value = MeetingConnectionState.Connecting(meetingId)
|
||||
|
||||
// Initialize WebRTC peer connection (audio + video)
|
||||
val connectionResult = webRTCManager.initializePeerConnection(meetingId, isAudioOnly = false)
|
||||
|
||||
when (connectionResult) {
|
||||
is Result.Success -> {
|
||||
peerConnection = connectionResult.data
|
||||
currentMeetingId = meetingId
|
||||
|
||||
// Start audio and video transmission
|
||||
peerConnection?.let { connection ->
|
||||
webRTCManager.startAudioTransmission(connection)
|
||||
webRTCManager.startVideoTransmission(connection)
|
||||
}
|
||||
|
||||
_connectionState.value = MeetingConnectionState.Connected(meetingId)
|
||||
Result.Success(Unit)
|
||||
}
|
||||
is Result.Error -> {
|
||||
_connectionState.value = MeetingConnectionState.Error(
|
||||
connectionResult.exception.message ?: "Failed to connect"
|
||||
)
|
||||
Result.Error(connectionResult.exception)
|
||||
}
|
||||
is Result.Loading -> {
|
||||
_connectionState.value = MeetingConnectionState.Error("Unexpected loading state")
|
||||
Result.Error(Exception("Unexpected loading state"))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_connectionState.value = MeetingConnectionState.Error(e.message ?: "Unknown error")
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave current meeting.
|
||||
*/
|
||||
suspend fun leaveMeeting(meetingId: String): Result<Unit> {
|
||||
return try {
|
||||
if (isScreenSharing) {
|
||||
toggleScreenSharing(false)
|
||||
}
|
||||
|
||||
// Stop audio and video transmission
|
||||
peerConnection?.let { connection ->
|
||||
webRTCManager.stopAudioTransmission(connection)
|
||||
webRTCManager.stopVideoTransmission(connection)
|
||||
webRTCManager.closePeerConnection(connection)
|
||||
}
|
||||
|
||||
peerConnection = null
|
||||
currentMeetingId = null
|
||||
_connectionState.value = MeetingConnectionState.Disconnected
|
||||
Result.Success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle screen sharing.
|
||||
*/
|
||||
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
|
||||
|
||||
isScreenSharing = enabled
|
||||
Result.Success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle file transfer.
|
||||
*/
|
||||
suspend fun toggleFileTransfer(enabled: Boolean): Result<Unit> {
|
||||
return try {
|
||||
// TODO: Implement actual file transfer capability
|
||||
isFileTransferEnabled = enabled
|
||||
Result.Success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Meeting connection state.
|
||||
*/
|
||||
sealed class MeetingConnectionState {
|
||||
object Disconnected : MeetingConnectionState()
|
||||
data class Connecting(val meetingId: String) : MeetingConnectionState()
|
||||
data class Connected(val meetingId: String) : MeetingConnectionState()
|
||||
data class Error(val message: String) : MeetingConnectionState()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package com.smoa.modules.meetings.ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.smoa.core.auth.RBACFramework
|
||||
import com.smoa.modules.meetings.domain.MeetingRoom
|
||||
import com.smoa.modules.meetings.domain.MeetingsService
|
||||
|
||||
/**
|
||||
* Meetings screen with meeting list and controls.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MeetingsScreen(
|
||||
meetingsService: MeetingsService,
|
||||
userRole: RBACFramework.Role,
|
||||
userId: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var meetings by remember { mutableStateOf<List<MeetingRoom>>(emptyList()) }
|
||||
var currentMeeting by remember { mutableStateOf<MeetingRoom?>(null) }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Load available meetings
|
||||
LaunchedEffect(userRole) {
|
||||
isLoading = true
|
||||
errorMessage = null
|
||||
try {
|
||||
meetings = meetingsService.getAvailableMeetings(userRole)
|
||||
} catch (e: Exception) {
|
||||
errorMessage = e.message
|
||||
} finally {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
// Observe current meeting
|
||||
LaunchedEffect(Unit) {
|
||||
meetingsService.currentMeeting.collect { meeting ->
|
||||
currentMeeting = meeting
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Secure Meetings",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
|
||||
// Create meeting button (for operators/admins)
|
||||
if (userRole == RBACFramework.Role.OPERATOR || userRole == RBACFramework.Role.ADMIN) {
|
||||
Button(onClick = {
|
||||
// Show create meeting dialog
|
||||
}) {
|
||||
Text("Create")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Current meeting indicator
|
||||
currentMeeting?.let { meeting ->
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "In Meeting: ${meeting.name}",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
meeting.description?.let { desc ->
|
||||
Text(
|
||||
text = desc,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
// Leave meeting
|
||||
},
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
) {
|
||||
Text("Leave Meeting")
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// Error message
|
||||
errorMessage?.let { error ->
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// Meeting list
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(meetings) { meeting ->
|
||||
MeetingCard(
|
||||
meeting = meeting,
|
||||
isActive = currentMeeting?.id == meeting.id,
|
||||
onClick = {
|
||||
// Join meeting (would require step-up auth)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Meeting card.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MeetingCard(
|
||||
meeting: MeetingRoom,
|
||||
isActive: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isActive) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
}
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = meeting.name,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
meeting.description?.let { desc ->
|
||||
Text(
|
||||
text = desc,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (isActive) {
|
||||
Text(
|
||||
text = "Joined",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
51
modules/military/build.gradle.kts
Normal file
51
modules/military/build.gradle.kts
Normal file
@@ -0,0 +1,51 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("kotlin-kapt")
|
||||
id("dagger.hilt.android.plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.smoa.modules.military"
|
||||
compileSdk = AppConfig.compileSdk
|
||||
|
||||
defaultConfig {
|
||||
minSdk = AppConfig.minSdk
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:common"))
|
||||
implementation(project(":core:auth"))
|
||||
implementation(project(":core:security"))
|
||||
implementation(project(":core:barcode"))
|
||||
|
||||
implementation(platform(Dependencies.composeBom))
|
||||
implementation(Dependencies.composeUi)
|
||||
implementation(Dependencies.composeMaterial3)
|
||||
implementation(Dependencies.androidxCoreKtx)
|
||||
|
||||
implementation(Dependencies.hiltAndroid)
|
||||
kapt(Dependencies.hiltAndroidCompiler)
|
||||
|
||||
implementation(Dependencies.coroutinesCore)
|
||||
implementation(Dependencies.coroutinesAndroid)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.smoa.modules.military
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun MilitaryModule(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Military Operations",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.smoa.modules.military.domain
|
||||
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Document classification marking manager per DOD standards.
|
||||
*/
|
||||
@Singleton
|
||||
class ClassificationManager @Inject constructor() {
|
||||
|
||||
/**
|
||||
* Get classification banner text.
|
||||
*/
|
||||
fun getClassificationBanner(level: ClassificationLevel): String {
|
||||
return when (level) {
|
||||
ClassificationLevel.UNCLASSIFIED -> "UNCLASSIFIED"
|
||||
ClassificationLevel.CONFIDENTIAL -> "CONFIDENTIAL"
|
||||
ClassificationLevel.SECRET -> "SECRET"
|
||||
ClassificationLevel.TOP_SECRET -> "TOP SECRET"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has clearance for classification level.
|
||||
*/
|
||||
fun hasClearance(userClearance: ClassificationLevel, documentLevel: ClassificationLevel): Boolean {
|
||||
val clearanceHierarchy = mapOf(
|
||||
ClassificationLevel.UNCLASSIFIED to 0,
|
||||
ClassificationLevel.CONFIDENTIAL to 1,
|
||||
ClassificationLevel.SECRET to 2,
|
||||
ClassificationLevel.TOP_SECRET to 3
|
||||
)
|
||||
|
||||
val userLevel = clearanceHierarchy[userClearance] ?: -1
|
||||
val docLevel = clearanceHierarchy[documentLevel] ?: Int.MAX_VALUE
|
||||
|
||||
return userLevel >= docLevel
|
||||
}
|
||||
}
|
||||
|
||||
enum class ClassificationLevel {
|
||||
UNCLASSIFIED,
|
||||
CONFIDENTIAL,
|
||||
SECRET,
|
||||
TOP_SECRET
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.smoa.modules.military.domain
|
||||
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Military credential data model per MIL-STD-129.
|
||||
*/
|
||||
data class MilitaryCredential(
|
||||
val credentialId: String,
|
||||
val serviceCode: String, // Service branch code
|
||||
val rank: String?,
|
||||
val lastName: String,
|
||||
val firstName: String,
|
||||
val middleInitial: String?,
|
||||
val socialSecurityNumber: String,
|
||||
val dateOfBirth: Date,
|
||||
val expirationDate: Date,
|
||||
val issueDate: Date,
|
||||
val cardNumber: String,
|
||||
val unit: String?,
|
||||
val clearanceLevel: ClearanceLevel?
|
||||
)
|
||||
|
||||
enum class ClearanceLevel {
|
||||
CONFIDENTIAL,
|
||||
SECRET,
|
||||
TOP_SECRET,
|
||||
TS_SCI // Top Secret - Sensitive Compartmented Information
|
||||
}
|
||||
|
||||
enum class ServiceBranch {
|
||||
ARMY,
|
||||
NAVY,
|
||||
AIR_FORCE,
|
||||
MARINES,
|
||||
COAST_GUARD,
|
||||
SPACE_FORCE
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.smoa.modules.military.domain
|
||||
|
||||
import com.smoa.core.security.AuditLogger
|
||||
import com.smoa.core.security.AuditEventType
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Military operations service.
|
||||
*/
|
||||
@Singleton
|
||||
class MilitaryService @Inject constructor(
|
||||
private val classificationManager: ClassificationManager,
|
||||
private val auditLogger: AuditLogger
|
||||
) {
|
||||
|
||||
/**
|
||||
* Create military credential.
|
||||
*/
|
||||
suspend fun createMilitaryCredential(
|
||||
serviceCode: String,
|
||||
rank: String?,
|
||||
lastName: String,
|
||||
firstName: String,
|
||||
socialSecurityNumber: String,
|
||||
dateOfBirth: Date,
|
||||
expirationDate: Date,
|
||||
unit: String?,
|
||||
clearanceLevel: ClearanceLevel?
|
||||
): Result<MilitaryCredential> {
|
||||
return try {
|
||||
val credential = MilitaryCredential(
|
||||
credentialId = UUID.randomUUID().toString(),
|
||||
serviceCode = serviceCode,
|
||||
rank = rank,
|
||||
lastName = lastName,
|
||||
firstName = firstName,
|
||||
middleInitial = null,
|
||||
socialSecurityNumber = socialSecurityNumber,
|
||||
dateOfBirth = dateOfBirth,
|
||||
expirationDate = expirationDate,
|
||||
issueDate = Date(),
|
||||
cardNumber = UUID.randomUUID().toString(),
|
||||
unit = unit,
|
||||
clearanceLevel = clearanceLevel
|
||||
)
|
||||
|
||||
auditLogger.logEvent(
|
||||
AuditEventType.CREDENTIAL_ACCESS,
|
||||
userId = firstName,
|
||||
module = "military",
|
||||
details = "Military credential created: ${credential.credentialId}"
|
||||
)
|
||||
|
||||
Result.success(credential)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check classification access.
|
||||
*/
|
||||
fun checkClassificationAccess(userClearance: ClearanceLevel, documentLevel: ClassificationLevel): Boolean {
|
||||
val userLevel = when (userClearance) {
|
||||
ClearanceLevel.CONFIDENTIAL -> ClassificationLevel.CONFIDENTIAL
|
||||
ClearanceLevel.SECRET -> ClassificationLevel.SECRET
|
||||
ClearanceLevel.TOP_SECRET, ClearanceLevel.TS_SCI -> ClassificationLevel.TOP_SECRET
|
||||
}
|
||||
return classificationManager.hasClearance(userLevel, documentLevel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.smoa.modules.military.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun MilitaryCredentialScreen(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Military Credential",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
54
modules/ncic/build.gradle.kts
Normal file
54
modules/ncic/build.gradle.kts
Normal file
@@ -0,0 +1,54 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("kotlin-kapt")
|
||||
id("dagger.hilt.android.plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.smoa.modules.ncic"
|
||||
compileSdk = AppConfig.compileSdk
|
||||
|
||||
defaultConfig {
|
||||
minSdk = AppConfig.minSdk
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:common"))
|
||||
implementation(project(":core:auth"))
|
||||
implementation(project(":core:security"))
|
||||
|
||||
implementation(platform(Dependencies.composeBom))
|
||||
implementation(Dependencies.composeUi)
|
||||
implementation(Dependencies.composeMaterial3)
|
||||
implementation(Dependencies.androidxCoreKtx)
|
||||
|
||||
implementation(Dependencies.hiltAndroid)
|
||||
kapt(Dependencies.hiltAndroidCompiler)
|
||||
|
||||
implementation(Dependencies.retrofit)
|
||||
implementation(Dependencies.okHttp)
|
||||
implementation(Dependencies.retrofitGson)
|
||||
|
||||
implementation(Dependencies.coroutinesCore)
|
||||
implementation(Dependencies.coroutinesAndroid)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.smoa.modules.ncic
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun NCICModule(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "NCIC/III Integration",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
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
|
||||
// }
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.smoa.modules.ncic.domain
|
||||
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* NCIC query models for National Crime Information Center database queries.
|
||||
*/
|
||||
data class NCICQuery(
|
||||
val queryId: String = UUID.randomUUID().toString(),
|
||||
val ori: String, // Originating Agency Identifier
|
||||
val ucn: String, // Unique Control Number
|
||||
val queryType: NCICQueryType,
|
||||
val searchCriteria: Map<String, String>,
|
||||
val timestamp: Date = Date(),
|
||||
val operatorId: String
|
||||
)
|
||||
|
||||
enum class NCICQueryType {
|
||||
PERSON,
|
||||
VEHICLE,
|
||||
ARTICLE,
|
||||
BOAT,
|
||||
GUN,
|
||||
LICENSE_PLATE
|
||||
}
|
||||
|
||||
data class NCICResponse(
|
||||
val queryId: String,
|
||||
val responseCode: NCICResponseCode,
|
||||
val records: List<NCICRecord>?,
|
||||
val timestamp: Date,
|
||||
val message: String?
|
||||
)
|
||||
|
||||
enum class NCICResponseCode {
|
||||
HIT,
|
||||
NO_HIT,
|
||||
ERROR,
|
||||
RESTRICTED
|
||||
}
|
||||
|
||||
data class NCICRecord(
|
||||
val recordType: String,
|
||||
val data: Map<String, String>,
|
||||
val flags: List<String>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.smoa.modules.ncic.domain
|
||||
|
||||
import com.smoa.core.security.AuditLogger
|
||||
import com.smoa.core.security.AuditEventType
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* NCIC query service.
|
||||
* Note: Actual NCIC API integration requires CJIS approval.
|
||||
*/
|
||||
@Singleton
|
||||
class NCICService @Inject constructor(
|
||||
private val oriManager: ORIManager,
|
||||
private val ucnGenerator: UCNGenerator,
|
||||
private val auditLogger: AuditLogger
|
||||
) {
|
||||
|
||||
/**
|
||||
* Execute NCIC query.
|
||||
* Note: Requires CJIS Security Policy compliance and API access.
|
||||
*/
|
||||
suspend fun executeQuery(query: NCICQuery): Result<NCICResponse> {
|
||||
return try {
|
||||
// Validate ORI
|
||||
if (!oriManager.validateORIFormat(query.ori)) {
|
||||
return Result.failure(IllegalArgumentException("Invalid ORI format"))
|
||||
}
|
||||
|
||||
// Validate UCN
|
||||
if (!ucnGenerator.validateUCN(query.ucn)) {
|
||||
return Result.failure(IllegalArgumentException("Invalid UCN format"))
|
||||
}
|
||||
|
||||
// TODO: Integrate with NCIC API (requires CJIS approval)
|
||||
// For now, simulate response
|
||||
val response = NCICResponse(
|
||||
queryId = query.queryId,
|
||||
responseCode = NCICResponseCode.NO_HIT,
|
||||
records = null,
|
||||
timestamp = Date(),
|
||||
message = "Query executed (simulated - API integration pending)"
|
||||
)
|
||||
|
||||
auditLogger.logEvent(
|
||||
AuditEventType.CREDENTIAL_ACCESS,
|
||||
userId = query.operatorId,
|
||||
module = "ncic",
|
||||
details = "NCIC query executed: ${query.queryId}, type: ${query.queryType}"
|
||||
)
|
||||
|
||||
Result.success(response)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute III (Interstate Identification Index) query.
|
||||
*/
|
||||
suspend fun executeIIIQuery(query: NCICQuery): Result<NCICResponse> {
|
||||
// III queries follow similar pattern to NCIC
|
||||
return executeQuery(query)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.smoa.modules.ncic.domain
|
||||
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* ORI (Originating Agency Identifier) management.
|
||||
*/
|
||||
@Singleton
|
||||
class ORIManager @Inject constructor() {
|
||||
|
||||
private val registeredORIs = mutableMapOf<String, ORIInfo>()
|
||||
|
||||
/**
|
||||
* Register an ORI for an agency.
|
||||
*/
|
||||
fun registerORI(ori: String, info: ORIInfo) {
|
||||
registeredORIs[ori] = info
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ORI information.
|
||||
*/
|
||||
fun getORIInfo(ori: String): ORIInfo? {
|
||||
return registeredORIs[ori]
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate ORI format.
|
||||
*/
|
||||
fun validateORIFormat(ori: String): Boolean {
|
||||
// ORI format: 9 characters (3 letters, 3 numbers, 3 letters/numbers)
|
||||
return ori.length == 9 && ori.matches(Regex("[A-Z]{3}[0-9]{3}[A-Z0-9]{3}"))
|
||||
}
|
||||
}
|
||||
|
||||
data class ORIInfo(
|
||||
val ori: String,
|
||||
val agencyName: String,
|
||||
val state: String,
|
||||
val jurisdiction: String
|
||||
)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.smoa.modules.ncic.domain
|
||||
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* UCN (Unique Control Number) generator and validator.
|
||||
*/
|
||||
@Singleton
|
||||
class UCNGenerator @Inject constructor() {
|
||||
|
||||
/**
|
||||
* Generate a UCN for a query.
|
||||
*/
|
||||
fun generateUCN(ori: String, timestamp: Date = Date()): String {
|
||||
// UCN format: ORI + Date + Sequence
|
||||
val dateStr = java.text.SimpleDateFormat("yyMMdd", java.util.Locale.US).format(timestamp)
|
||||
val sequence = UUID.randomUUID().toString().take(6).uppercase()
|
||||
return "$ori$dateStr$sequence"
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate UCN format.
|
||||
*/
|
||||
fun validateUCN(ucn: String): Boolean {
|
||||
// UCN should be at least 15 characters
|
||||
return ucn.length >= 15 && ucn.isNotBlank()
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract ORI from UCN.
|
||||
*/
|
||||
fun extractORI(ucn: String): String? {
|
||||
return if (ucn.length >= 9) {
|
||||
ucn.substring(0, 9)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.smoa.modules.ncic.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun NCICQueryScreen(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "NCIC Query",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
59
modules/orders/build.gradle.kts
Normal file
59
modules/orders/build.gradle.kts
Normal file
@@ -0,0 +1,59 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("kotlin-kapt")
|
||||
id("dagger.hilt.android.plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.smoa.modules.orders"
|
||||
compileSdk = AppConfig.compileSdk
|
||||
|
||||
defaultConfig {
|
||||
minSdk = AppConfig.minSdk
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:common"))
|
||||
implementation(project(":core:auth"))
|
||||
implementation(project(":core:security"))
|
||||
|
||||
implementation(platform(Dependencies.composeBom))
|
||||
implementation(Dependencies.composeUi)
|
||||
implementation(Dependencies.composeUiGraphics)
|
||||
implementation(Dependencies.composeMaterial3)
|
||||
implementation(Dependencies.androidxCoreKtx)
|
||||
implementation(Dependencies.androidxLifecycleRuntimeKtx)
|
||||
|
||||
implementation(Dependencies.hiltAndroid)
|
||||
kapt(Dependencies.hiltAndroidCompiler)
|
||||
|
||||
implementation(Dependencies.roomRuntime)
|
||||
implementation(Dependencies.roomKtx)
|
||||
kapt(Dependencies.roomCompiler)
|
||||
|
||||
// Database Encryption
|
||||
implementation(Dependencies.sqlcipher)
|
||||
|
||||
implementation(Dependencies.coroutinesCore)
|
||||
implementation(Dependencies.coroutinesAndroid)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.smoa.modules.orders.data
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import com.smoa.modules.orders.domain.OrderStatus
|
||||
import com.smoa.modules.orders.domain.OrderType
|
||||
import java.util.Date
|
||||
|
||||
class OrderConverters {
|
||||
@TypeConverter
|
||||
fun fromOrderType(value: OrderType): String = value.name
|
||||
|
||||
@TypeConverter
|
||||
fun toOrderType(value: String): OrderType = OrderType.valueOf(value)
|
||||
|
||||
@TypeConverter
|
||||
fun fromOrderStatus(value: OrderStatus): String = value.name
|
||||
|
||||
@TypeConverter
|
||||
fun toOrderStatus(value: String): OrderStatus = OrderStatus.valueOf(value)
|
||||
|
||||
@TypeConverter
|
||||
fun fromTimestamp(value: Long?): Date? = value?.let { Date(it) }
|
||||
|
||||
@TypeConverter
|
||||
fun dateToTimestamp(date: Date?): Long? = date?.time
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.smoa.modules.orders.data
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Update
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface OrderDao {
|
||||
@Query("SELECT * FROM orders ORDER BY issueDate DESC")
|
||||
fun getAllOrders(): Flow<List<OrderEntity>>
|
||||
|
||||
@Query("SELECT * FROM orders WHERE orderId = :orderId")
|
||||
suspend fun getOrderById(orderId: String): OrderEntity?
|
||||
|
||||
@Query("SELECT * FROM orders WHERE status = :status ORDER BY issueDate DESC")
|
||||
fun getOrdersByStatus(status: String): Flow<List<OrderEntity>>
|
||||
|
||||
@Query("SELECT * FROM orders WHERE orderType = :orderType ORDER BY issueDate DESC")
|
||||
fun getOrdersByType(orderType: String): Flow<List<OrderEntity>>
|
||||
|
||||
@Query("SELECT * FROM orders WHERE issuedTo = :userId ORDER BY issueDate DESC")
|
||||
fun getOrdersForUser(userId: String): Flow<List<OrderEntity>>
|
||||
|
||||
@Query("SELECT * FROM orders WHERE title LIKE :query OR content LIKE :query ORDER BY issueDate DESC")
|
||||
fun searchOrders(query: String): Flow<List<OrderEntity>>
|
||||
|
||||
@Query("SELECT * FROM orders WHERE expirationDate IS NOT NULL AND expirationDate < :nowMillis AND status NOT IN ('EXPIRED', 'REVOKED')")
|
||||
suspend fun getExpiredOrders(nowMillis: Long): List<OrderEntity>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertOrder(order: OrderEntity)
|
||||
|
||||
@Update
|
||||
suspend fun updateOrder(order: OrderEntity)
|
||||
|
||||
@Query("DELETE FROM orders WHERE orderId = :orderId")
|
||||
suspend fun deleteOrder(orderId: String)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.smoa.modules.orders.data
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
|
||||
@Database(
|
||||
entities = [OrderEntity::class],
|
||||
version = 1,
|
||||
exportSchema = false
|
||||
)
|
||||
@TypeConverters(OrderConverters::class)
|
||||
abstract class OrderDatabase : RoomDatabase() {
|
||||
abstract fun orderDao(): OrderDao
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.smoa.modules.orders.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 OrderDatabaseModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOrderDatabase(
|
||||
@ApplicationContext context: Context,
|
||||
encryptedDatabaseHelper: EncryptedDatabaseHelper
|
||||
): OrderDatabase {
|
||||
val passphrase = encryptedDatabaseHelper.getDatabasePassphrase("orders_database")
|
||||
val factory = encryptedDatabaseHelper.createOpenHelperFactory("orders_database")
|
||||
|
||||
return Room.databaseBuilder(
|
||||
context,
|
||||
OrderDatabase::class.java,
|
||||
"orders_database"
|
||||
)
|
||||
.openHelperFactory(factory)
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideOrderDao(database: OrderDatabase): OrderDao {
|
||||
return database.orderDao()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.smoa.modules.orders.data
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.TypeConverters
|
||||
import com.smoa.modules.orders.domain.OrderStatus
|
||||
import com.smoa.modules.orders.domain.OrderType
|
||||
import java.util.Date
|
||||
|
||||
@Entity(tableName = "orders")
|
||||
@TypeConverters(OrderConverters::class)
|
||||
data class OrderEntity(
|
||||
@PrimaryKey
|
||||
val orderId: String,
|
||||
val orderType: OrderType,
|
||||
val title: String,
|
||||
val content: String,
|
||||
val issuedBy: String,
|
||||
val issuedTo: String?,
|
||||
val issueDate: Date,
|
||||
val effectiveDate: Date,
|
||||
val expirationDate: Date?,
|
||||
val status: OrderStatus,
|
||||
val classification: String?,
|
||||
val jurisdiction: String,
|
||||
val caseNumber: String?,
|
||||
val createdAt: Date,
|
||||
val updatedAt: Date
|
||||
)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.smoa.modules.orders.domain
|
||||
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Order data model for digital orders management.
|
||||
*/
|
||||
data class Order(
|
||||
val orderId: String,
|
||||
val orderType: OrderType,
|
||||
val title: String,
|
||||
val content: String,
|
||||
val issuedBy: String, // Authority/author
|
||||
val issuedTo: String?,
|
||||
val issueDate: Date,
|
||||
val effectiveDate: Date,
|
||||
val expirationDate: Date?,
|
||||
val status: OrderStatus,
|
||||
val attachments: List<OrderAttachment> = emptyList(),
|
||||
val signatures: List<DigitalSignature> = emptyList(),
|
||||
val metadata: OrderMetadata
|
||||
)
|
||||
|
||||
enum class OrderType {
|
||||
AUTHORIZATION,
|
||||
ASSIGNMENT,
|
||||
SEARCH_WARRANT,
|
||||
ARREST_WARRANT,
|
||||
COURT_ORDER,
|
||||
ADMINISTRATIVE
|
||||
}
|
||||
|
||||
enum class OrderStatus {
|
||||
DRAFT,
|
||||
PENDING_APPROVAL,
|
||||
APPROVED,
|
||||
ISSUED,
|
||||
EXECUTED,
|
||||
EXPIRED,
|
||||
REVOKED
|
||||
}
|
||||
|
||||
data class OrderAttachment(
|
||||
val attachmentId: String,
|
||||
val fileName: String,
|
||||
val mimeType: String,
|
||||
val size: Long,
|
||||
val content: ByteArray,
|
||||
val uploadedDate: Date
|
||||
)
|
||||
|
||||
data class DigitalSignature(
|
||||
val signatureId: String,
|
||||
val signerId: String,
|
||||
val signerName: String,
|
||||
val signatureDate: Date,
|
||||
val signatureData: ByteArray,
|
||||
val certificate: String? // X.509 certificate
|
||||
)
|
||||
|
||||
data class OrderMetadata(
|
||||
val classification: ClassificationLevel?,
|
||||
val jurisdiction: String,
|
||||
val caseNumber: String?,
|
||||
val relatedOrders: List<String> = emptyList(),
|
||||
val keywords: List<String> = emptyList()
|
||||
)
|
||||
|
||||
enum class ClassificationLevel {
|
||||
UNCLASSIFIED,
|
||||
CONFIDENTIAL,
|
||||
SECRET,
|
||||
TOP_SECRET
|
||||
}
|
||||
|
||||
data class OrderCopy(
|
||||
val originalOrderId: String,
|
||||
val copyId: String,
|
||||
val generatedDate: Date,
|
||||
val generatedBy: String,
|
||||
val copyType: CopyType,
|
||||
val authenticationCode: String, // HMAC-based for verification
|
||||
val orderContent: ByteArray // Encrypted/signed
|
||||
)
|
||||
|
||||
enum class CopyType {
|
||||
CERTIFIED_TRUE_COPY,
|
||||
INFORMATIONAL_COPY,
|
||||
REDACTED_COPY
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.smoa.modules.orders.domain
|
||||
|
||||
import com.smoa.modules.orders.data.OrderDao
|
||||
import com.smoa.modules.orders.data.OrderEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class OrderRepository @Inject constructor(
|
||||
private val orderDao: OrderDao
|
||||
) {
|
||||
fun getAllOrders(): Flow<List<Order>> {
|
||||
return orderDao.getAllOrders().map { entities ->
|
||||
entities.map { it.toDomain() }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getOrderById(orderId: String): Order? {
|
||||
return orderDao.getOrderById(orderId)?.toDomain()
|
||||
}
|
||||
|
||||
fun getOrdersByStatus(status: OrderStatus): Flow<List<Order>> {
|
||||
return orderDao.getOrdersByStatus(status.name).map { entities ->
|
||||
entities.map { it.toDomain() }
|
||||
}
|
||||
}
|
||||
|
||||
fun getOrdersByType(orderType: OrderType): Flow<List<Order>> {
|
||||
return orderDao.getOrdersByType(orderType.name).map { entities ->
|
||||
entities.map { it.toDomain() }
|
||||
}
|
||||
}
|
||||
|
||||
fun getOrdersForUser(userId: String): Flow<List<Order>> {
|
||||
return orderDao.getOrdersForUser(userId).map { entities ->
|
||||
entities.map { it.toDomain() }
|
||||
}
|
||||
}
|
||||
|
||||
fun searchOrders(query: String): Flow<List<Order>> {
|
||||
return orderDao.searchOrders("%$query%").map { entities ->
|
||||
entities.map { it.toDomain() }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun insertOrder(order: Order) {
|
||||
orderDao.insertOrder(order.toEntity())
|
||||
}
|
||||
|
||||
suspend fun updateOrder(order: Order) {
|
||||
orderDao.updateOrder(order.toEntity())
|
||||
}
|
||||
|
||||
suspend fun deleteOrder(orderId: String) {
|
||||
orderDao.deleteOrder(orderId)
|
||||
}
|
||||
|
||||
suspend fun getExpiredOrders(): List<Order> {
|
||||
return orderDao.getExpiredOrders(Date().time).map { it.toDomain() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun OrderEntity.toDomain(): Order {
|
||||
return Order(
|
||||
orderId = orderId,
|
||||
orderType = orderType,
|
||||
title = title,
|
||||
content = content,
|
||||
issuedBy = issuedBy,
|
||||
issuedTo = issuedTo,
|
||||
issueDate = issueDate,
|
||||
effectiveDate = effectiveDate,
|
||||
expirationDate = expirationDate,
|
||||
status = status,
|
||||
attachments = emptyList(), // Load separately if needed
|
||||
signatures = emptyList(), // Load separately if needed
|
||||
metadata = OrderMetadata(
|
||||
classification = classification?.let {
|
||||
ClassificationLevel.valueOf(it)
|
||||
},
|
||||
jurisdiction = jurisdiction,
|
||||
caseNumber = caseNumber,
|
||||
relatedOrders = emptyList(),
|
||||
keywords = emptyList()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun Order.toEntity(): OrderEntity {
|
||||
return OrderEntity(
|
||||
orderId = orderId,
|
||||
orderType = orderType,
|
||||
title = title,
|
||||
content = content,
|
||||
issuedBy = issuedBy,
|
||||
issuedTo = issuedTo,
|
||||
issueDate = issueDate,
|
||||
effectiveDate = effectiveDate,
|
||||
expirationDate = expirationDate,
|
||||
status = status,
|
||||
classification = metadata.classification?.name,
|
||||
jurisdiction = metadata.jurisdiction,
|
||||
caseNumber = metadata.caseNumber,
|
||||
createdAt = Date(),
|
||||
updatedAt = Date()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.smoa.modules.orders.domain
|
||||
|
||||
import com.smoa.core.common.Result
|
||||
import com.smoa.core.security.AuditLogger
|
||||
import com.smoa.core.security.AuditEventType
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class OrderService @Inject constructor(
|
||||
private val repository: OrderRepository,
|
||||
private val auditLogger: AuditLogger
|
||||
) {
|
||||
|
||||
/**
|
||||
* Create a new order.
|
||||
*/
|
||||
suspend fun createOrder(
|
||||
orderType: OrderType,
|
||||
title: String,
|
||||
content: String,
|
||||
issuedBy: String,
|
||||
issuedTo: String?,
|
||||
effectiveDate: Date,
|
||||
expirationDate: Date?,
|
||||
metadata: OrderMetadata
|
||||
): Result<Order> {
|
||||
return try {
|
||||
val order = Order(
|
||||
orderId = UUID.randomUUID().toString(),
|
||||
orderType = orderType,
|
||||
title = title,
|
||||
content = content,
|
||||
issuedBy = issuedBy,
|
||||
issuedTo = issuedTo,
|
||||
issueDate = Date(),
|
||||
effectiveDate = effectiveDate,
|
||||
expirationDate = expirationDate,
|
||||
status = OrderStatus.DRAFT,
|
||||
attachments = emptyList(),
|
||||
signatures = emptyList(),
|
||||
metadata = metadata
|
||||
)
|
||||
|
||||
repository.insertOrder(order)
|
||||
auditLogger.logEvent(
|
||||
AuditEventType.POLICY_UPDATE,
|
||||
userId = issuedBy,
|
||||
module = "orders",
|
||||
details = "Order created: ${order.orderId}"
|
||||
)
|
||||
|
||||
Result.Success(order)
|
||||
} catch (e: Exception) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update order status through workflow.
|
||||
*/
|
||||
suspend fun updateOrderStatus(
|
||||
orderId: String,
|
||||
newStatus: OrderStatus,
|
||||
userId: String
|
||||
): Result<Order> {
|
||||
return try {
|
||||
val order = repository.getOrderById(orderId)
|
||||
?: return Result.Error(IllegalArgumentException("Order not found"))
|
||||
|
||||
val updatedOrder = order.copy(
|
||||
status = newStatus,
|
||||
metadata = order.metadata.copy()
|
||||
)
|
||||
|
||||
repository.updateOrder(updatedOrder)
|
||||
auditLogger.logEvent(
|
||||
AuditEventType.POLICY_UPDATE,
|
||||
userId = userId,
|
||||
module = "orders",
|
||||
details = "Order status updated: $orderId -> $newStatus"
|
||||
)
|
||||
|
||||
Result.Success(updatedOrder)
|
||||
} catch (e: Exception) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and expire orders that have passed expiration date.
|
||||
*/
|
||||
suspend fun checkAndExpireOrders() {
|
||||
val expiredOrders = repository.getExpiredOrders()
|
||||
expiredOrders.forEach { order ->
|
||||
if (order.status != OrderStatus.EXPIRED && order.status != OrderStatus.REVOKED) {
|
||||
updateOrderStatus(order.orderId, OrderStatus.EXPIRED, "system")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate authenticated copy of order.
|
||||
*/
|
||||
suspend fun generateOrderCopy(
|
||||
orderId: String,
|
||||
copyType: CopyType,
|
||||
generatedBy: String
|
||||
): Result<OrderCopy> {
|
||||
return try {
|
||||
val order = repository.getOrderById(orderId)
|
||||
?: return Result.Error(IllegalArgumentException("Order not found"))
|
||||
|
||||
// Generate HMAC-based authentication code
|
||||
val authCode = generateAuthenticationCode(order, copyType, generatedBy)
|
||||
|
||||
val copy = OrderCopy(
|
||||
originalOrderId = orderId,
|
||||
copyId = UUID.randomUUID().toString(),
|
||||
generatedDate = Date(),
|
||||
generatedBy = generatedBy,
|
||||
copyType = copyType,
|
||||
authenticationCode = authCode,
|
||||
orderContent = order.content.toByteArray() // In production, encrypt this
|
||||
)
|
||||
|
||||
auditLogger.logEvent(
|
||||
AuditEventType.CREDENTIAL_ACCESS,
|
||||
userId = generatedBy,
|
||||
module = "orders",
|
||||
details = "Order copy generated: $orderId, type: $copyType"
|
||||
)
|
||||
|
||||
Result.Success(copy)
|
||||
} catch (e: Exception) {
|
||||
Result.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HMAC-based authentication code for order copy.
|
||||
*/
|
||||
private fun generateAuthenticationCode(
|
||||
order: Order,
|
||||
copyType: CopyType,
|
||||
generatedBy: String
|
||||
): String {
|
||||
// Simplified - in production, use proper HMAC with secret key
|
||||
val data = "${order.orderId}|${copyType.name}|${generatedBy}|${Date().time}"
|
||||
return data.hashCode().toString()
|
||||
}
|
||||
|
||||
fun getAllOrders(): Flow<List<Order>> = repository.getAllOrders()
|
||||
fun getOrdersByStatus(status: OrderStatus): Flow<List<Order>> = repository.getOrdersByStatus(status)
|
||||
fun getOrdersByType(orderType: OrderType): Flow<List<Order>> = repository.getOrdersByType(orderType)
|
||||
fun searchOrders(query: String): Flow<List<Order>> = repository.searchOrders(query)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.smoa.modules.orders.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Order detail screen for viewing individual order.
|
||||
*/
|
||||
@Composable
|
||||
fun OrderDetailScreen(
|
||||
orderId: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Order Details",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
// Order detail UI will be implemented here
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.smoa.modules.orders.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Order list screen displaying all orders.
|
||||
*/
|
||||
@Composable
|
||||
fun OrderListScreen(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Orders",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(bottom = 16.dp)
|
||||
)
|
||||
// Order list UI will be implemented here
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.smoa.modules.orders.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Orders module - Digital orders management system.
|
||||
*/
|
||||
@Composable
|
||||
fun OrdersModule(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Orders Management",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
// Orders management UI will be implemented here
|
||||
}
|
||||
}
|
||||
|
||||
65
modules/reports/build.gradle.kts
Normal file
65
modules/reports/build.gradle.kts
Normal file
@@ -0,0 +1,65 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("kotlin-kapt")
|
||||
id("dagger.hilt.android.plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.smoa.modules.reports"
|
||||
compileSdk = AppConfig.compileSdk
|
||||
|
||||
defaultConfig {
|
||||
minSdk = AppConfig.minSdk
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:common"))
|
||||
implementation(project(":core:security"))
|
||||
|
||||
implementation(platform(Dependencies.composeBom))
|
||||
implementation(Dependencies.composeUi)
|
||||
implementation(Dependencies.composeUiGraphics)
|
||||
implementation(Dependencies.composeMaterial3)
|
||||
implementation(Dependencies.androidxCoreKtx)
|
||||
implementation(Dependencies.androidxLifecycleRuntimeKtx)
|
||||
|
||||
implementation(Dependencies.hiltAndroid)
|
||||
kapt(Dependencies.hiltAndroidCompiler)
|
||||
|
||||
// Report generation libraries
|
||||
// TODO: PDFBox Android - may need alternative version or repository
|
||||
// Temporarily commented - uncomment when dependency is available:
|
||||
// implementation(Dependencies.pdfbox)
|
||||
// TODO: POI requires minSdk 26, but project uses minSdk 24
|
||||
// Temporarily commented - uncomment when minSdk is increased or alternative is found:
|
||||
// implementation(Dependencies.poi)
|
||||
// implementation(Dependencies.poiOoxml)
|
||||
implementation(Dependencies.jacksonCore)
|
||||
implementation(Dependencies.jacksonDatabind)
|
||||
implementation(Dependencies.jacksonKotlin)
|
||||
implementation(Dependencies.jaxbApi)
|
||||
implementation(Dependencies.jaxbRuntime)
|
||||
|
||||
implementation(Dependencies.coroutinesCore)
|
||||
implementation(Dependencies.coroutinesAndroid)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.smoa.modules.reports.domain
|
||||
|
||||
import java.util.Date
|
||||
|
||||
data class Report(
|
||||
val reportId: String,
|
||||
val reportType: ReportType,
|
||||
val title: String,
|
||||
val template: ReportTemplate?,
|
||||
val format: ReportFormat,
|
||||
val content: ByteArray,
|
||||
val generatedDate: Date,
|
||||
val generatedBy: String,
|
||||
val signature: DigitalSignature?,
|
||||
val metadata: ReportMetadata
|
||||
)
|
||||
|
||||
enum class ReportType {
|
||||
OPERATIONAL,
|
||||
COMPLIANCE,
|
||||
AUDIT,
|
||||
EVIDENCE,
|
||||
ACTIVITY,
|
||||
REGULATORY
|
||||
}
|
||||
|
||||
enum class ReportFormat {
|
||||
PDF,
|
||||
XML,
|
||||
JSON,
|
||||
CSV,
|
||||
EXCEL
|
||||
}
|
||||
|
||||
data class ReportTemplate(
|
||||
val templateId: String,
|
||||
val name: String,
|
||||
val reportType: ReportType,
|
||||
val format: ReportFormat,
|
||||
val templateContent: String
|
||||
)
|
||||
|
||||
data class DigitalSignature(
|
||||
val signatureId: String,
|
||||
val signerId: String,
|
||||
val signatureDate: Date,
|
||||
val signatureData: ByteArray
|
||||
)
|
||||
|
||||
data class ReportMetadata(
|
||||
val scheduled: Boolean = false,
|
||||
val scheduleFrequency: ScheduleFrequency? = null,
|
||||
val distributionList: List<String> = emptyList()
|
||||
)
|
||||
|
||||
enum class ScheduleFrequency {
|
||||
DAILY,
|
||||
WEEKLY,
|
||||
MONTHLY,
|
||||
QUARTERLY,
|
||||
YEARLY
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.smoa.modules.reports.domain
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.KotlinModule
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.ByteArrayOutputStream
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Report generator service for multi-format report generation.
|
||||
*/
|
||||
@Singleton
|
||||
class ReportGenerator @Inject constructor() {
|
||||
|
||||
private val objectMapper = ObjectMapper().registerModule(KotlinModule.Builder().build())
|
||||
|
||||
/**
|
||||
* Generate report in specified format.
|
||||
*/
|
||||
suspend fun generateReport(
|
||||
report: Report,
|
||||
template: ReportTemplate?
|
||||
): Result<ByteArray> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val content = when (report.format) {
|
||||
ReportFormat.JSON -> generateJSON(report)
|
||||
ReportFormat.XML -> generateXML(report)
|
||||
ReportFormat.CSV -> generateCSV(report)
|
||||
ReportFormat.PDF -> generatePDF(report, template)
|
||||
ReportFormat.EXCEL -> generateExcel(report)
|
||||
}
|
||||
Result.success(content)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateJSON(report: Report): ByteArray {
|
||||
return objectMapper.writeValueAsBytes(report)
|
||||
}
|
||||
|
||||
private fun generateXML(report: Report): ByteArray {
|
||||
// Placeholder - will use JAXB in full implementation
|
||||
return report.content
|
||||
}
|
||||
|
||||
private fun generateCSV(report: Report): ByteArray {
|
||||
// Placeholder - will use Apache POI in full implementation
|
||||
return report.content
|
||||
}
|
||||
|
||||
private fun generatePDF(report: Report, template: ReportTemplate?): ByteArray {
|
||||
// Placeholder - will use PDFBox in full implementation
|
||||
return report.content
|
||||
}
|
||||
|
||||
private fun generateExcel(report: Report): ByteArray {
|
||||
// Placeholder - will use Apache POI in full implementation
|
||||
return report.content
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.smoa.modules.reports.domain
|
||||
|
||||
import com.smoa.core.security.AuditLogger
|
||||
import com.smoa.core.security.AuditEventType
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Report service for generating and managing reports.
|
||||
*/
|
||||
@Singleton
|
||||
class ReportService @Inject constructor(
|
||||
private val reportGenerator: ReportGenerator,
|
||||
private val auditLogger: AuditLogger
|
||||
) {
|
||||
|
||||
/**
|
||||
* Generate report.
|
||||
*/
|
||||
suspend fun generateReport(
|
||||
reportType: ReportType,
|
||||
format: ReportFormat,
|
||||
title: String,
|
||||
content: ByteArray,
|
||||
generatedBy: String,
|
||||
template: ReportTemplate?
|
||||
): Result<Report> {
|
||||
return try {
|
||||
val report = Report(
|
||||
reportId = UUID.randomUUID().toString(),
|
||||
reportType = reportType,
|
||||
title = title,
|
||||
template = template,
|
||||
format = format,
|
||||
content = content,
|
||||
generatedDate = Date(),
|
||||
generatedBy = generatedBy,
|
||||
signature = null, // TODO: Add digital signature
|
||||
metadata = ReportMetadata()
|
||||
)
|
||||
|
||||
// Generate report in specified format
|
||||
val generatedContent = reportGenerator.generateReport(report, template)
|
||||
val finalReport = report.copy(content = generatedContent.getOrElse { content })
|
||||
|
||||
auditLogger.logEvent(
|
||||
AuditEventType.POLICY_UPDATE,
|
||||
userId = generatedBy,
|
||||
module = "reports",
|
||||
details = "Report generated: ${finalReport.reportId}, type: $reportType"
|
||||
)
|
||||
|
||||
Result.success(finalReport)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user