Initial commit

This commit is contained in:
defiQUG
2025-12-26 10:48:33 -08:00
commit 97f75e144f
270 changed files with 35886 additions and 0 deletions

View 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)
}

View File

@@ -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()
)
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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()
}
}

View File

@@ -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
)
}

View File

@@ -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)
}
}

View File

@@ -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()
)

View File

@@ -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
)
}
}
}
}

View File

@@ -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()) }
}
}