Monorepo: Gitea CI, docs, auth/sync, backend APIs, gitignore
- Add Gitea Actions workflow; point README to gitea.d-bis.org/Sankofa_Phoenix/SMOA - Expand .gitignore for Spring H2 data, secrets, Kotlin .kotlin/, tooling - Track docs/api/generated ReDoc bundle; refresh api docs README - Android: network/auth/sync, UI shell, tests; backend credentials/integrity APIs - Docs, scripts (generate-api-docs), modules and core updates Made-with: Cursor
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("com.google.dagger.hilt.android")
|
||||
id("kotlin-kapt")
|
||||
id("dagger.hilt.android.plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -29,6 +29,14 @@ android {
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.4"
|
||||
}
|
||||
|
||||
hilt {
|
||||
enableAggregatingTask = true
|
||||
}
|
||||
}
|
||||
|
||||
kapt {
|
||||
correctErrorTypes = true
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -52,4 +60,3 @@ dependencies {
|
||||
testImplementation(Dependencies.coroutinesTest)
|
||||
testImplementation(Dependencies.truth)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,11 +25,12 @@ class BrowserService @Inject constructor(
|
||||
* Navigate to URL with security checks.
|
||||
*/
|
||||
suspend fun navigateToURL(url: String): Result<String> {
|
||||
// Enforce VPN requirement
|
||||
try {
|
||||
vpnManager.enforceVPNRequirement()
|
||||
} catch (e: VPNRequiredException) {
|
||||
return Result.failure(e)
|
||||
if (vpnManager.isBrowserVPNEnforced()) {
|
||||
try {
|
||||
vpnManager.enforceVPNRequirement()
|
||||
} catch (e: VPNRequiredException) {
|
||||
return Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
// Check URL allow-list
|
||||
|
||||
@@ -13,8 +13,12 @@ class URLFilter @Inject constructor() {
|
||||
private val allowedPaths = mutableMapOf<String, Set<String>>()
|
||||
|
||||
init {
|
||||
// Default allow-list (can be configured via policy)
|
||||
// Add default mission/agency resources here
|
||||
// Demo / dev defaults — replace with policy-backed list in production
|
||||
addAllowedDomain("example.com")
|
||||
addAllowedDomain("www.wikipedia.org")
|
||||
addAllowedDomain("developer.android.com")
|
||||
addAllowedDomain("source.android.com")
|
||||
addAllowedDomain("duckduckgo.com")
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,22 +1,54 @@
|
||||
package com.smoa.modules.browser.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Build
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.smoa.core.security.ScreenProtection
|
||||
import com.smoa.modules.browser.domain.BrowserService
|
||||
import com.smoa.modules.browser.domain.URLFilter
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val DEFAULT_START_URL = "https://example.com"
|
||||
|
||||
/**
|
||||
* Controlled browser screen with VPN enforcement and URL filtering.
|
||||
*/
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BrowserScreen(
|
||||
@@ -25,19 +57,26 @@ fun BrowserScreen(
|
||||
screenProtection: ScreenProtection,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
// Enable screen protection
|
||||
screenProtection.EnableScreenProtection()
|
||||
|
||||
var currentURL by remember { mutableStateOf("") }
|
||||
var urlInput by remember { mutableStateOf("") }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// WebView state
|
||||
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||
|
||||
// Navigate to URL
|
||||
fun normalizeUrl(raw: String): String {
|
||||
val trimmed = raw.trim()
|
||||
if (trimmed.isEmpty()) return trimmed
|
||||
return if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) {
|
||||
"https://$trimmed"
|
||||
} else {
|
||||
trimmed
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun navigateToURL(url: String) {
|
||||
isLoading = true
|
||||
errorMessage = null
|
||||
@@ -45,14 +84,18 @@ fun BrowserScreen(
|
||||
browserService.navigateToURL(url)
|
||||
.onSuccess { allowedURL ->
|
||||
currentURL = allowedURL
|
||||
urlInput = allowedURL
|
||||
isLoading = true
|
||||
webView?.loadUrl(allowedURL)
|
||||
}
|
||||
.onFailure { error ->
|
||||
errorMessage = error.message
|
||||
}
|
||||
.also {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(webView) {
|
||||
if (webView == null) return@LaunchedEffect
|
||||
navigateToURL(DEFAULT_START_URL)
|
||||
}
|
||||
|
||||
Column(
|
||||
@@ -60,7 +103,38 @@ fun BrowserScreen(
|
||||
.fillMaxSize()
|
||||
.padding(8.dp)
|
||||
) {
|
||||
// URL bar
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { if (webView?.canGoBack() == true) webView?.goBack() },
|
||||
enabled = webView?.canGoBack() == true
|
||||
) {
|
||||
Icon(Icons.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
IconButton(
|
||||
onClick = { if (webView?.canGoForward() == true) webView?.goForward() },
|
||||
enabled = webView?.canGoForward() == true
|
||||
) {
|
||||
Icon(Icons.Filled.ArrowForward, contentDescription = "Forward")
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
val target = normalizeUrl(urlInput).ifBlank { currentURL }
|
||||
if (target.isNotBlank()) {
|
||||
scope.launch { navigateToURL(target) }
|
||||
} else {
|
||||
webView?.reload()
|
||||
}
|
||||
},
|
||||
enabled = webView != null
|
||||
) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Reload")
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
@@ -77,16 +151,9 @@ fun BrowserScreen(
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (urlInput.isNotBlank()) {
|
||||
// Add https:// if no protocol specified
|
||||
val url = if (!urlInput.startsWith("http://") && !urlInput.startsWith("https://")) {
|
||||
"https://$urlInput"
|
||||
} else {
|
||||
urlInput
|
||||
}
|
||||
// Navigate (this would need to be in a coroutine scope)
|
||||
// For now, just update the URL
|
||||
currentURL = url
|
||||
val url = normalizeUrl(urlInput)
|
||||
if (url.isNotBlank()) {
|
||||
scope.launch { navigateToURL(url) }
|
||||
}
|
||||
},
|
||||
enabled = !isLoading && urlInput.isNotBlank()
|
||||
@@ -95,9 +162,15 @@ fun BrowserScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Allowed: ${urlFilter.getAllowedDomains().joinToString()}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Error message
|
||||
errorMessage?.let { error ->
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
@@ -114,13 +187,11 @@ fun BrowserScreen(
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// Loading indicator
|
||||
if (isLoading) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// WebView
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
WebView(ctx).apply {
|
||||
@@ -133,19 +204,46 @@ fun BrowserScreen(
|
||||
settings.displayZoomControls = false
|
||||
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
|
||||
// Check if URL is allowed before loading
|
||||
url?.let {
|
||||
if (!browserService.isURLAllowed(it)) {
|
||||
errorMessage = "URL not in allow-list: $it"
|
||||
return true // Block navigation
|
||||
}
|
||||
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
|
||||
val url = request.url?.toString() ?: return false
|
||||
if (!browserService.isURLAllowed(url)) {
|
||||
errorMessage = "URL not in allow-list: $url"
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
return false
|
||||
}
|
||||
val u = url ?: return false
|
||||
if (!browserService.isURLAllowed(u)) {
|
||||
errorMessage = "URL not in allow-list: $u"
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) {
|
||||
isLoading = true
|
||||
url?.let { urlInput = it }
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
isLoading = false
|
||||
url?.let {
|
||||
currentURL = it
|
||||
urlInput = it
|
||||
}
|
||||
return false // Allow navigation
|
||||
}
|
||||
}
|
||||
|
||||
webView = this
|
||||
}
|
||||
},
|
||||
update = { wv ->
|
||||
if (webView !== wv) {
|
||||
webView = wv
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
@@ -154,4 +252,3 @@ fun BrowserScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ class BrowserServiceTest {
|
||||
fun `navigateToURL should fail when VPN not connected`() = runTest {
|
||||
// Given
|
||||
val url = "https://example.com"
|
||||
every { vpnManager.isVPNRequired() } returns true
|
||||
every { vpnManager.isBrowserVPNEnforced() } returns true
|
||||
every { vpnManager.isVPNConnected() } returns false
|
||||
every { vpnManager.enforceVPNRequirement() } throws VPNRequiredException("VPN required")
|
||||
|
||||
@@ -49,8 +49,7 @@ class BrowserServiceTest {
|
||||
fun `navigateToURL should fail when URL not in allow-list`() = runTest {
|
||||
// Given
|
||||
val url = "https://blocked.com"
|
||||
every { vpnManager.isVPNRequired() } returns true
|
||||
every { vpnManager.isVPNConnected() } returns true
|
||||
every { vpnManager.isBrowserVPNEnforced() } returns false
|
||||
every { urlFilter.isAllowed(url) } returns false
|
||||
|
||||
// When
|
||||
@@ -65,8 +64,9 @@ class BrowserServiceTest {
|
||||
fun `navigateToURL should succeed for allowed URL with VPN`() = runTest {
|
||||
// Given
|
||||
val url = "https://allowed.com"
|
||||
every { vpnManager.isVPNRequired() } returns true
|
||||
every { vpnManager.isBrowserVPNEnforced() } returns true
|
||||
every { vpnManager.isVPNConnected() } returns true
|
||||
every { vpnManager.enforceVPNRequirement() } returns Unit
|
||||
every { urlFilter.isAllowed(url) } returns true
|
||||
|
||||
// When
|
||||
|
||||
Reference in New Issue
Block a user