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 {
|
||||
@@ -11,6 +11,7 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
minSdk = AppConfig.minSdk
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
@@ -29,6 +30,14 @@ android {
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.4"
|
||||
}
|
||||
|
||||
hilt {
|
||||
enableAggregatingTask = true
|
||||
}
|
||||
}
|
||||
|
||||
kapt {
|
||||
correctErrorTypes = true
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -59,4 +68,7 @@ dependencies {
|
||||
testImplementation(Dependencies.mockk)
|
||||
testImplementation(Dependencies.coroutinesTest)
|
||||
testImplementation(Dependencies.truth)
|
||||
|
||||
androidTestImplementation(Dependencies.androidxJunit)
|
||||
androidTestImplementation("androidx.test:core:1.5.0")
|
||||
}
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.smoa.core.security
|
||||
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class EncryptionManagerInstrumentedTest {
|
||||
|
||||
@Test
|
||||
fun getOrCreateEncryptionKey_usesAndroidKeyStore() {
|
||||
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
|
||||
val manager = EncryptionManager(context)
|
||||
val alias = "instrumented_smoa_enc_key"
|
||||
|
||||
val key = manager.getOrCreateEncryptionKey(alias)
|
||||
|
||||
assertNotNull(key)
|
||||
assertEquals("AES", key.algorithm)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import android.content.Context
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.sqlite.db.SupportSQLiteOpenHelper
|
||||
import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
|
||||
import javax.crypto.SecretKey
|
||||
import java.security.SecureRandom
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -14,11 +14,12 @@ import javax.inject.Singleton
|
||||
*/
|
||||
@Singleton
|
||||
class EncryptedDatabaseHelper @Inject constructor(
|
||||
private val encryptionManager: EncryptionManager,
|
||||
private val keyManager: KeyManager
|
||||
) {
|
||||
companion object {
|
||||
private const val KEY_ALIAS_PREFIX = "db_encryption_key_"
|
||||
/** Raw key length for SQLCipher (256-bit AES). */
|
||||
private const val RAW_KEY_BYTES = 32
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,14 +34,11 @@ class EncryptedDatabaseHelper @Inject constructor(
|
||||
// Key exists, decode from base64
|
||||
android.util.Base64.decode(keyString, android.util.Base64.DEFAULT)
|
||||
} else {
|
||||
// Generate new key
|
||||
val key = encryptionManager.getOrCreateEncryptionKey(alias)
|
||||
val keyBytes = key.encoded
|
||||
|
||||
// Store key in secure storage (base64 encoded)
|
||||
// AndroidKeyStore SecretKey.getEncoded() is null for hardware-backed keys — unusable for SQLCipher.
|
||||
// Generate random raw key material and persist via EncryptedSharedPreferences.
|
||||
val keyBytes = ByteArray(RAW_KEY_BYTES).also { SecureRandom().nextBytes(it) }
|
||||
val encodedKey = android.util.Base64.encodeToString(keyBytes, android.util.Base64.DEFAULT)
|
||||
keyManager.putSecureString("$KEY_ALIAS_PREFIX$alias", encodedKey)
|
||||
|
||||
keyBytes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,9 @@ package com.smoa.core.security
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.os.Build
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -32,11 +30,8 @@ class ScreenProtection @Inject constructor(
|
||||
WindowManager.LayoutParams.FLAG_SECURE,
|
||||
WindowManager.LayoutParams.FLAG_SECURE
|
||||
)
|
||||
|
||||
// Additional protection for Android 11+
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
WindowCompat.setDecorFitsSystemWindows(activity.window, false)
|
||||
}
|
||||
// Do not call setDecorFitsSystemWindows(false) here: it breaks Scaffold/WindowInsets on
|
||||
// foldables unless every screen consumes insets; FLAG_SECURE alone blocks screenshots.
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -52,6 +52,21 @@ class VPNManager @Inject constructor(
|
||||
return true
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var browserVpnEnforcedFlag: Boolean = false
|
||||
|
||||
/**
|
||||
* Set from app [com.smoa.BuildConfig.SMOA_BROWSER_VPN_ENFORCED] or policy at startup.
|
||||
*/
|
||||
fun setBrowserVpnEnforced(enforced: Boolean) {
|
||||
browserVpnEnforcedFlag = enforced
|
||||
}
|
||||
|
||||
/**
|
||||
* When true, in-app browser navigation enforces an active VPN first.
|
||||
*/
|
||||
fun isBrowserVPNEnforced(): Boolean = browserVpnEnforcedFlag
|
||||
|
||||
/**
|
||||
* Request VPN permission from user.
|
||||
* Returns true if permission is granted or already available.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.smoa.core.security.di
|
||||
|
||||
import android.content.Context
|
||||
import com.smoa.core.security.EncryptedDatabaseHelper
|
||||
import com.smoa.core.security.EncryptionManager
|
||||
import com.smoa.core.security.KeyManager
|
||||
import com.smoa.core.security.ScreenProtection
|
||||
@@ -33,15 +32,6 @@ object SecurityModule {
|
||||
return KeyManager(context, encryptionManager)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideEncryptedDatabaseHelper(
|
||||
encryptionManager: EncryptionManager,
|
||||
keyManager: KeyManager
|
||||
): EncryptedDatabaseHelper {
|
||||
return EncryptedDatabaseHelper(encryptionManager, keyManager)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideScreenProtection(
|
||||
|
||||
@@ -2,55 +2,49 @@ package com.smoa.core.security
|
||||
|
||||
import android.content.Context
|
||||
import io.mockk.mockk
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for EncryptionManager.
|
||||
* `EncryptionManager` opens **AndroidKeyStore** in its constructor; that provider is not available on the
|
||||
* host JVM for unit tests. Prefer instrumented tests on a device/emulator for this class.
|
||||
*/
|
||||
@Ignore("AndroidKeyStore not available in JVM unit tests")
|
||||
class EncryptionManagerTest {
|
||||
private val context = mockk<Context>(relaxed = true)
|
||||
private val encryptionManager = EncryptionManager(context)
|
||||
private val encryptionManager get() = EncryptionManager(context)
|
||||
|
||||
@Test
|
||||
fun `getOrCreateEncryptionKey should create key if not exists`() {
|
||||
// Given
|
||||
val alias = "test_key"
|
||||
val alias = "test_key_encryption_manager"
|
||||
|
||||
// When
|
||||
val key = encryptionManager.getOrCreateEncryptionKey(alias)
|
||||
|
||||
// Then
|
||||
assertNotNull(key)
|
||||
assertEquals("AES", key.algorithm)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getOrCreateEncryptionKey should return same key for same alias`() {
|
||||
// Given
|
||||
val alias = "test_key"
|
||||
val alias = "test_key_encryption_manager_same"
|
||||
|
||||
// When
|
||||
val key1 = encryptionManager.getOrCreateEncryptionKey(alias)
|
||||
val key2 = encryptionManager.getOrCreateEncryptionKey(alias)
|
||||
|
||||
// Then
|
||||
assertNotNull(key1)
|
||||
assertNotNull(key2)
|
||||
// Keys should be the same for the same alias
|
||||
assertArrayEquals(key1.encoded, key2.encoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createEncryptedFile should create encrypted file`() {
|
||||
// Given
|
||||
val fileName = "test_file.txt"
|
||||
val fileName = "test_file_encryption_manager.txt"
|
||||
|
||||
// When
|
||||
val encryptedFile = encryptionManager.createEncryptedFile(fileName)
|
||||
|
||||
// Then
|
||||
assertNotNull(encryptedFile)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,16 @@ class VPNManagerTest {
|
||||
assertTrue(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isBrowserVPNEnforced follows setBrowserVpnEnforced`() {
|
||||
val vpnManager = VPNManager(context)
|
||||
assertFalse(vpnManager.isBrowserVPNEnforced())
|
||||
vpnManager.setBrowserVpnEnforced(true)
|
||||
assertTrue(vpnManager.isBrowserVPNEnforced())
|
||||
vpnManager.setBrowserVpnEnforced(false)
|
||||
assertFalse(vpnManager.isBrowserVPNEnforced())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enforceVPNRequirement should throw exception when VPN not connected`() {
|
||||
// Given
|
||||
|
||||
Reference in New Issue
Block a user