@file:SuppressLint("LocalContextGetResourceValueCall") package com.example.otp_kommunikator import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.compose.runtime.DisposableEffect import android.annotation.SuppressLint import android.content.Context import android.graphics.Bitmap import android.net.Uri import android.os.Bundle import android.view.WindowManager import androidx.activity.ComponentActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts import androidx.camera.core.CameraSelector import androidx.camera.core.ImageAnalysis import androidx.camera.core.Preview import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.view.PreviewView import androidx.compose.foundation.Image 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.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.rememberScrollState import com.google.zxing.BarcodeFormat import com.google.zxing.EncodeHintType import com.google.zxing.qrcode.QRCodeWriter import net.sqlcipher.database.SQLiteDatabase import java.io.File import java.io.FileOutputStream import java.nio.ByteBuffer import java.security.MessageDigest val loginLock = Any() class SecureTextWatcher(private val onCharsChanged: (CharArray) -> Unit) : android.text.TextWatcher { override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} override fun afterTextChanged(editable: android.text.Editable?) { if (editable == null) { onCharsChanged(CharArray(0)) return } var validLen = 0 for (i in 0 until editable.length) { val c = editable[i] if (c != '\n' && c != '\r') validLen++ } val result = CharArray(validLen) var idx = 0 for (i in 0 until editable.length) { val c = editable[i] if (c != '\n' && c != '\r') result[idx++] = c } onCharsChanged(result) } } class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) SQLiteDatabase.loadLibs(this) setContent { MaterialTheme { Surface(modifier = Modifier.fillMaxSize().systemBarsPadding().imePadding(), color = MaterialTheme.colorScheme.background) { var currentScreen by remember { mutableStateOf("INIT") } var currentContact by remember { mutableStateOf("") } var sessionContactPassword by remember { mutableStateOf(CharArray(0)) } var ignoreNextPause by remember { mutableStateOf(false) } var isDecoyMode by remember { mutableStateOf(false) } val context = LocalContext.current LaunchedEffect(Unit) { try { val prefs = getSecurePrefs(context) if (prefs.getString("global_master_hash", "").isNullOrEmpty()) { currentScreen = "SETUP_GLOBAL" } else { currentScreen = "BLIND_LOGIN" } } catch (e: Exception) { val prefsFile = File(context.applicationInfo.dataDir, "shared_prefs/OTP_Secure_Config.xml") if (prefsFile.exists()) prefsFile.delete() currentScreen = "SETUP_GLOBAL" } } val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_PAUSE) { if (ignoreNextPause) { ignoreNextPause = false } else { sessionContactPassword.fill('0') sessionContactPassword = CharArray(0) currentContact = "" isDecoyMode = false if (currentScreen != "SETUP_GLOBAL" && currentScreen != "INIT") { currentScreen = "BLIND_LOGIN" } } } } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } when (currentScreen) { "INIT" -> { } "SETUP_GLOBAL" -> SetupGlobalScreen( onSetupComplete = { currentScreen = "BLIND_LOGIN" }, onIntentLaunch = { ignoreNextPause = true } ) "BLIND_LOGIN" -> BlindLoginScreen( onMasterLogin = { isDecoyMode = false currentScreen = "CONTACTS" }, onDecoyLogin = { isDecoyMode = true currentScreen = "CONTACTS" }, onPanicTriggered = { currentScreen = "SETUP_GLOBAL" } ) "CONTACTS" -> ContactListScreen( isDecoyMode = isDecoyMode, onContactSelected = { name -> currentContact = name currentScreen = "LOGIN" }, onImportClick = { currentScreen = "IMPORT" }, onGenerateClick = { currentScreen = "GENERATE" }, onAboutClick = { currentScreen = "ABOUT" }, onLockClick = { isDecoyMode = false currentScreen = "BLIND_LOGIN" } ) "LOGIN" -> LoginScreen( isDecoyMode = isDecoyMode, contactName = currentContact, onLoginSuccess = { pwdArray -> sessionContactPassword = pwdArray currentScreen = "MAIN" }, onBackClick = { currentScreen = "CONTACTS" } ) "MAIN" -> MainMenuScreen( isDecoyMode = isDecoyMode, sessionPassword = sessionContactPassword, contactName = currentContact, onSendClick = { currentScreen = "SEND" }, onReceiveClick = { currentScreen = "RECEIVE" }, onBackClick = { sessionContactPassword.fill('0') sessionContactPassword = CharArray(0) currentContact = "" currentScreen = "CONTACTS" } ) "SEND" -> SendScreen(isDecoyMode, sessionContactPassword, currentContact, onBackClick = { currentScreen = "MAIN" }) "RECEIVE" -> ReceiveScreen(isDecoyMode, sessionContactPassword, currentContact, onBackClick = { currentScreen = "MAIN" }) "IMPORT" -> ImportScreen( isDecoyMode = isDecoyMode, onImportSuccess = { name, pwdArray -> currentContact = name sessionContactPassword = pwdArray currentScreen = "MAIN" }, onBackClick = { currentScreen = "CONTACTS" }, onIntentLaunch = { ignoreNextPause = true } ) "GENERATE" -> GenerateScreen(onBackClick = { currentScreen = "CONTACTS" }, onIntentLaunch = { ignoreNextPause = true }) "ABOUT" -> AboutScreen(onBackClick = { currentScreen = "CONTACTS" }) } } } } } } // ==================================================================== // FORENSISCHE UI-KOMPONENTEN & ZERO-ALLOCATION ENCODER (V5.1.0) // ==================================================================== class SecureInputView(context: android.content.Context) : android.view.View(context) { val buffer = CharArray(150) var currentLength = 0 var onLengthChanged: ((Int) -> Unit)? = null val paint = android.graphics.Paint().apply { textSize = 50f isAntiAlias = true textAlign = android.graphics.Paint.Align.LEFT } init { isFocusable = true isFocusableInTouchMode = true buffer.fill('0') } override fun onTouchEvent(event: android.view.MotionEvent): Boolean { if (event.action == android.view.MotionEvent.ACTION_UP) { requestFocus() val imm = context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager imm.showSoftInput(this, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT) } return true } override fun onCheckIsTextEditor(): Boolean = true override fun onCreateInputConnection(outAttrs: android.view.inputmethod.EditorInfo): android.view.inputmethod.InputConnection { outAttrs.inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS outAttrs.imeOptions = android.view.inputmethod.EditorInfo.IME_ACTION_DONE return object : android.view.inputmethod.BaseInputConnection(this, true) { override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean { text?.let { // Blockade: Wenn mehr als 1 Zeichen gesendet wird, handelt es sich um Copy&Paste. if (it.length > 1) return false if (currentLength < 150) { buffer[currentLength++] = it[0] onLengthChanged?.invoke(currentLength) invalidate() } } return true } override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean { if (beforeLength > 0 && currentLength > 0) { currentLength-- buffer[currentLength] = '0' onLengthChanged?.invoke(currentLength) invalidate() return true } return super.deleteSurroundingText(beforeLength, afterLength) } override fun sendKeyEvent(event: android.view.KeyEvent?): Boolean { if (event?.action == android.view.KeyEvent.ACTION_DOWN && event.keyCode == android.view.KeyEvent.KEYCODE_DEL) { if (currentLength > 0) { currentLength-- buffer[currentLength] = '0' onLengthChanged?.invoke(currentLength) invalidate() } return true } return super.sendKeyEvent(event) } } } override fun onDraw(canvas: android.graphics.Canvas) { super.onDraw(canvas) canvas.drawColor(android.graphics.Color.parseColor("#f0f0f0")) val maxWidth = width.toFloat() - 20f var x = 10f var y = paint.textSize + 10f var start = 0 while (start < currentLength) { val count = paint.breakText(buffer, start, currentLength - start, maxWidth, null) canvas.drawText(buffer, start, count, x, y, paint) start += count y += paint.fontSpacing } if (currentLength < 150) { canvas.drawText("_", x + if (currentLength > 0) paint.measureText(buffer, start - 1, 1) else 0f, y - paint.fontSpacing, paint) } } fun clearBuffer() { buffer.fill('0') currentLength = 0 invalidate() onLengthChanged?.invoke(0) } } class SecureCharacterView(context: android.content.Context) : android.view.View(context) { private var chars: CharArray? = null val paint = android.graphics.Paint().apply { textSize = 50f isAntiAlias = true textAlign = android.graphics.Paint.Align.CENTER } fun setChars(newChars: CharArray?) { this.chars = newChars invalidate() } override fun onDraw(canvas: android.graphics.Canvas) { super.onDraw(canvas) val currentChars = chars ?: return if (currentChars.isEmpty()) return val maxWidth = width.toFloat() - 60f val x = width / 2f val lines = mutableListOf>() var start = 0 var i = 0 var lastSpace = -1 while (i < currentChars.size) { if (currentChars[i] == ' ' || currentChars[i] == '\n') { lastSpace = i } if (currentChars[i] == '\n') { lines.add(Pair(start, i - start)) start = i + 1 i = start lastSpace = -1 continue } val length = paint.measureText(currentChars, start, i - start + 1) if (length > maxWidth) { if (lastSpace > start) { lines.add(Pair(start, lastSpace - start)) start = lastSpace + 1 i = start } else { if (i > start) { lines.add(Pair(start, i - start)) start = i } else { lines.add(Pair(start, i - start + 1)) start = i + 1 i = start } } lastSpace = -1 } else { i++ } } if (start < currentChars.size) { lines.add(Pair(start, currentChars.size - start)) } val lineHeight = paint.fontSpacing val totalHeight = lines.size * lineHeight var y = (height / 2f) - (totalHeight / 2f) + (paint.textSize / 2f) for (line in lines) { if (line.second > 0) { canvas.drawText(currentChars, line.first, line.second, x, y, paint) } y += lineHeight } } } fun manualUtf8Encode(chars: CharArray, length: Int): ByteArray { var byteCount = 0 for (i in 0 until length) { val c = chars[i].code if (c <= 0x7F) byteCount++ else if (c <= 0x7FF) byteCount += 2 else byteCount += 3 } val bytes = ByteArray(byteCount) var bIdx = 0 for (i in 0 until length) { val c = chars[i].code if (c <= 0x7F) { bytes[bIdx++] = c.toByte() } else if (c <= 0x7FF) { bytes[bIdx++] = (0xC0 or (c shr 6)).toByte() bytes[bIdx++] = (0x80 or (c and 0x3F)).toByte() } else { bytes[bIdx++] = (0xE0 or (c shr 12)).toByte() bytes[bIdx++] = (0x80 or ((c shr 6) and 0x3F)).toByte() bytes[bIdx++] = (0x80 or (c and 0x3F)).toByte() } } return bytes } // ==================================================================== // SCREENS // ==================================================================== @Composable fun SetupGlobalScreen(onSetupComplete: () -> Unit, onIntentLaunch: () -> Unit) { val context = LocalContext.current var masterPwdChars by remember { mutableStateOf(CharArray(0)) } var decoyPwdChars by remember { mutableStateOf(CharArray(0)) } var panicPwdChars by remember { mutableStateOf(CharArray(0)) } var isSaving by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf("") } var selectedEntropyUri by remember { mutableStateOf(null) } var entropyFileName by remember { mutableStateOf("") } val filePickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> selectedEntropyUri = uri entropyFileName = uri?.path?.split("/")?.last() ?: "entropy.bin" errorMessage = "" } Column(modifier = Modifier.fillMaxSize().padding(32.dp).verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { Text("Systeminitialisierung 5.1", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) Spacer(modifier = Modifier.height(16.dp)) Text("Richte die drei getrennten Zugangs-Ebenen ein.", textAlign = TextAlign.Center) Spacer(modifier = Modifier.height(24.dp)) Button(onClick = { onIntentLaunch(); filePickerLauncher.launch(arrayOf("*/*")) }) { Text(if (selectedEntropyUri == null) "entropy.bin für Salts wählen" else "Entropie geladen") } Spacer(modifier = Modifier.height(24.dp)) AndroidView(factory = { ctx -> android.widget.EditText(ctx).apply { hint = "1. MASTER-Passwort (Zwingend erforderlich)"; inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD; disableCopyPaste(this); addTextChangedListener(SecureTextWatcher { chars -> masterPwdChars.fill('0'); masterPwdChars = chars }) } }, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) AndroidView(factory = { ctx -> android.widget.EditText(ctx).apply { hint = "2. DECOY-Passwort (Optional)"; inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD; disableCopyPaste(this); addTextChangedListener(SecureTextWatcher { chars -> decoyPwdChars.fill('0'); decoyPwdChars = chars }) } }, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) AndroidView(factory = { ctx -> android.widget.EditText(ctx).apply { hint = "3. PANIC-Passwort (Optional)"; inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD; disableCopyPaste(this); addTextChangedListener(SecureTextWatcher { chars -> panicPwdChars.fill('0'); panicPwdChars = chars }) } }, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) if (errorMessage.isNotEmpty()) { Spacer(modifier = Modifier.height(8.dp)); Text(errorMessage, color = MaterialTheme.colorScheme.error, textAlign = TextAlign.Center, fontWeight = FontWeight.Bold) } Spacer(modifier = Modifier.height(32.dp)) if (isSaving) { Text("Berechne Kryptografie...", color = MaterialTheme.colorScheme.primary) LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) } else { Button( onClick = { if (masterPwdChars.isEmpty() || selectedEntropyUri == null) return@Button isSaving = true; errorMessage = "" Thread { try { val entropyStream = context.contentResolver.openInputStream(selectedEntropyUri!!) ?: throw Exception("Entropie-Datei Fehler.") val entropyData = ByteArray(112) var total = 0 while (total < 112) { val r = entropyStream.read(entropyData, total, 112 - total) if (r == -1) throw Exception("Zu wenig Entropie in der Datei!") total += r } entropyStream.close() val masterSalt = entropyData.copyOfRange(0, 16) val decoySalt = entropyData.copyOfRange(16, 32) val panicSalt = entropyData.copyOfRange(32, 48) val mKey = CryptoManager.deriveKeyArgon2(masterPwdChars, masterSalt) val mHash = CryptoManager.byteArrayToHex(CryptoManager.getSHA256(CryptoManager.deriveHKDF(mKey, "AUTH"))) mKey.fill(0) val dHash = if (decoyPwdChars.isNotEmpty()) { val dKey = CryptoManager.deriveKeyArgon2(decoyPwdChars, decoySalt) val hash = CryptoManager.byteArrayToHex(CryptoManager.getSHA256(CryptoManager.deriveHKDF(dKey, "AUTH"))) dKey.fill(0) hash } else { CryptoManager.byteArrayToHex(entropyData.copyOfRange(48, 80)) } val pHash = if (panicPwdChars.isNotEmpty()) { val pKey = CryptoManager.deriveKeyArgon2(panicPwdChars, panicSalt) val hash = CryptoManager.byteArrayToHex(CryptoManager.getSHA256(CryptoManager.deriveHKDF(pKey, "AUTH"))) pKey.fill(0) hash } else { CryptoManager.byteArrayToHex(entropyData.copyOfRange(80, 112)) } entropyData.fill(0) val rand = ByteArray(16) java.security.SecureRandom().nextBytes(rand) val deviceSalt = CryptoManager.byteArrayToHex(rand) val prefs = getSecurePrefs(context) prefs.edit() .putString("global_master_salt", CryptoManager.byteArrayToHex(masterSalt)) .putString("global_master_hash", mHash) .putString("global_decoy_salt", CryptoManager.byteArrayToHex(decoySalt)) .putString("global_decoy_hash", dHash) .putString("global_panic_salt", CryptoManager.byteArrayToHex(panicSalt)) .putString("global_panic_hash", pHash) .putString("local_device_salt", deviceSalt) .commit() android.os.Handler(android.os.Looper.getMainLooper()).post { masterPwdChars.fill('0'); decoyPwdChars.fill('0'); panicPwdChars.fill('0'); isSaving = false; onSetupComplete() } } catch (e: Exception) { android.os.Handler(android.os.Looper.getMainLooper()).post { errorMessage = e.message ?: "Fehler."; isSaving = false } } }.start() }, modifier = Modifier.fillMaxWidth().height(60.dp) ) { Text("System versiegeln") } } } } @Composable fun BlindLoginScreen(onMasterLogin: (CharArray) -> Unit, onDecoyLogin: (CharArray) -> Unit, onPanicTriggered: () -> Unit) { val context = LocalContext.current var inputChars by remember { mutableStateOf(CharArray(0)) } var errorMessage by remember { mutableStateOf("") } var isChecking by remember { mutableStateOf(false) } Column(modifier = Modifier.fillMaxSize().padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { Text("Systemzugriff", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold); Spacer(modifier = Modifier.height(32.dp)) AndroidView(factory = { ctx -> android.widget.EditText(ctx).apply { hint = "Passwort eingeben"; inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD; disableCopyPaste(this); addTextChangedListener(SecureTextWatcher { chars -> inputChars.fill('0'); inputChars = chars }) } }, modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) Spacer(modifier = Modifier.height(16.dp)) if (errorMessage.isNotEmpty()) { Text(errorMessage, color = MaterialTheme.colorScheme.error, textAlign = TextAlign.Center); Spacer(modifier = Modifier.height(16.dp)) } if (isChecking) { LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp)); Spacer(modifier = Modifier.height(16.dp)) } Button( onClick = { if (inputChars.isEmpty()) return@Button isChecking = true; errorMessage = "" Thread { val prefs = getSecurePrefs(context) val mSalt = CryptoManager.hexToByteArray(prefs.getString("global_master_salt", "") ?: "") val dSalt = CryptoManager.hexToByteArray(prefs.getString("global_decoy_salt", "") ?: "") val pSalt = CryptoManager.hexToByteArray(prefs.getString("global_panic_salt", "") ?: "") val mHashStr = prefs.getString("global_master_hash", "") ?: "" val dHashStr = prefs.getString("global_decoy_hash", "") ?: "" val pHashStr = prefs.getString("global_panic_hash", "") ?: "" var isPanic = false var isMaster = false var isDecoy = false if (pHashStr.isNotEmpty() && pSalt.isNotEmpty()) { val pKey = CryptoManager.deriveKeyArgon2(inputChars, pSalt) if (MessageDigest.isEqual(CryptoManager.getSHA256(CryptoManager.deriveHKDF(pKey, "AUTH")), CryptoManager.hexToByteArray(pHashStr))) isPanic = true pKey.fill(0) } if (mHashStr.isNotEmpty() && mSalt.isNotEmpty()) { val mKey = CryptoManager.deriveKeyArgon2(inputChars, mSalt) if (MessageDigest.isEqual(CryptoManager.getSHA256(CryptoManager.deriveHKDF(mKey, "AUTH")), CryptoManager.hexToByteArray(mHashStr))) isMaster = true mKey.fill(0) } if (dHashStr.isNotEmpty() && dSalt.isNotEmpty()) { val dKey = CryptoManager.deriveKeyArgon2(inputChars, dSalt) if (MessageDigest.isEqual(CryptoManager.getSHA256(CryptoManager.deriveHKDF(dKey, "AUTH")), CryptoManager.hexToByteArray(dHashStr))) isDecoy = true dKey.fill(0) } if (isPanic) { wipeAllData(context) android.os.Handler(android.os.Looper.getMainLooper()).post { inputChars.fill('0'); isChecking = false; onPanicTriggered() } return@Thread } if (isMaster) { android.os.Handler(android.os.Looper.getMainLooper()).post { val pwdCopy = inputChars.clone(); inputChars.fill('0'); isChecking = false; onMasterLogin(pwdCopy) } return@Thread } if (isDecoy) { android.os.Handler(android.os.Looper.getMainLooper()).post { val pwdCopy = inputChars.clone(); inputChars.fill('0'); isChecking = false; onDecoyLogin(pwdCopy) } return@Thread } Thread.sleep(500) android.os.Handler(android.os.Looper.getMainLooper()).post { errorMessage = "Zugriff verweigert."; inputChars.fill('0'); isChecking = false } }.start() }, enabled = !isChecking && inputChars.isNotEmpty(), modifier = Modifier.fillMaxWidth().height(60.dp) ) { Text(if (isChecking) "Prüfe..." else "Entsperren") } } } @Composable fun ContactListScreen(isDecoyMode: Boolean, onContactSelected: (String) -> Unit, onImportClick: () -> Unit, onGenerateClick: () -> Unit, onAboutClick: () -> Unit, onLockClick: () -> Unit) { val context = LocalContext.current var contacts by remember { mutableStateOf>(emptyList()) } LaunchedEffect(isDecoyMode) { contacts = getContactList(context, isDecoyMode).sorted() } Column(modifier = Modifier.fillMaxSize().padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { Text("Identitäten & Kontakte", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold); Spacer(modifier = Modifier.height(32.dp)) if (contacts.isEmpty()) { Text("Keine Kontakte in diesem Profil gefunden.", color = MaterialTheme.colorScheme.onSurfaceVariant); Spacer(modifier = Modifier.height(16.dp)) } else { contacts.forEach { contact -> Button(onClick = { onContactSelected(contact) }, modifier = Modifier.fillMaxWidth(0.8f).padding(vertical = 4.dp)) { Text(contact, fontWeight = FontWeight.Bold) } } Spacer(modifier = Modifier.height(24.dp)) } OutlinedButton(onClick = onImportClick, modifier = Modifier.fillMaxWidth(0.8f)) { Text("Neuen Kontakt einlesen") } Spacer(modifier = Modifier.height(8.dp)) OutlinedButton(onClick = onGenerateClick, modifier = Modifier.fillMaxWidth(0.8f)) { Text("Neue OTP-Schlüssel erzeugen") } Spacer(modifier = Modifier.height(32.dp)) Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { TextButton(onClick = onAboutClick) { Text("Über die App") }; Spacer(modifier = Modifier.width(16.dp)) TextButton(onClick = onLockClick, colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)) { Text("Sitzung sperren") } } } } @Composable fun LoginScreen(isDecoyMode: Boolean, contactName: String, onLoginSuccess: (CharArray) -> Unit, onBackClick: () -> Unit) { val context = LocalContext.current var passwordChars by remember { mutableStateOf(CharArray(0)) } var errorMessage by remember { mutableStateOf("") } var isLoggingIn by remember { mutableStateOf(false) } val obfuscatedId = getObfuscatedId(context, contactName, isDecoyMode) val dbFile = File(context.filesDir, "$obfuscatedId.db") val prefs = getSecurePrefs(context) if (!dbFile.exists()) { LaunchedEffect(Unit) { onBackClick() } return } Column(modifier = Modifier.fillMaxSize().padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { Text("Login: $contactName", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary); Spacer(modifier = Modifier.height(32.dp)) AndroidView(factory = { ctx -> android.widget.EditText(ctx).apply { hint = "Kontakt-Passwort"; inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD; disableCopyPaste(this); addTextChangedListener(SecureTextWatcher { chars -> passwordChars.fill('0'); passwordChars = chars }) } }, modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) Spacer(modifier = Modifier.height(16.dp)) if (errorMessage.isNotEmpty()) { Text(errorMessage, color = MaterialTheme.colorScheme.error, textAlign = TextAlign.Center); Spacer(modifier = Modifier.height(16.dp)) } if (isLoggingIn) { Text("Entschlüssele Kontakt (Argon2)...", color = MaterialTheme.colorScheme.primary); Spacer(modifier = Modifier.height(8.dp)); LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp)); Spacer(modifier = Modifier.height(16.dp)) } Button( onClick = { if (passwordChars.isEmpty()) return@Button isLoggingIn = true; errorMessage = "" Thread { synchronized(loginLock) { val savedSafeHashHex = prefs.getString("${obfuscatedId}_safe_pin_hash", "") ?: "" val savedPanicHashHex = prefs.getString("${obfuscatedId}_panic_pin_hash", "") ?: "" val savedSaltHex = prefs.getString("${obfuscatedId}_argon_salt", "") ?: "" var failedAttempts = prefs.getInt("${obfuscatedId}_failed_attempts", 0) if (savedSaltHex.isEmpty()) { android.os.Handler(android.os.Looper.getMainLooper()).post { errorMessage = "Systemfehler: Salt fehlt."; isLoggingIn = false } return@Thread } val dbSalt = CryptoManager.hexToByteArray(savedSaltHex) val masterKey = CryptoManager.deriveKeyArgon2(passwordChars, dbSalt) val inputHashBytes = CryptoManager.getSHA256(CryptoManager.deriveHKDF(masterKey, "LOGIN_AUTH_KEY")) masterKey.fill(0) val savedPanicHashBytes = CryptoManager.hexToByteArray(savedPanicHashHex) val savedSafeHashBytes = CryptoManager.hexToByteArray(savedSafeHashHex) val isPanic = savedPanicHashHex.isNotEmpty() && MessageDigest.isEqual(inputHashBytes, savedPanicHashBytes) val isSafe = savedSafeHashHex.isNotEmpty() && MessageDigest.isEqual(inputHashBytes, savedSafeHashBytes) inputHashBytes.fill(0); savedPanicHashBytes.fill(0); savedSafeHashBytes.fill(0) if (isPanic) { android.os.Handler(android.os.Looper.getMainLooper()).post { if (dbFile.exists()) dbFile.delete() removeContactFromIndex(context, isDecoyMode, contactName) prefs.edit().remove("${obfuscatedId}_argon_salt").remove("${obfuscatedId}_safe_pin_hash").remove("${obfuscatedId}_panic_pin_hash").remove("${obfuscatedId}_failed_attempts").commit() passwordChars.fill('0'); isLoggingIn = false; onBackClick() } } else if (isSafe) { android.os.Handler(android.os.Looper.getMainLooper()).post { prefs.edit().putInt("${obfuscatedId}_failed_attempts", 0).commit() onLoginSuccess(passwordChars.clone()); passwordChars.fill('0'); isLoggingIn = false } } else { android.os.Handler(android.os.Looper.getMainLooper()).post { failedAttempts++ if (failedAttempts >= 5) { if (dbFile.exists()) dbFile.delete() removeContactFromIndex(context, isDecoyMode, contactName) prefs.edit().remove("${obfuscatedId}_argon_salt").remove("${obfuscatedId}_safe_pin_hash").remove("${obfuscatedId}_panic_pin_hash").remove("${obfuscatedId}_failed_attempts").commit() errorMessage = "5 Fehlversuche. Kontakt gelöscht."; onBackClick() } else { prefs.edit().putInt("${obfuscatedId}_failed_attempts", failedAttempts).commit() errorMessage = "Falsches Passwort ($failedAttempts/5)." } passwordChars.fill('0'); isLoggingIn = false } } } }.start() }, enabled = !isLoggingIn && passwordChars.isNotEmpty(), modifier = Modifier.fillMaxWidth().height(60.dp) ) { Text(if (isLoggingIn) "Entsperrt..." else "Kontakt öffnen") } Spacer(modifier = Modifier.height(24.dp)) TextButton(onClick = onBackClick, enabled = !isLoggingIn) { Text("Abbrechen") } } } @Composable fun MainMenuScreen(isDecoyMode: Boolean, sessionPassword: CharArray, contactName: String, onSendClick: () -> Unit, onReceiveClick: () -> Unit, onBackClick: () -> Unit) { val context = LocalContext.current var sendKeysCount by remember { mutableStateOf(0) } var receiveKeysCount by remember { mutableStateOf(0) } LaunchedEffect(Unit) { try { val db = openLocalDatabase(context, isDecoyMode, sessionPassword, contactName) if (db != null) { sendKeysCount = getRemainingKeyCount(db, "SEND") receiveKeysCount = getRemainingKeyCount(db, "RECEIVE") db.close() } } catch (e: Exception) { // Fehler lautlos abfangen, UI bleibt stabil } } Column(modifier = Modifier.fillMaxSize().padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { Text("Kontakt: $contactName", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold); Spacer(modifier = Modifier.height(8.dp)) Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { Column(modifier = Modifier.padding(16.dp).fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { Text("Schlüsselvorrat", fontWeight = FontWeight.Bold); Spacer(modifier = Modifier.height(8.dp)); Text("Senden: $sendKeysCount | Empfangen: $receiveKeysCount") } } Spacer(modifier = Modifier.height(32.dp)) Button(onClick = onSendClick, modifier = Modifier.fillMaxWidth().height(60.dp)) { Text("Nachricht senden") } Spacer(modifier = Modifier.height(16.dp)) Button(onClick = onReceiveClick, modifier = Modifier.fillMaxWidth().height(60.dp)) { Text("Nachricht empfangen") } Spacer(modifier = Modifier.height(32.dp)) OutlinedButton(onClick = onBackClick, modifier = Modifier.fillMaxWidth().height(50.dp)) { Text("Schließen") } } } @Composable fun GenerateScreen(onBackClick: () -> Unit, onIntentLaunch: () -> Unit) { val context = LocalContext.current var keyCountText by remember { mutableStateOf("1000") } var safePinAChars by remember { mutableStateOf(CharArray(0)) } var safePinBChars by remember { mutableStateOf(CharArray(0)) } var isGenerating by remember { mutableStateOf(false) } var resultPath by remember { mutableStateOf(null) } var errorMessage by remember { mutableStateOf("") } var selectedEntropyUri by remember { mutableStateOf(null) } var entropyFileName by remember { mutableStateOf("") } val scrollState = rememberScrollState() val filePickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> selectedEntropyUri = uri; entropyFileName = uri?.path?.split("/")?.last() ?: "entropy.bin"; errorMessage = "" } Column(modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(scrollState), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { Text(stringResource(R.string.gen_title), style = MaterialTheme.typography.headlineSmall); Spacer(modifier = Modifier.height(16.dp)) OutlinedTextField(value = keyCountText, onValueChange = { if (it.all { char -> char.isDigit() }) keyCountText = it }, label = { Text(stringResource(R.string.gen_label_count)) }, modifier = Modifier.fillMaxWidth()); Spacer(modifier = Modifier.height(24.dp)) Text("Strikter TRNG-Zwang:", fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary); Text("Wähle die externe entropy.bin Datei (TAILS)", textAlign = TextAlign.Center, style = MaterialTheme.typography.bodySmall); Spacer(modifier = Modifier.height(8.dp)) Button(onClick = { onIntentLaunch(); filePickerLauncher.launch(arrayOf("*/*")) }) { Text(if (selectedEntropyUri == null) "entropy.bin auswählen" else "Datei gewählt") } if (entropyFileName.isNotEmpty()) { Text(entropyFileName, style = MaterialTheme.typography.bodySmall) } if (errorMessage.isNotEmpty()) { Spacer(modifier = Modifier.height(8.dp)); Text(errorMessage, color = MaterialTheme.colorScheme.error, textAlign = TextAlign.Center, fontWeight = FontWeight.Bold) } Spacer(modifier = Modifier.height(24.dp)) AndroidView(factory = { ctx -> android.widget.EditText(ctx).apply { hint = "Passwort für GERÄT A (Deins)"; inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD; disableCopyPaste(this); addTextChangedListener(SecureTextWatcher { chars -> safePinAChars.fill('0'); safePinAChars = chars }) } }, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) AndroidView(factory = { ctx -> android.widget.EditText(ctx).apply { hint = "Passwort für GERÄT B (Partner)"; inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD; disableCopyPaste(this); addTextChangedListener(SecureTextWatcher { chars -> safePinBChars.fill('0'); safePinBChars = chars }) } }, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) Spacer(modifier = Modifier.height(32.dp)) if (isGenerating) { Text("Verarbeite Schlüssel (Argon2)...", color = MaterialTheme.colorScheme.primary); Spacer(modifier = Modifier.height(8.dp)); LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(4.dp)); Spacer(modifier = Modifier.height(16.dp)) } Button(onClick = { val count = keyCountText.toIntOrNull() ?: 0 if (count > 0 && safePinAChars.isNotEmpty() && safePinBChars.isNotEmpty() && selectedEntropyUri != null) { isGenerating = true; errorMessage = "" val charArrayA = safePinAChars.clone(); val charArrayB = safePinBChars.clone() safePinAChars.fill('0'); safePinBChars.fill('0') Thread { try { val path = generateKeyDatabases(context, count, charArrayA, charArrayB, selectedEntropyUri!!) android.os.Handler(android.os.Looper.getMainLooper()).post { resultPath = path; isGenerating = false } } catch (e: Exception) { android.os.Handler(android.os.Looper.getMainLooper()).post { errorMessage = e.message ?: "Fehler"; isGenerating = false } } }.start() } }, enabled = !isGenerating && keyCountText.isNotEmpty() && selectedEntropyUri != null, modifier = Modifier.fillMaxWidth().height(60.dp)) { Text(if (isGenerating) stringResource(R.string.gen_btn_calculating) else "Datenbanken individuell versiegeln") } Spacer(modifier = Modifier.height(16.dp)) if (resultPath != null) { Text(stringResource(R.string.gen_msg_success), color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold); Spacer(modifier = Modifier.height(8.dp)); Text(stringResource(R.string.gen_msg_path) + "\n$resultPath", textAlign = TextAlign.Center) } Spacer(modifier = Modifier.height(32.dp)) Button(onClick = onBackClick, enabled = !isGenerating, modifier = Modifier.fillMaxWidth()) { Text("Zurück") } } } @Composable fun ImportScreen(isDecoyMode: Boolean, onImportSuccess: (String, CharArray) -> Unit, onBackClick: () -> Unit, onIntentLaunch: () -> Unit) { val context = LocalContext.current var importStatus by remember { mutableStateOf(null) } var selectedSdFile by remember { mutableStateOf(null) } var contactName by remember { mutableStateOf("") } var safePinChars by remember { mutableStateOf(CharArray(0)) } var panicPinChars by remember { mutableStateOf(CharArray(0)) } var isImporting by remember { mutableStateOf(false) } var availableSdFiles by remember { mutableStateOf>(emptyList()) } var sdCardDetected by remember { mutableStateOf(false) } val scrollState = rememberScrollState() LaunchedEffect(Unit) { val externalDirs = ContextCompat.getExternalFilesDirs(context, null) val foundFiles = mutableListOf() if (externalDirs.size > 1) { sdCardDetected = true for (i in 1 until externalDirs.size) { val dir = externalDirs[i] if (dir != null) { val files = dir.listFiles { file -> file.extension.lowercase() == "db" && file.name.contains("Geraet") }?.toList() ?: emptyList() foundFiles.addAll(files) } } } availableSdFiles = foundFiles } Column(modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(scrollState), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { Text(stringResource(R.string.imp_title), style = MaterialTheme.typography.headlineSmall); Spacer(modifier = Modifier.height(24.dp)) if (selectedSdFile == null) { if (!sdCardDetected) { Text("KEINE SD-KARTE ERKANNT", color = MaterialTheme.colorScheme.error, fontWeight = FontWeight.Bold) Text("Bitte stelle sicher, dass die MicroSD-Karte fest eingesteckt ist.", textAlign = TextAlign.Center) } else if (availableSdFiles.isEmpty()) { Text("SD-Karte erkannt, aber keine Datenbanken gefunden.", textAlign = TextAlign.Center) } else { Text("Datenbanken auf SD-Karte gefunden:", fontWeight = FontWeight.Bold); Spacer(modifier = Modifier.height(16.dp)) availableSdFiles.forEach { file -> Button(onClick = { selectedSdFile = file; importStatus = null }, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { Text(file.name) } } } Spacer(modifier = Modifier.height(32.dp)) val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> if (uri != null) { importStatus = "Manueller Import gestartet..." } } TextButton(onClick = { onIntentLaunch(); launcher.launch("*/*") }) { Text("Anderen Ordner durchsuchen") } } else { Text("Gewählte Datenbank:", style = MaterialTheme.typography.labelLarge) Text(selectedSdFile!!.name, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary); Spacer(modifier = Modifier.height(24.dp)) OutlinedTextField(value = contactName, onValueChange = { if (it.matches(Regex("^[a-zA-Z0-9_]*$"))) contactName = it }, label = { Text("Pseudonym / Kontaktname") }, modifier = Modifier.fillMaxWidth()); Spacer(modifier = Modifier.height(8.dp)) AndroidView(factory = { ctx -> android.widget.EditText(ctx).apply { hint = "Passwort für diesen Kontakt"; inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD; disableCopyPaste(this); addTextChangedListener(SecureTextWatcher { chars -> safePinChars.fill('0'); safePinChars = chars }) } }, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) AndroidView(factory = { ctx -> android.widget.EditText(ctx).apply { hint = "Lösch-Passwort (nur für diesen Kontakt)"; inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD; disableCopyPaste(this); addTextChangedListener(SecureTextWatcher { chars -> panicPinChars.fill('0'); panicPinChars = chars }) } }, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) Spacer(modifier = Modifier.height(24.dp)) if (importStatus != null) { Text(importStatus!!, color = MaterialTheme.colorScheme.error, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center); Spacer(modifier = Modifier.height(16.dp)) } if (isImporting) { Text("Verifiziere Datenbank direkt auf SD-Karte...", color = MaterialTheme.colorScheme.primary) LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) } else { Button( onClick = { isImporting = true; importStatus = null Thread { try { val nameParts = selectedSdFile!!.nameWithoutExtension.split("_") val extractedSaltHex = nameParts.last() if (extractedSaltHex.length != 32) throw Exception("Dateiname ungültig") val dbSalt = CryptoManager.hexToByteArray(extractedSaltHex) val testDb = openSecureDatabase(selectedSdFile!!, safePinChars, dbSalt) if (testDb != null) { testDb.close() val obfuscatedId = getObfuscatedId(context, contactName, isDecoyMode) val finalFile = File(context.filesDir, "$obfuscatedId.db") if (finalFile.exists()) finalFile.delete() selectedSdFile!!.inputStream().use { input -> FileOutputStream(finalFile).use { output -> input.copyTo(output) } } val prefs = getSecurePrefs(context) val masterSafe = CryptoManager.deriveKeyArgon2(safePinChars, dbSalt) val safeHash = CryptoManager.byteArrayToHex(CryptoManager.getSHA256(CryptoManager.deriveHKDF(masterSafe, "LOGIN_AUTH_KEY"))) masterSafe.fill(0) val masterPanic = CryptoManager.deriveKeyArgon2(panicPinChars, dbSalt) val panicHash = CryptoManager.byteArrayToHex(CryptoManager.getSHA256(CryptoManager.deriveHKDF(masterPanic, "LOGIN_AUTH_KEY"))) masterPanic.fill(0) prefs.edit() .putString("${obfuscatedId}_argon_salt", extractedSaltHex) .putString("${obfuscatedId}_safe_pin_hash", safeHash) .putString("${obfuscatedId}_panic_pin_hash", panicHash) .commit() addContactToIndex(context, isDecoyMode, contactName) selectedSdFile!!.delete() android.os.Handler(android.os.Looper.getMainLooper()).post { onImportSuccess(contactName, safePinChars.clone()) safePinChars.fill('0'); panicPinChars.fill('0') } } else { throw Exception("Authentifizierung fehlgeschlagen.") } } catch (e: Exception) { android.os.Handler(android.os.Looper.getMainLooper()).post { importStatus = "Fehler: Passwort falsch oder Datei beschädigt." isImporting = false } } }.start() }, enabled = safePinChars.isNotEmpty() && panicPinChars.isNotEmpty() && contactName.isNotEmpty(), modifier = Modifier.fillMaxWidth().height(60.dp) ) { Text("Als '$contactName' einlesen") } } } Spacer(modifier = Modifier.height(32.dp)) Button(onClick = onBackClick, enabled = !isImporting, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.btn_back)) } } } @Composable fun SendScreen(isDecoyMode: Boolean, sessionPassword: CharArray, contactName: String, onBackClick: () -> Unit) { val context = LocalContext.current var currentCharsLength by remember { mutableStateOf(0) } var currentBytesLength by remember { mutableStateOf(0) } var secureInputView by remember { mutableStateOf(null) } var qrBitmap by remember { mutableStateOf(null) } var statusText by remember { mutableStateOf("Initialisiere...") } var remainingKeys by remember { mutableStateOf(0) } var isEncrypting by remember { mutableStateOf(false) } var isLoaded by remember { mutableStateOf(false) } LaunchedEffect(Unit) { Thread { try { val db = openLocalDatabase(context, isDecoyMode, sessionPassword, contactName) if (db != null) { val count = getRemainingKeyCount(db, "SEND") db.close() android.os.Handler(android.os.Looper.getMainLooper()).post { remainingKeys = count statusText = "Bereit" isLoaded = true } } else { android.os.Handler(android.os.Looper.getMainLooper()).post { statusText = "Fehler beim Laden"; isLoaded = true } } } catch (e: Exception) { android.os.Handler(android.os.Looper.getMainLooper()).post { statusText = "DB-Fehler: ${e.message}"; isLoaded = true } } }.start() } Column(modifier = Modifier.fillMaxSize().padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally) { if (!isLoaded) { Text("Lade Kontakt-Datenbank...") LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) } else { Text("Nachrichten verbleibend: $remainingKeys", modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.End, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold) Spacer(modifier = Modifier.height(8.dp)) val currentOnSurfaceColor = with(MaterialTheme.colorScheme.onSurface) { android.graphics.Color.argb((alpha * 255).toInt(), (red * 255).toInt(), (green * 255).toInt(), (blue * 255).toInt()) } AndroidView( factory = { ctx -> SecureInputView(ctx).apply { paint.color = currentOnSurfaceColor onLengthChanged = { length -> currentCharsLength = length var bytes = 0 for (i in 0 until length) { val c = buffer[i].code if (c <= 0x7F) bytes++ else if (c <= 0x7FF) bytes += 2 else bytes += 3 } currentBytesLength = bytes } secureInputView = this } }, modifier = Modifier.fillMaxWidth().height(150.dp).padding(vertical = 8.dp) ) Text("$currentBytesLength / 150 Bytes", modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.End, color = if (currentBytesLength > 150) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant) Spacer(modifier = Modifier.height(16.dp)) Button( onClick = { val inputView = secureInputView ?: return@Button if (currentCharsLength == 0 || currentBytesLength > 150 || isEncrypting) return@Button isEncrypting = true statusText = "Verschlüssle (Argon2)..." Thread { var resultBitmap: Bitmap? = null var resultText = "Fehler" var resultRemaining = remainingKeys var success = false try { val db = openLocalDatabase(context, isDecoyMode, sessionPassword, contactName) if (db != null) { try { db.beginTransaction() val otpKey = getNextKey(db, "SEND") val keyLength = getConfigValue(db, "key_length").toIntOrNull() ?: 182 if (otpKey != null) { val msgBytes = manualUtf8Encode(inputView.buffer, currentCharsLength) val targetLength = keyLength - 32 val paddedBytes = CryptoManager.padToTargetLength(msgBytes, targetLength) val encryptedPayloadBytes = CryptoManager.encryptWithHMAC(paddedBytes, otpKey.keyMaterial) val encryptedPayloadHex = CryptoManager.byteArrayToHex(encryptedPayloadBytes) deleteKey(db, "SEND", otpKey.index) db.setTransactionSuccessful() resultBitmap = generateQrCodeBitmap(otpKey.index, encryptedPayloadHex) resultRemaining = getRemainingKeyCount(db, "SEND") resultText = "Verschlüsselt (ID: ${otpKey.index})" success = true otpKey.destroy() msgBytes.fill(0) paddedBytes.fill(0) } else { resultText = "Keine Schlüssel vorhanden!" } } catch (e: Exception) { resultText = "Fehler: ${e.message}" } finally { db.endTransaction() db.close() } } } catch (e: Exception) { resultText = "DB-Fehler: ${e.message}" } android.os.Handler(android.os.Looper.getMainLooper()).post { qrBitmap = resultBitmap statusText = resultText remainingKeys = resultRemaining if (success) inputView.clearBuffer() isEncrypting = false } }.start() }, enabled = currentCharsLength > 0 && currentBytesLength <= 150 && !isEncrypting, modifier = Modifier.fillMaxWidth().height(60.dp) ) { if (isEncrypting) { CircularProgressIndicator(modifier = Modifier.size(24.dp), color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp) Spacer(modifier = Modifier.width(12.dp)) Text("Bitte warten...") } else { Text("QR-Code erzeugen") } } } Spacer(modifier = Modifier.height(16.dp)) Button(onClick = onBackClick, enabled = !isEncrypting, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), modifier = Modifier.fillMaxWidth()) { Text("Zurück") } Spacer(modifier = Modifier.height(16.dp)) Text(statusText, fontWeight = FontWeight.Bold) qrBitmap?.let { Image(bitmap = it.asImageBitmap(), contentDescription = "QR Code", modifier = Modifier.size(300.dp)) } } } @Composable fun ReceiveScreen(isDecoyMode: Boolean, sessionPassword: CharArray, contactName: String, onBackClick: () -> Unit) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current var decryptedChars by remember { mutableStateOf(null) } var scanStatus by remember { mutableStateOf("Suche QR-Code...") } var lastProcessedQrString by remember { mutableStateOf(null) } var isProcessing by remember { mutableStateOf(false) } var hasCameraPermission by remember { mutableStateOf(ContextCompat.checkSelfPermission(context, android.Manifest.permission.CAMERA) == android.content.pm.PackageManager.PERMISSION_GRANTED) } val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> hasCameraPermission = isGranted } LaunchedEffect(Unit) { if (!hasCameraPermission) permissionLauncher.launch(android.Manifest.permission.CAMERA) } DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_PAUSE || event == Lifecycle.Event.ON_STOP) { decryptedChars?.fill('0'); decryptedChars = null; scanStatus = "Sitzung aus Sicherheitsgründen gesperrt." } } lifecycleOwner.lifecycle.addObserver(observer); onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } Column(modifier = Modifier.fillMaxSize()) { if (decryptedChars == null) { Box(modifier = Modifier.weight(1f)) { if (hasCameraPermission) { AndroidView(factory = { ctx -> val previewView = PreviewView(ctx) val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx) cameraProviderFuture.addListener({ val cameraProvider = cameraProviderFuture.get() val preview = Preview.Builder().build().also { it.setSurfaceProvider(previewView.surfaceProvider) } val analysis = ImageAnalysis.Builder().setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST).build().also { it.setAnalyzer(ContextCompat.getMainExecutor(ctx)) { proxy -> val qrResultRaw = scanQrImage(proxy) if (qrResultRaw != null && !isProcessing) { if (qrResultRaw.length > 400) { scanStatus = "Abgewiesen: Code zu lang." } else if (qrResultRaw != lastProcessedQrString) { lastProcessedQrString = qrResultRaw if (!qrResultRaw.contains(":")) { scanStatus = "Format-Fehler: Fehlender Trenner." } else { val parts = qrResultRaw.split(":") if (parts.size != 2) { scanStatus = "Format-Fehler: Struktur ungültig." } else { val keyIdStr = parts[0] val hexData = parts[1] if (!hexData.matches(Regex("^[0-9A-Fa-f]+$"))) { scanStatus = "Format-Fehler: Keine Hex-Daten." } else { isProcessing = true scanStatus = "Code erkannt. Entschlüssele..." Thread { var newDecryptedChars: CharArray? = null var newScanStatus = "Fehler." try { val db = openLocalDatabase(context, isDecoyMode, sessionPassword, contactName) if (db != null) { val keyLength = getConfigValue(db, "key_length").toIntOrNull() ?: 0 if (hexData.length == keyLength * 2) { var otpKey: OtpKey? = null db.beginTransaction() try { otpKey = getKeyByIndex(db, "RECEIVE", keyIdStr) if (otpKey == null) { newScanStatus = "Schlüssel-ID $keyIdStr nicht vorhanden." } else if (verifyChecksum(otpKey.keyMaterial, otpKey.checksum) && keyLength > 32) { val combinedBytes = CryptoManager.hexToByteArray(hexData) val hmacBytes = combinedBytes.copyOfRange(0, 32) val cipherBytes = combinedBytes.copyOfRange(32, combinedBytes.size) val paddedBytes = CryptoManager.decryptWithHMAC(cipherBytes, hmacBytes, otpKey.keyMaterial) val unpaddedBytes = CryptoManager.removePadding(paddedBytes) deleteKey(db, "RECEIVE", keyIdStr) db.setTransactionSuccessful() val charBuffer = Charsets.UTF_8.decode(ByteBuffer.wrap(unpaddedBytes)) val chars = CharArray(charBuffer.remaining()); charBuffer.get(chars) newDecryptedChars = chars; newScanStatus = "Erfolgreich entschlüsselt." } else { newScanStatus = "Datenbank-Fehler (Prüfsumme ungültig)." } } catch (e: Exception) { newScanStatus = "Manipulation erkannt! Nachricht abgewiesen." } finally { otpKey?.destroy(); db.endTransaction(); db.close() } } else { newScanStatus = "Abgewiesen: Falsche Datenlänge."; db.close() } } else { newScanStatus = "Datenbank-Fehler (Zugriff verweigert)." } } catch (e: Exception) { newScanStatus = "DB-Fehler: ${e.message}" } android.os.Handler(android.os.Looper.getMainLooper()).post { if (newDecryptedChars != null) decryptedChars = newDecryptedChars scanStatus = newScanStatus isProcessing = false } }.start() } } } } } proxy.close() } } cameraProvider.unbindAll(); cameraProvider.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis) }, ContextCompat.getMainExecutor(ctx)); previewView }, modifier = Modifier.fillMaxSize()) } } Row( modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { Surface( shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), color = MaterialTheme.colorScheme.surfaceVariant, shadowElevation = 4.dp ) { Row( modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { if (isProcessing) { CircularProgressIndicator(modifier = Modifier.size(20.dp), color = MaterialTheme.colorScheme.error, strokeWidth = 2.dp) Spacer(modifier = Modifier.width(12.dp)) } Text( text = scanStatus, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.error ) } } } Button(onClick = onBackClick, enabled = !isProcessing, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), modifier = Modifier.fillMaxWidth().padding(16.dp)) { Text("Abbrechen") } Spacer(modifier = Modifier.height(16.dp)) HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f)) Spacer(modifier = Modifier.height(16.dp)) var burnKeyId by remember { mutableStateOf("") } var burnStatus by remember { mutableStateOf("") } var isBurning by remember { mutableStateOf(false) } Text("Gezielte Schlüsselvernichtung", fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.error) Spacer(modifier = Modifier.height(8.dp)) OutlinedTextField( value = burnKeyId, onValueChange = { burnKeyId = it }, label = { Text("Schlüssel-ID (exakt eingeben)") }, modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), singleLine = true ) Spacer(modifier = Modifier.height(8.dp)) Button( onClick = { if (burnKeyId.isEmpty()) return@Button isBurning = true; burnStatus = "Vernichte Schlüssel..." Thread { var resultMsg = "" try { val db = openLocalDatabase(context, isDecoyMode, sessionPassword, contactName) if (db != null) { db.beginTransaction() try { val otpKey = getKeyByIndex(db, "RECEIVE", burnKeyId) if (otpKey != null) { deleteKey(db, "RECEIVE", burnKeyId) db.setTransactionSuccessful() otpKey.destroy() resultMsg = "Erfolg: Schlüssel '$burnKeyId' unwiderruflich vernichtet." } else { resultMsg = "ID nicht gefunden. (Bereits gelöscht?)" } } catch (e: Exception) { resultMsg = "Fehler beim Löschen der ID." } finally { db.endTransaction() db.close() } } else { resultMsg = "Datenbankfehler." } } catch (e: Exception) { resultMsg = "Kritischer Fehler: ${e.message}" } android.os.Handler(android.os.Looper.getMainLooper()).post { burnStatus = resultMsg isBurning = false if (resultMsg.startsWith("Erfolg")) burnKeyId = "" } }.start() }, enabled = !isBurning && burnKeyId.isNotEmpty(), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp) ) { if (isBurning) { CircularProgressIndicator(modifier = Modifier.size(20.dp), color = MaterialTheme.colorScheme.onError, strokeWidth = 2.dp) Spacer(modifier = Modifier.width(12.dp)) } Text("Schlüssel unwiderruflich verbrennen") } if (burnStatus.isNotEmpty()) { Spacer(modifier = Modifier.height(8.dp)) Text(burnStatus, color = if (burnStatus.startsWith("Erfolg")) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, textAlign = TextAlign.Center, modifier = Modifier.padding(horizontal = 16.dp)) Spacer(modifier = Modifier.height(16.dp)) } } else { Column(modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) { Text(stringResource(R.string.recv_title_decrypted), style = MaterialTheme.typography.titleMedium); Spacer(modifier = Modifier.height(16.dp)) val currentOnSurfaceColor = with(MaterialTheme.colorScheme.onSurface) { android.graphics.Color.argb((alpha * 255).toInt(), (red * 255).toInt(), (green * 255).toInt(), (blue * 255).toInt()) } AndroidView( factory = { ctx -> SecureCharacterView(ctx) }, update = { view -> view.paint.color = currentOnSurfaceColor view.setChars(decryptedChars) }, modifier = Modifier.fillMaxWidth().height(250.dp).padding(8.dp) ) Spacer(modifier = Modifier.height(32.dp)) Button(onClick = onBackClick, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), modifier = Modifier.fillMaxWidth()) { Text("Zurück") } Spacer(modifier = Modifier.height(16.dp)) Button(onClick = { decryptedChars?.fill('0'); decryptedChars = null; scanStatus = "Suche QR-Code..."; lastProcessedQrString = null }, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.recv_btn_scan_next)) } } } } } @Composable fun AboutScreen(onBackClick: () -> Unit) { val context = LocalContext.current var isGerman by remember { mutableStateOf(true) } val scrollState = rememberScrollState() val readmeText = remember(isGerman) { try { val resId = if (isGerman) R.raw.readme_de else R.raw.readme_en context.resources.openRawResource(resId).bufferedReader().use { it.readText() } } catch (e: Exception) { if (isGerman) "Dokumentation konnte nicht geladen werden." else "Documentation could not be loaded." } } Column(modifier = Modifier.fillMaxSize().padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally) { Text(stringResource(R.string.app_name), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) Text("Version 5.1.0", style = MaterialTheme.typography.titleMedium); Spacer(modifier = Modifier.height(16.dp)) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { FilterChip(selected = isGerman, onClick = { isGerman = true }, label = { Text("Deutsch") }); Spacer(modifier = Modifier.width(8.dp)); FilterChip(selected = !isGerman, onClick = { isGerman = false }, label = { Text("English") }) } Spacer(modifier = Modifier.height(16.dp)) Surface(modifier = Modifier.weight(1f).fillMaxWidth(), color = MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.medium) { Column(modifier = Modifier.fillMaxSize().verticalScroll(scrollState).padding(12.dp)) { Text(text = readmeText, style = MaterialTheme.typography.bodySmall, fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace) } } Spacer(modifier = Modifier.height(16.dp)) Text(stringResource(R.string.about_developed_by), style = MaterialTheme.typography.bodyMedium) Text("Stefan Dützer", style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold) Text("Lizenz: GNU GPL v3", style = MaterialTheme.typography.labelMedium); Spacer(modifier = Modifier.height(16.dp)) Button(onClick = onBackClick, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.btn_back)) } } } fun generateQrCodeBitmap(index: String, hexPayload: String): Bitmap? { val totalLength = index.length + 1 + hexPayload.length val combinedChars = CharArray(totalLength) try { var destPos = 0 for (i in index.indices) combinedChars[destPos++] = index[i] combinedChars[destPos++] = ':' for (i in hexPayload.indices) combinedChars[destPos++] = hexPayload[i] val contentString = String(combinedChars) val bitMatrix = QRCodeWriter().encode(contentString, BarcodeFormat.QR_CODE, 512, 512, mapOf(EncodeHintType.MARGIN to 1)) val bitmap = Bitmap.createBitmap(bitMatrix.width, bitMatrix.height, Bitmap.Config.RGB_565) for (x in 0 until bitMatrix.width) { for (y in 0 until bitMatrix.height) { bitmap.setPixel(x, y, if (bitMatrix.get(x, y)) android.graphics.Color.BLACK else android.graphics.Color.WHITE) } } return bitmap } catch (e: Exception) { return null } finally { combinedChars.fill('0') } } fun scanQrImage(imageProxy: androidx.camera.core.ImageProxy): String? { try { val buffer = imageProxy.planes[0].buffer; val data = ByteArray(buffer.remaining()); buffer.get(data) val source = com.google.zxing.PlanarYUVLuminanceSource(data, imageProxy.width, imageProxy.height, 0, 0, imageProxy.width, imageProxy.height, false) val binaryBitmap = com.google.zxing.BinaryBitmap(com.google.zxing.common.HybridBinarizer(source)) return com.google.zxing.MultiFormatReader().decode(binaryBitmap).text } catch (e: Exception) { return null } } fun verifyChecksum(keyBytes: ByteArray, expectedChecksum: Long): Boolean { return try { CryptoManager.calculateChecksum(keyBytes) == expectedChecksum } catch (e: Exception) { false } } // Globale Funktion zur Deaktivierung der OS-Zwischenablage für Textfelder fun disableCopyPaste(editText: android.widget.EditText) { editText.isLongClickable = false editText.setTextIsSelectable(false) editText.customSelectionActionModeCallback = object : android.view.ActionMode.Callback { override fun onCreateActionMode(mode: android.view.ActionMode?, menu: android.view.Menu?): Boolean = false override fun onPrepareActionMode(mode: android.view.ActionMode?, menu: android.view.Menu?): Boolean = false override fun onActionItemClicked(mode: android.view.ActionMode?, item: android.view.MenuItem?): Boolean = false override fun onDestroyActionMode(mode: android.view.ActionMode?) {} } }