Initial commit
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user