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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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