diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index eb34da2..660089d 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -20,7 +20,7 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - buildConfigField("String", "DEFAULT_RELAY_URL", "\"https://relay.ashprotocol.app\"") + buildConfigField("String", "DEFAULT_RELAY_URL", "\"https://eu.relay.ashprotocol.app\"") } signingConfigs { diff --git a/apps/android/app/src/main/java/com/monadial/ash/core/services/ConversationStorageService.kt b/apps/android/app/src/main/java/com/monadial/ash/core/services/ConversationStorageService.kt index a99a3ce..be7895e 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/core/services/ConversationStorageService.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/core/services/ConversationStorageService.kt @@ -10,11 +10,23 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import javax.inject.Inject import javax.inject.Singleton +/** + * Pad storage data matching iOS PadStorageData structure. + * Stores pad bytes together with consumption state for atomic persistence. + */ +@Serializable +data class PadStorageData( + val bytes: String, // Base64-encoded pad bytes + val consumedFront: Long, + val consumedBack: Long +) + @Singleton class ConversationStorageService @Inject constructor( @ApplicationContext private val context: Context @@ -76,20 +88,83 @@ class ConversationStorageService @Inject constructor( } } - // Store pad bytes separately for security + // === Pad Storage (matching iOS PadStorageData) === + // Pad bytes and consumption state are stored together atomically + + /** + * Save pad with initial state (after ceremony). + * Matching iOS: PadManager.storePad + */ suspend fun savePadBytes(conversationId: String, padBytes: ByteArray) = withContext(Dispatchers.IO) { - val encoded = android.util.Base64.encodeToString(padBytes, android.util.Base64.NO_WRAP) + val storageData = PadStorageData( + bytes = android.util.Base64.encodeToString(padBytes, android.util.Base64.NO_WRAP), + consumedFront = 0, + consumedBack = 0 + ) + val serialized = json.encodeToString(storageData) + encryptedPrefs.edit() + .putString("pad_$conversationId", serialized) + .apply() + } + + /** + * Save pad with current consumption state. + * Matching iOS: PadManager.savePadState + */ + suspend fun savePadState( + conversationId: String, + padBytes: ByteArray, + consumedFront: Long, + consumedBack: Long + ) = withContext(Dispatchers.IO) { + val storageData = PadStorageData( + bytes = android.util.Base64.encodeToString(padBytes, android.util.Base64.NO_WRAP), + consumedFront = consumedFront, + consumedBack = consumedBack + ) + val serialized = json.encodeToString(storageData) encryptedPrefs.edit() - .putString("pad_$conversationId", encoded) + .putString("pad_$conversationId", serialized) .apply() } + /** + * Get pad bytes only (for decryption/token derivation). + */ suspend fun getPadBytes(conversationId: String): ByteArray? = withContext(Dispatchers.IO) { - val encoded = encryptedPrefs.getString("pad_$conversationId", null) - encoded?.let { + val serialized = encryptedPrefs.getString("pad_$conversationId", null) ?: return@withContext null + try { + val storageData = json.decodeFromString(serialized) + android.util.Base64.decode(storageData.bytes, android.util.Base64.NO_WRAP) + } catch (e: Exception) { + // Fallback: try legacy format (raw Base64) try { - android.util.Base64.decode(it, android.util.Base64.NO_WRAP) - } catch (e: Exception) { + android.util.Base64.decode(serialized, android.util.Base64.NO_WRAP) + } catch (e2: Exception) { + null + } + } + } + + /** + * Get full pad storage data (bytes + consumption state). + * Matching iOS: PadManager.loadPad reading from Keychain + */ + suspend fun getPadStorageData(conversationId: String): PadStorageData? = withContext(Dispatchers.IO) { + val serialized = encryptedPrefs.getString("pad_$conversationId", null) ?: return@withContext null + try { + json.decodeFromString(serialized) + } catch (e: Exception) { + // Fallback: try legacy format (raw Base64) - use conversation for state + try { + val bytes = android.util.Base64.decode(serialized, android.util.Base64.NO_WRAP) + val conversation = getConversation(conversationId) + PadStorageData( + bytes = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP), + consumedFront = conversation?.padConsumedFront ?: 0, + consumedBack = conversation?.padConsumedBack ?: 0 + ) + } catch (e2: Exception) { null } } @@ -101,16 +176,26 @@ class ConversationStorageService @Inject constructor( .apply() } + /** + * Update consumption state in pad storage. + * Matching iOS: PadManager.savePadState + */ suspend fun updatePadConsumption( conversationId: String, - padConsumedFront: Long, - padConsumedBack: Long + consumedFront: Long, + consumedBack: Long ) = withContext(Dispatchers.IO) { - val conversation = getConversation(conversationId) ?: return@withContext - val updated = conversation.copy( - padConsumedFront = padConsumedFront, - padConsumedBack = padConsumedBack + // Load existing pad data + val existing = getPadStorageData(conversationId) ?: return@withContext + + // Save with updated consumption state + val updated = existing.copy( + consumedFront = consumedFront, + consumedBack = consumedBack ) - saveConversation(updated) + val serialized = json.encodeToString(updated) + encryptedPrefs.edit() + .putString("pad_$conversationId", serialized) + .apply() } } diff --git a/apps/android/app/src/main/java/com/monadial/ash/core/services/PadManager.kt b/apps/android/app/src/main/java/com/monadial/ash/core/services/PadManager.kt index ff34520..51f696f 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/core/services/PadManager.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/core/services/PadManager.kt @@ -44,26 +44,28 @@ class PadManager @Inject constructor( // MARK: - Load/Store /** - * Load pad for a conversation, using cache if available + * Load pad for a conversation, using cache if available. + * Matching iOS: PadManager.loadPad */ suspend fun loadPad(conversationId: String): Pad = mutex.withLock { // Check cache first padCache[conversationId]?.let { return@withLock it } - // Load from storage - val padBytes = conversationStorage.getPadBytes(conversationId) + // Load from storage (matching iOS: PadStorageData from Keychain) + val storageData = conversationStorage.getPadStorageData(conversationId) ?: throw IllegalStateException("Pad not found for conversation $conversationId") - val conversation = conversationStorage.getConversation(conversationId) - ?: throw IllegalStateException("Conversation not found: $conversationId") + val padBytes = android.util.Base64.decode(storageData.bytes, android.util.Base64.NO_WRAP) - // Create Rust Pad with state + // Create Rust Pad with state (matching iOS: Pad.fromBytesWithState) val pad = Pad.fromBytesWithState( padBytes.map { it.toUByte() }, - conversation.padConsumedFront.toULong(), - conversation.padConsumedBack.toULong() + storageData.consumedFront.toULong(), + storageData.consumedBack.toULong() ) + Log.d(TAG, "Loaded pad for ${conversationId.take(8)}: front=${storageData.consumedFront}, back=${storageData.consumedBack}") + padCache[conversationId] = pad pad } @@ -80,13 +82,22 @@ class PadManager @Inject constructor( } /** - * Save current pad state to storage + * Save current pad state to storage. + * Matching iOS: PadManager.savePadState - saves bytes + consumption together * Note: Called within mutex.withLock, no additional locking needed */ private suspend fun savePadState(pad: Pad, conversationId: String) { val consumedFront = pad.consumedFront().toLong() val consumedBack = pad.consumedBack().toLong() - conversationStorage.updatePadConsumption(conversationId, consumedFront, consumedBack) + val padBytes = pad.asBytes().map { it.toByte() }.toByteArray() + + // Save bytes + consumption state together (matching iOS PadStorageData) + conversationStorage.savePadState( + conversationId = conversationId, + padBytes = padBytes, + consumedFront = consumedFront, + consumedBack = consumedBack + ) } // MARK: - Send Operations @@ -122,16 +133,15 @@ class PadManager @Inject constructor( * IMPORTANT: This updates consumption state - call only once per message! */ suspend fun consumeForSending(length: Int, role: Role, conversationId: String): ByteArray = mutex.withLock { - // Get cached pad or load it + // Get cached pad or load it (matching iOS pattern) val pad = padCache[conversationId] ?: run { - val padBytes = conversationStorage.getPadBytes(conversationId) + val storageData = conversationStorage.getPadStorageData(conversationId) ?: throw IllegalStateException("Pad not found for conversation $conversationId") - val conversation = conversationStorage.getConversation(conversationId) - ?: throw IllegalStateException("Conversation not found: $conversationId") + val padBytes = android.util.Base64.decode(storageData.bytes, android.util.Base64.NO_WRAP) Pad.fromBytesWithState( padBytes.map { it.toUByte() }, - conversation.padConsumedFront.toULong(), - conversation.padConsumedBack.toULong() + storageData.consumedFront.toULong(), + storageData.consumedBack.toULong() ).also { padCache[conversationId] = it } } @@ -154,16 +164,15 @@ class PadManager @Inject constructor( * Update peer's consumption based on received message */ suspend fun updatePeerConsumption(peerRole: Role, consumed: Long, conversationId: String) = mutex.withLock { - // Get cached pad or load it + // Get cached pad or load it (matching iOS pattern) val pad = padCache[conversationId] ?: run { - val padBytes = conversationStorage.getPadBytes(conversationId) + val storageData = conversationStorage.getPadStorageData(conversationId) ?: throw IllegalStateException("Pad not found for conversation $conversationId") - val conversation = conversationStorage.getConversation(conversationId) - ?: throw IllegalStateException("Conversation not found: $conversationId") + val padBytes = android.util.Base64.decode(storageData.bytes, android.util.Base64.NO_WRAP) Pad.fromBytesWithState( padBytes.map { it.toUByte() }, - conversation.padConsumedFront.toULong(), - conversation.padConsumedBack.toULong() + storageData.consumedFront.toULong(), + storageData.consumedBack.toULong() ).also { padCache[conversationId] = it } } @@ -215,26 +224,23 @@ class PadManager @Inject constructor( * When a message expires, the key material is zeroed to prevent future decryption. */ suspend fun zeroPadBytes(offset: Long, length: Int, conversationId: String) = mutex.withLock { - // Get cached pad or load it + // Get cached pad or load it (matching iOS pattern) val pad = padCache[conversationId] ?: run { - val padBytes = conversationStorage.getPadBytes(conversationId) + val storageData = conversationStorage.getPadStorageData(conversationId) ?: throw IllegalStateException("Pad not found for conversation $conversationId") - val conversation = conversationStorage.getConversation(conversationId) - ?: throw IllegalStateException("Conversation not found: $conversationId") + val padBytes = android.util.Base64.decode(storageData.bytes, android.util.Base64.NO_WRAP) Pad.fromBytesWithState( padBytes.map { it.toUByte() }, - conversation.padConsumedFront.toULong(), - conversation.padConsumedBack.toULong() + storageData.consumedFront.toULong(), + storageData.consumedBack.toULong() ).also { padCache[conversationId] = it } } val success = pad.zeroBytesAt(offset.toULong(), length.toULong()) if (success) { - // Persist updated state (with zeroed bytes) - // Note: We need to save the full pad bytes, not just consumption state - val bytes = pad.asBytes().map { it.toByte() }.toByteArray() - conversationStorage.savePadBytes(conversationId, bytes) + // Persist updated state (with zeroed bytes) - matching iOS savePadState + savePadState(pad, conversationId) Log.d(TAG, "Zeroed $length pad bytes at offset $offset for forward secrecy") } else { Log.w(TAG, "Failed to zero pad bytes: offset $offset, length $length out of bounds") diff --git a/apps/android/app/src/main/java/com/monadial/ash/core/services/QRCodeService.kt b/apps/android/app/src/main/java/com/monadial/ash/core/services/QRCodeService.kt index de48947..d6c0782 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/core/services/QRCodeService.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/core/services/QRCodeService.kt @@ -23,26 +23,33 @@ class QRCodeService @Inject constructor() { * Uses L error correction level (7%) for maximum capacity. * Fountain codes provide their own redundancy. * + * Block size 1500 bytes + 16 byte header = 1516 bytes + * Base64 encoded: ~2021 characters + * This fits in QR Version 23-24 with L error correction + * * @param data Raw bytes to encode - * @param size Target size in pixels + * @param size Target size in pixels (default 600 for larger QR codes) * @return QR code bitmap or null on failure */ - fun generate(data: ByteArray, size: Int = 400): Bitmap? { + fun generate(data: ByteArray, size: Int = 600): Bitmap? { return try { // Base64 encode for compatibility with QR string parsing val base64 = Base64.encodeToString(data, Base64.NO_WRAP) - // Check if data is within QR code limits (roughly 2953 chars for L level) + // Check if data is within QR code limits + // Version 40 L can hold ~2953 chars, Version 24 L can hold ~2181 chars if (base64.length > 2900) { Log.e(TAG, "Data too large for QR code: ${base64.length} chars") return null } + Log.d(TAG, "Generating QR code: ${data.size} bytes -> ${base64.length} chars base64, target size: $size px") + val writer = QRCodeWriter() val hints = mapOf( EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.L, EncodeHintType.MARGIN to 1, - EncodeHintType.CHARACTER_SET to "UTF-8" + EncodeHintType.CHARACTER_SET to "ISO-8859-1" // Binary-safe encoding ) val bitMatrix = writer.encode(base64, BarcodeFormat.QR_CODE, size, size, hints) @@ -59,8 +66,11 @@ class QRCodeService @Inject constructor() { } } - val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565) + // Use ARGB_8888 for better quality + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) bitmap.setPixels(pixels, 0, width, 0, 0, width, height) + + Log.d(TAG, "QR code generated: ${width}x${height} pixels") bitmap } catch (e: Exception) { Log.e(TAG, "Failed to generate QR code: ${e.message}", e) diff --git a/apps/android/app/src/main/java/com/monadial/ash/core/services/RelayService.kt b/apps/android/app/src/main/java/com/monadial/ash/core/services/RelayService.kt index b500fd6..23b9561 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/core/services/RelayService.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/core/services/RelayService.kt @@ -105,6 +105,7 @@ data class PollMessagesResponse( data class ConnectionTestResult( val success: Boolean, val version: String? = null, + val latencyMs: Long? = null, val error: String? = null ) @@ -167,10 +168,12 @@ class RelayService @Inject constructor( suspend fun testConnection(relayUrl: String): ConnectionTestResult { return try { + val startTime = System.currentTimeMillis() val response: HttpResponse = httpClient.get("$relayUrl/health") + val latencyMs = System.currentTimeMillis() - startTime if (response.status.isSuccess()) { val health: HealthResponse = response.body() - ConnectionTestResult(success = true, version = health.version) + ConnectionTestResult(success = true, version = health.version, latencyMs = latencyMs) } else { ConnectionTestResult(success = false, error = "Status: ${response.status}") } diff --git a/apps/android/app/src/main/java/com/monadial/ash/domain/entities/CeremonyPhase.kt b/apps/android/app/src/main/java/com/monadial/ash/domain/entities/CeremonyPhase.kt index 0755fed..4ba8add 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/domain/entities/CeremonyPhase.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/domain/entities/CeremonyPhase.kt @@ -30,35 +30,53 @@ enum class CeremonyError { INVALID_FRAME } -enum class PadSize(val bytes: Long, val displayName: String, val subtitle: String) { - SMALL(64 * 1024L, "64 KB", "100+ messages"), - MEDIUM(256 * 1024L, "256 KB", "500+ messages"), - LARGE(1024 * 1024L, "1 MB", "2000+ messages"); +enum class PadSize( + val bytes: Long, + val displayName: String, + val messageEstimate: Int, + val frameCount: Int +) { + TINY(32 * 1024L, "Tiny", 50, 38), + SMALL(64 * 1024L, "Small", 100, 75), + MEDIUM(256 * 1024L, "Medium", 500, 296), + LARGE(512 * 1024L, "Large", 1000, 591), + HUGE(1024 * 1024L, "Huge", 2000, 1180); - val messageEstimate: Int - get() = when (this) { - SMALL -> 100 - MEDIUM -> 500 - LARGE -> 2000 - } + val subtitle: String + get() = "~$messageEstimate messages" val transferTime: String get() = when (this) { + TINY -> "~10 seconds" SMALL -> "~15 seconds" MEDIUM -> "~45 seconds" - LARGE -> "~2 minutes" + LARGE -> "~1.5 minutes" + HUGE -> "~3 minutes" } } data class ConsentState( - val secureEnvironment: Boolean = false, - val noSurveillance: Boolean = false, - val ethicsReviewed: Boolean = false, - val keyLossUnderstood: Boolean = false, - val relayWarningUnderstood: Boolean = false, - val dataLossAccepted: Boolean = false, - val burnUnderstood: Boolean = false + // Environment + val noOneWatching: Boolean = false, + val notUnderSurveillance: Boolean = false, + // Responsibilities + val ethicsUnderstood: Boolean = false, + val keysNotRecoverable: Boolean = false, + // Limitations + val relayMayBeUnavailable: Boolean = false, + val relayDataNotPersisted: Boolean = false, + val burnDestroysAll: Boolean = false ) { - val allConfirmed: Boolean get() = secureEnvironment && noSurveillance && ethicsReviewed && - keyLossUnderstood && relayWarningUnderstood && dataLossAccepted && burnUnderstood + val environmentConfirmed: Boolean get() = noOneWatching && notUnderSurveillance + val responsibilitiesConfirmed: Boolean get() = ethicsUnderstood && keysNotRecoverable + val limitationsConfirmed: Boolean get() = relayMayBeUnavailable && relayDataNotPersisted && burnDestroysAll + val allConfirmed: Boolean get() = environmentConfirmed && responsibilitiesConfirmed && limitationsConfirmed + + val confirmedCount: Int get() = listOf( + noOneWatching, notUnderSurveillance, + ethicsUnderstood, keysNotRecoverable, + relayMayBeUnavailable, relayDataNotPersisted, burnDestroysAll + ).count { it } + + val totalCount: Int get() = 7 } diff --git a/apps/android/app/src/main/java/com/monadial/ash/ui/components/EntropyCollectionView.kt b/apps/android/app/src/main/java/com/monadial/ash/ui/components/EntropyCollectionView.kt index 9479a64..13b796e 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/ui/components/EntropyCollectionView.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/ui/components/EntropyCollectionView.kt @@ -3,22 +3,15 @@ package com.monadial.ash.ui.components import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column +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.foundation.shape.RoundedCornerShape -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -31,6 +24,10 @@ import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp +/** + * Entropy collection canvas - just the drawing surface + * Text/labels are handled by the parent EntropyCollectionContent + */ @Composable fun EntropyCollectionView( progress: Float, @@ -40,100 +37,52 @@ fun EntropyCollectionView( ) { val touchPoints = remember { mutableStateListOf() } - Column( - modifier = modifier.padding(24.dp), - verticalArrangement = Arrangement.spacedBy(24.dp), - horizontalAlignment = Alignment.CenterHorizontally + Box( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .pointerInput(Unit) { + detectDragGestures { change, _ -> + val point = change.position + touchPoints.add(point) + if (touchPoints.size > 1000) { + repeat(200) { touchPoints.removeFirstOrNull() } + } + // Normalize coordinates to 0-1 range + val normalizedX = point.x / size.width + val normalizedY = point.y / size.height + onPointCollected(normalizedX, normalizedY) + } + }, + contentAlignment = Alignment.Center ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = "Draw Random Patterns", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface - ) + if (touchPoints.isEmpty()) { Text( - text = "Move your finger to generate entropy", - style = MaterialTheme.typography.bodyMedium, + text = "Touch and drag here", + style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant ) } - Box( - modifier = Modifier - .fillMaxWidth() - .height(280.dp) - .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant) - .pointerInput(Unit) { - detectDragGestures { change, _ -> - val point = change.position - touchPoints.add(point) - if (touchPoints.size > 500) { - repeat(100) { touchPoints.removeFirstOrNull() } - } - // Normalize coordinates to 0-1 range - val normalizedX = point.x / size.width - val normalizedY = point.y / size.height - onPointCollected(normalizedX, normalizedY) + Canvas(modifier = Modifier.fillMaxSize()) { + if (touchPoints.size > 1) { + val path = Path().apply { + moveTo(touchPoints[0].x, touchPoints[0].y) + touchPoints.drop(1).forEach { point -> + lineTo(point.x, point.y) } - }, - contentAlignment = Alignment.Center - ) { - if (touchPoints.isEmpty()) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = "👆", - style = MaterialTheme.typography.displayMedium - ) - Text( - text = "Touch and drag", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) } - } - - Canvas(modifier = Modifier.matchParentSize()) { - if (touchPoints.size > 1) { - val path = Path().apply { - moveTo(touchPoints[0].x, touchPoints[0].y) - touchPoints.drop(1).forEach { point -> - lineTo(point.x, point.y) - } - } - drawPath( - path = path, - color = accentColor.copy(alpha = 0.6f), - style = Stroke( - width = 3.dp.toPx(), - cap = StrokeCap.Round, - join = StrokeJoin.Round - ) + drawPath( + path = path, + color = accentColor.copy(alpha = 0.7f), + style = Stroke( + width = 4.dp.toPx(), + cap = StrokeCap.Round, + join = StrokeJoin.Round ) - } + ) } } - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - LinearProgressIndicator( - progress = { progress }, - modifier = Modifier.fillMaxWidth(), - color = accentColor - ) - Text( - text = "${(progress * 100).toInt()}%", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } } } diff --git a/apps/android/app/src/main/java/com/monadial/ash/ui/components/QRCodeView.kt b/apps/android/app/src/main/java/com/monadial/ash/ui/components/QRCodeView.kt index 7ad5357..b4982b8 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/ui/components/QRCodeView.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/ui/components/QRCodeView.kt @@ -26,24 +26,25 @@ import androidx.compose.ui.unit.dp fun QRCodeView( bitmap: Bitmap?, modifier: Modifier = Modifier, - size: Dp = 280.dp + size: Dp = 320.dp ) { Box( modifier = modifier .size(size) - .shadow(16.dp, RoundedCornerShape(16.dp)) - .clip(RoundedCornerShape(16.dp)) + .shadow(8.dp, RoundedCornerShape(8.dp)) + .clip(RoundedCornerShape(8.dp)) .background(Color.White), contentAlignment = Alignment.Center ) { when { bitmap != null -> { + // Minimal padding - QR codes have built-in quiet zone Image( bitmap = bitmap.asImageBitmap(), contentDescription = "QR Code", modifier = Modifier - .padding(10.dp) - .size(size - 20.dp), + .padding(4.dp) + .size(size - 8.dp), contentScale = ContentScale.Fit, filterQuality = FilterQuality.None ) diff --git a/apps/android/app/src/main/java/com/monadial/ash/ui/components/QRScannerView.kt b/apps/android/app/src/main/java/com/monadial/ash/ui/components/QRScannerView.kt index 8f1b9b7..e50b011 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/ui/components/QRScannerView.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/ui/components/QRScannerView.kt @@ -1,17 +1,21 @@ package com.monadial.ash.ui.components import android.Manifest +import android.os.Handler +import android.os.Looper import android.util.Log +import android.util.Size import androidx.camera.core.CameraSelector import androidx.camera.core.ImageAnalysis import androidx.camera.core.Preview +import androidx.camera.core.resolutionselector.ResolutionSelector +import androidx.camera.core.resolutionselector.ResolutionStrategy import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.view.PreviewView import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -30,15 +34,70 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState +import com.google.mlkit.vision.barcode.BarcodeScannerOptions import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.common.InputImage +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ExecutorService import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference +private const val TAG = "QRScanner" +private const val DEDUPLICATION_INTERVAL_MS = 300L + +/** + * Thread-safe callback holder that can be updated without recreating camera + */ +private class CallbackHolder( + initialCallback: (String) -> Unit +) { + private val callbackRef = AtomicReference(initialCallback) + private val lastScanTime = AtomicLong(0L) + private val recentScans = ConcurrentHashMap() + private val mainHandler = Handler(Looper.getMainLooper()) + + fun updateCallback(callback: (String) -> Unit) { + callbackRef.set(callback) + } + + fun onQRScanned(value: String) { + val now = System.currentTimeMillis() + + // Thread-safe deduplication + val lastTime = recentScans[value] + if (lastTime != null && (now - lastTime) < DEDUPLICATION_INTERVAL_MS) { + return + } + + // Update scan time + recentScans[value] = now + lastScanTime.set(now) + + // Clean old entries periodically + if (recentScans.size > 100) { + val cutoff = now - 5000 // Keep last 5 seconds + recentScans.entries.removeIf { it.value < cutoff } + } + + Log.d(TAG, "QR scanned: ${value.take(50)}...") + + // Always invoke callback on main thread + mainHandler.post { + callbackRef.get()?.invoke(value) + } + } +} + +/** + * QR Scanner View - stable camera implementation + */ @OptIn(ExperimentalPermissionsApi::class) @Composable fun QRScannerView( @@ -49,6 +108,14 @@ fun QRScannerView( val lifecycleOwner = LocalLifecycleOwner.current val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA) + // Create stable callback holder that survives recomposition + val callbackHolder = remember { CallbackHolder(onQRCodeScanned) } + + // Update callback reference when it changes (without recreating holder) + LaunchedEffect(onQRCodeScanned) { + callbackHolder.updateCallback(onQRCodeScanned) + } + LaunchedEffect(Unit) { if (!cameraPermissionState.status.isGranted) { cameraPermissionState.launchPermissionRequest() @@ -57,10 +124,13 @@ fun QRScannerView( if (cameraPermissionState.status.isGranted) { var cameraProvider by remember { mutableStateOf(null) } + val analysisExecutor = remember { Executors.newSingleThreadExecutor() } DisposableEffect(Unit) { onDispose { + Log.d(TAG, "Disposing QRScannerView - unbinding camera") cameraProvider?.unbindAll() + analysisExecutor.shutdown() } } @@ -72,80 +142,25 @@ fun QRScannerView( ) { AndroidView( factory = { ctx -> - val previewView = PreviewView(ctx) - val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx) - - cameraProviderFuture.addListener({ - val provider = cameraProviderFuture.get() - cameraProvider = provider - - val preview = Preview.Builder().build().also { - it.surfaceProvider = previewView.surfaceProvider - } - - val barcodeScanner = BarcodeScanning.getClient() - val analysisExecutor = Executors.newSingleThreadExecutor() - - val imageAnalysis = ImageAnalysis.Builder() - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .build() - .also { analysis -> - analysis.setAnalyzer(analysisExecutor) { imageProxy -> - @androidx.camera.core.ExperimentalGetImage - val mediaImage = imageProxy.image - if (mediaImage != null) { - val image = InputImage.fromMediaImage( - mediaImage, - imageProxy.imageInfo.rotationDegrees - ) - - barcodeScanner.process(image) - .addOnSuccessListener { barcodes -> - for (barcode in barcodes) { - if (barcode.format == Barcode.FORMAT_QR_CODE) { - barcode.rawValue?.let { value -> - onQRCodeScanned(value) - } - } - } - } - .addOnCompleteListener { - imageProxy.close() - } - } else { - imageProxy.close() - } - } - } + Log.d(TAG, "Creating PreviewView") + val previewView = PreviewView(ctx).apply { + implementationMode = PreviewView.ImplementationMode.PERFORMANCE + } - try { - provider.unbindAll() - provider.bindToLifecycle( - lifecycleOwner, - CameraSelector.DEFAULT_BACK_CAMERA, - preview, - imageAnalysis - ) - } catch (e: Exception) { - Log.e("QRScanner", "Camera binding failed", e) - } - }, ContextCompat.getMainExecutor(ctx)) + setupCamera( + context = ctx, + lifecycleOwner = lifecycleOwner, + previewView = previewView, + analysisExecutor = analysisExecutor, + onCameraProviderReady = { provider -> cameraProvider = provider }, + callbackHolder = callbackHolder + ) previewView }, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), + update = { /* no-op */ } ) - - // Scanning frame overlay - Box( - modifier = Modifier - .size(280.dp) - .align(Alignment.Center) - .clip(RoundedCornerShape(16.dp)) - .background(Color.Transparent) - ) { - // Corner indicators could be added here - } } } else { Box( @@ -163,14 +178,95 @@ fun QRScannerView( } } +private fun setupCamera( + context: android.content.Context, + lifecycleOwner: LifecycleOwner, + previewView: PreviewView, + analysisExecutor: ExecutorService, + onCameraProviderReady: (ProcessCameraProvider) -> Unit, + callbackHolder: CallbackHolder +) { + val cameraProviderFuture = ProcessCameraProvider.getInstance(context) + + cameraProviderFuture.addListener({ + val provider = cameraProviderFuture.get() + onCameraProviderReady(provider) + + val preview = Preview.Builder().build().also { + it.surfaceProvider = previewView.surfaceProvider + } + + val options = BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build() + val barcodeScanner = BarcodeScanning.getClient(options) + + val resolutionSelector = ResolutionSelector.Builder() + .setResolutionStrategy( + ResolutionStrategy( + Size(1920, 1080), + ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER + ) + ) + .build() + + val imageAnalysis = ImageAnalysis.Builder() + .setResolutionSelector(resolutionSelector) + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { analysis -> + analysis.setAnalyzer(analysisExecutor) { imageProxy -> + @androidx.camera.core.ExperimentalGetImage + val mediaImage = imageProxy.image + if (mediaImage != null) { + val image = InputImage.fromMediaImage( + mediaImage, + imageProxy.imageInfo.rotationDegrees + ) + + barcodeScanner.process(image) + .addOnSuccessListener { barcodes -> + for (barcode in barcodes) { + if (barcode.format == Barcode.FORMAT_QR_CODE) { + barcode.rawValue?.let { value -> + callbackHolder.onQRScanned(value) + } + } + } + } + .addOnFailureListener { e -> + Log.e(TAG, "Barcode scanning failed: ${e.message}") + } + .addOnCompleteListener { + imageProxy.close() + } + } else { + imageProxy.close() + } + } + } + + try { + provider.unbindAll() + provider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + imageAnalysis + ) + Log.d(TAG, "Camera bound successfully") + } catch (e: Exception) { + Log.e(TAG, "Camera binding failed", e) + } + }, ContextCompat.getMainExecutor(context)) +} + @Composable fun ScanProgressOverlay( receivedBlocks: Int, totalBlocks: Int, modifier: Modifier = Modifier ) { - val progress = if (totalBlocks > 0) receivedBlocks.toFloat() / totalBlocks else 0f - Box( modifier = modifier .clip(RoundedCornerShape(8.dp)) diff --git a/apps/android/app/src/main/java/com/monadial/ash/ui/screens/CeremonyScreen.kt b/apps/android/app/src/main/java/com/monadial/ash/ui/screens/CeremonyScreen.kt index 00b362b..aa410c7 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/ui/screens/CeremonyScreen.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/ui/screens/CeremonyScreen.kt @@ -1,6 +1,9 @@ package com.monadial.ash.ui.screens +import android.app.Activity +import android.view.WindowManager import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith @@ -10,6 +13,9 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -27,15 +33,28 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.CameraAlt import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.FastForward +import androidx.compose.material.icons.filled.FastRewind +import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.LocalFireDepartment -import androidx.compose.material.icons.automirrored.filled.MenuBook +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.QrCode2 -import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Shield -import androidx.compose.material.icons.filled.Storage -import androidx.compose.material.icons.filled.VideocamOff -import androidx.compose.material.icons.filled.VpnKey +import androidx.compose.material.icons.filled.SkipNext +import androidx.compose.material.icons.filled.SkipPrevious +import androidx.compose.material.icons.filled.Speed +import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.outlined.ChatBubbleOutline +import androidx.compose.material.icons.outlined.Cloud +import androidx.compose.material.icons.outlined.Description +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Palette +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material.icons.outlined.Sync +import androidx.compose.material.icons.outlined.TouchApp import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card @@ -43,19 +62,39 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.Checkbox import androidx.compose.material3.CheckboxDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonDefaults import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -65,8 +104,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -79,18 +119,14 @@ import com.monadial.ash.domain.entities.DisappearingMessages import com.monadial.ash.domain.entities.MessageRetention import com.monadial.ash.domain.entities.PadSize import com.monadial.ash.ui.components.EntropyCollectionView -import com.monadial.ash.ui.components.QRCodeFrameCounter import com.monadial.ash.ui.components.QRCodeView import com.monadial.ash.ui.components.QRScannerView import com.monadial.ash.ui.components.ScanProgressOverlay -import com.monadial.ash.ui.theme.AshColors -import com.monadial.ash.ui.theme.AshCornerRadius -import com.monadial.ash.ui.theme.AshSpacing import com.monadial.ash.ui.viewmodels.InitiatorCeremonyViewModel import com.monadial.ash.ui.viewmodels.ReceiverCeremonyViewModel /** - * Ceremony Screen - 1:1 port from iOS + * Ceremony Screen - Material Design 3 * Handles the complete key exchange ceremony flow */ @OptIn(ExperimentalMaterial3Api::class) @@ -128,7 +164,9 @@ enum class CeremonyRole { RECEIVER } -// MARK: - Role Selection Screen (matches iOS RoleSelectionView) +// ============================================================================ +// Role Selection Screen +// ============================================================================ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -152,111 +190,155 @@ private fun RoleSelectionScreen( modifier = Modifier .fillMaxSize() .padding(padding) - .padding(AshSpacing.lg), + .padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - Spacer(modifier = Modifier.height(AshSpacing.xl)) + Spacer(modifier = Modifier.weight(0.2f)) + + // Hero icon + Surface( + modifier = Modifier.size(96.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Outlined.Sync, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(48.dp) + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) Text( text = "Choose Your Role", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold + style = MaterialTheme.typography.headlineMedium ) - Spacer(modifier = Modifier.height(AshSpacing.sm)) + Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Both devices must be physically present to establish a secure channel.", - style = MaterialTheme.typography.bodyMedium, + text = "One device creates the conversation,\nthe other joins by scanning", + style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center ) - Spacer(modifier = Modifier.height(AshSpacing.xxl)) - - // Create (Initiator) option - RoleOptionCard( - title = "Create", - subtitle = "Generate a new one-time pad and display QR codes for your partner to scan.", - icon = Icons.Default.QrCode2, - iconTint = AshColors.ashAccent, - onClick = { onRoleSelected(CeremonyRole.INITIATOR) } - ) - - Spacer(modifier = Modifier.height(AshSpacing.md)) - - // Join (Receiver) option - RoleOptionCard( - title = "Join", - subtitle = "Scan QR codes from your partner's device to receive the encryption pad.", - icon = Icons.Default.CameraAlt, - iconTint = AshColors.green, - onClick = { onRoleSelected(CeremonyRole.RECEIVER) } - ) - - Spacer(modifier = Modifier.weight(1f)) - } - } -} + Spacer(modifier = Modifier.weight(0.3f)) -@Composable -private fun RoleOptionCard( - title: String, - subtitle: String, - icon: ImageVector, - iconTint: Color, - onClick: () -> Unit -) { - Card( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ), - shape = RoundedCornerShape(AshCornerRadius.lg) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(AshSpacing.lg), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(56.dp) - .clip(CircleShape) - .background(iconTint.copy(alpha = 0.15f)), - contentAlignment = Alignment.Center + // Create option + ElevatedCard( + onClick = { onRoleSelected(CeremonyRole.INITIATOR) }, + modifier = Modifier.fillMaxWidth() ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = iconTint, - modifier = Modifier.size(28.dp) - ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + modifier = Modifier.size(48.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.primaryContainer + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.QrCode2, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Create", + style = MaterialTheme.typography.titleMedium + ) + Text( + text = "Generate pad and display QR codes", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.primary + ) { + Text( + text = "Initiator", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary + ) + } + } } - Spacer(modifier = Modifier.width(AshSpacing.md)) + Spacer(modifier = Modifier.height(12.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold - ) - Spacer(modifier = Modifier.height(AshSpacing.xxs)) - Text( - text = subtitle, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + // Join option + ElevatedCard( + onClick = { onRoleSelected(CeremonyRole.RECEIVER) }, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + modifier = Modifier.size(48.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.tertiaryContainer + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.CameraAlt, + contentDescription = null, + tint = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + } + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Join", + style = MaterialTheme.typography.titleMedium + ) + Text( + text = "Scan QR codes from other device", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.tertiary + ) { + Text( + text = "Receiver", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onTertiary + ) + } + } } + + Spacer(modifier = Modifier.weight(0.3f)) } } } -// MARK: - Initiator Ceremony Screen +// ============================================================================ +// Initiator Ceremony Screen +// ============================================================================ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -279,13 +361,17 @@ private fun InitiatorCeremonyScreen( val totalFrames by viewModel.totalFrames.collectAsState() val connectionTestResult by viewModel.connectionTestResult.collectAsState() val isTestingConnection by viewModel.isTestingConnection.collectAsState() + val passphraseEnabled by viewModel.passphraseEnabled.collectAsState() + val passphrase by viewModel.passphrase.collectAsState() + val isPaused by viewModel.isPaused.collectAsState() + val fps by viewModel.fps.collectAsState() val accentColor = Color(selectedColor.toColorLong()) Scaffold( topBar = { TopAppBar( - title = { Text(getInitiatorTitle(phase)) }, + title = { Text("New Conversation") }, navigationIcon = { IconButton(onClick = { viewModel.cancel() @@ -304,7 +390,6 @@ private fun InitiatorCeremonyScreen( .fillMaxSize() .padding(padding), label = "ceremony_phase", - // Use contentKey to prevent re-animation when only progress changes contentKey = { phaseToContentKey(it) } ) { currentPhase -> when (currentPhase) { @@ -312,6 +397,10 @@ private fun InitiatorCeremonyScreen( PadSizeSelectionContent( selectedSize = selectedPadSize, onSizeSelected = viewModel::selectPadSize, + passphraseEnabled = passphraseEnabled, + onPassphraseToggle = viewModel::setPassphraseEnabled, + passphrase = passphrase, + onPassphraseChange = viewModel::setPassphrase, onProceed = viewModel::proceedToOptions, accentColor = accentColor ) @@ -347,7 +436,7 @@ private fun InitiatorCeremonyScreen( } is CeremonyPhase.CollectingEntropy -> { - EntropyCollectionView( + EntropyCollectionContent( progress = entropyProgress, onPointCollected = viewModel::addEntropy, accentColor = accentColor @@ -374,6 +463,15 @@ private fun InitiatorCeremonyScreen( bitmap = currentQRBitmap, currentFrame = currentFrameIndex, totalFrames = totalFrames, + isPaused = isPaused, + fps = fps, + onTogglePause = viewModel::togglePause, + onPreviousFrame = viewModel::previousFrame, + onNextFrame = viewModel::nextFrame, + onFirstFrame = viewModel::firstFrame, + onLastFrame = viewModel::lastFrame, + onReset = viewModel::resetFrames, + onFpsChange = viewModel::setFps, onDone = viewModel::finishSending, accentColor = accentColor ) @@ -382,6 +480,8 @@ private fun InitiatorCeremonyScreen( is CeremonyPhase.Verifying -> { VerificationContent( mnemonic = currentPhase.mnemonic, + conversationName = conversationName, + onNameChange = viewModel::setConversationName, onConfirm = { val conversation = viewModel.confirmVerification() conversation?.let { onComplete(it.id) } @@ -412,7 +512,9 @@ private fun InitiatorCeremonyScreen( } } -// MARK: - Receiver Ceremony Screen +// ============================================================================ +// Receiver Ceremony Screen +// ============================================================================ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -423,13 +525,16 @@ private fun ReceiverCeremonyScreen( ) { val phase by viewModel.phase.collectAsState() val conversationName by viewModel.conversationName.collectAsState() + val selectedColor by viewModel.selectedColor.collectAsState() val receivedBlocks by viewModel.receivedBlocks.collectAsState() val totalBlocks by viewModel.totalBlocks.collectAsState() + val passphraseEnabled by viewModel.passphraseEnabled.collectAsState() + val passphrase by viewModel.passphrase.collectAsState() Scaffold( topBar = { TopAppBar( - title = { Text(getReceiverTitle(phase)) }, + title = { Text("New Conversation") }, navigationIcon = { IconButton(onClick = { viewModel.cancel() @@ -448,14 +553,17 @@ private fun ReceiverCeremonyScreen( .fillMaxSize() .padding(padding), label = "receiver_ceremony_phase", - // Use contentKey to prevent re-animation when only progress changes contentKey = { phaseToContentKey(it) } ) { currentPhase -> when (currentPhase) { is CeremonyPhase.ConfiguringReceiver -> { ReceiverSetupContent( - conversationName = conversationName, - onNameChange = viewModel::setConversationName, + passphraseEnabled = passphraseEnabled, + onPassphraseToggle = viewModel::setPassphraseEnabled, + passphrase = passphrase, + onPassphraseChange = viewModel::setPassphrase, + selectedColor = selectedColor, + onColorChange = viewModel::setSelectedColor, onStartScanning = viewModel::startScanning ) } @@ -471,12 +579,14 @@ private fun ReceiverCeremonyScreen( is CeremonyPhase.Verifying -> { VerificationContent( mnemonic = currentPhase.mnemonic, + conversationName = conversationName, + onNameChange = viewModel::setConversationName, onConfirm = { val conversation = viewModel.confirmVerification() conversation?.let { onComplete(it.id) } }, onReject = viewModel::rejectVerification, - accentColor = MaterialTheme.colorScheme.primary + accentColor = Color(selectedColor.toColorLong()) ) } @@ -501,35 +611,63 @@ private fun ReceiverCeremonyScreen( } } -// MARK: - Pad Size Selection (matches iOS PadSizeView) +// ============================================================================ +// Pad Size Selection +// ============================================================================ @Composable private fun PadSizeSelectionContent( selectedSize: PadSize, onSizeSelected: (PadSize) -> Unit, + passphraseEnabled: Boolean, + onPassphraseToggle: (Boolean) -> Unit, + passphrase: String, + onPassphraseChange: (String) -> Unit, onProceed: () -> Unit, accentColor: Color ) { + val accentContainer = accentColor.copy(alpha = 0.15f) + Column( modifier = Modifier .fillMaxSize() - .padding(AshSpacing.lg), - verticalArrangement = Arrangement.spacedBy(AshSpacing.md) + .verticalScroll(rememberScrollState()) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { + // Header + Surface( + modifier = Modifier.size(72.dp), + shape = CircleShape, + color = accentContainer + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.Tune, + contentDescription = null, + tint = accentColor, + modifier = Modifier.size(36.dp) + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + Text( - text = "Choose Pad Size", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold + text = "Pad Size", + style = MaterialTheme.typography.headlineSmall ) Text( - text = "Larger pads support more messages but take longer to transfer.", + text = "Larger pads allow more messages but take longer to transfer", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center ) - Spacer(modifier = Modifier.height(AshSpacing.md)) + Spacer(modifier = Modifier.height(24.dp)) + // Pad size options PadSize.entries.forEach { size -> PadSizeCard( size = size, @@ -537,19 +675,72 @@ private fun PadSizeSelectionContent( onClick = { onSizeSelected(size) }, accentColor = accentColor ) + Spacer(modifier = Modifier.height(8.dp)) } - Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.height(16.dp)) + + // Passphrase section + OutlinedCard( + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Outlined.Lock, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Passphrase Protection", + style = MaterialTheme.typography.titleSmall + ) + Text( + text = "Encrypt QR codes with shared secret", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = passphraseEnabled, + onCheckedChange = onPassphraseToggle, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = accentColor, + checkedBorderColor = accentColor + ) + ) + } + + if (passphraseEnabled) { + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = passphrase, + onValueChange = onPassphraseChange, + label = { Text("Passphrase") }, + placeholder = { Text("Enter shared secret") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) Button( onClick = onProceed, - modifier = Modifier - .fillMaxWidth() - .height(50.dp), - colors = ButtonDefaults.buttonColors(containerColor = accentColor), - shape = RoundedCornerShape(AshCornerRadius.md) + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = accentColor + ) ) { - Text("Continue", fontWeight = FontWeight.SemiBold) + Text("Continue") } } } @@ -561,58 +752,84 @@ private fun PadSizeCard( onClick: () -> Unit, accentColor: Color ) { + val accentContainer = accentColor.copy(alpha = 0.15f) + val containerColor = if (isSelected) { + accentContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + } + Card( - modifier = Modifier - .fillMaxWidth() - .clickable { onClick() } - .then( - if (isSelected) Modifier.border(2.dp, accentColor, RoundedCornerShape(AshCornerRadius.md)) - else Modifier - ), - colors = CardDefaults.cardColors( - containerColor = if (isSelected) - accentColor.copy(alpha = 0.1f) - else - MaterialTheme.colorScheme.surfaceVariant - ), - shape = RoundedCornerShape(AshCornerRadius.md) + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = containerColor), + border = if (isSelected) { + androidx.compose.foundation.BorderStroke(2.dp, accentColor) + } else null ) { Row( modifier = Modifier .fillMaxWidth() - .padding(AshSpacing.md), + .padding(16.dp), verticalAlignment = Alignment.CenterVertically ) { Column(modifier = Modifier.weight(1f)) { Text( text = size.displayName, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - Text( - text = size.subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = "Transfer: ${size.transferTime}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + style = MaterialTheme.typography.titleMedium ) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = Icons.Outlined.ChatBubbleOutline, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "~${size.messageEstimate} msgs", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = Icons.Default.QrCode2, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "${size.frameCount} frames", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } } - if (isSelected) { - Icon( - Icons.Default.Check, - contentDescription = "Selected", - tint = accentColor + RadioButton( + selected = isSelected, + onClick = onClick, + colors = RadioButtonDefaults.colors( + selectedColor = accentColor, + unselectedColor = MaterialTheme.colorScheme.onSurfaceVariant ) - } + ) } } } -// MARK: - Options Configuration (matches iOS OptionsView) +// ============================================================================ +// Options Configuration +// ============================================================================ +@OptIn(ExperimentalLayoutApi::class) @Composable private fun OptionsConfigurationContent( conversationName: String, @@ -631,93 +848,225 @@ private fun OptionsConfigurationContent( onProceed: () -> Unit, accentColor: Color ) { + var showRetentionMenu by remember { mutableStateOf(false) } + var showDisappearingMenu by remember { mutableStateOf(false) } + Column( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) - .padding(AshSpacing.lg), - verticalArrangement = Arrangement.spacedBy(AshSpacing.lg) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( - text = "Conversation Settings", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold - ) - - // Conversation Name - OutlinedTextField( - value = conversationName, - onValueChange = onNameChange, - label = { Text("Conversation Name") }, - placeholder = { Text("Optional - give this conversation a name") }, + text = "Settings", + style = MaterialTheme.typography.headlineSmall, modifier = Modifier.fillMaxWidth(), - singleLine = true + textAlign = TextAlign.Center ) - // Color Selection Text( - text = "Accent Color", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Medium - ) - Row( - horizontalArrangement = Arrangement.spacedBy(AshSpacing.xs), + text = "Configure message handling and delivery", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth() - ) { - ConversationColor.entries.take(5).forEach { color -> - ColorDot( - color = Color(color.toColorLong()), - isSelected = color == selectedColor, - onClick = { onColorChange(color) } - ) + ) + + // Message Timing Section + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Outlined.Schedule, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Message Timing", + style = MaterialTheme.typography.titleSmall + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Server Retention + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { showRetentionMenu = true } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Server Retention", style = MaterialTheme.typography.bodyMedium) + Text( + "How long unread messages wait", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Box { + TextButton( + onClick = { showRetentionMenu = true }, + colors = ButtonDefaults.textButtonColors(contentColor = accentColor) + ) { + Text(serverRetention.shortName) + } + DropdownMenu( + expanded = showRetentionMenu, + onDismissRequest = { showRetentionMenu = false } + ) { + MessageRetention.entries.forEach { retention -> + DropdownMenuItem( + text = { Text(retention.displayName) }, + onClick = { + onRetentionChange(retention) + showRetentionMenu = false + } + ) + } + } + } + } + + HorizontalDivider() + + // Disappearing Messages + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { showDisappearingMenu = true } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Disappearing Messages", style = MaterialTheme.typography.bodyMedium) + Text( + "Auto-delete after viewing", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Box { + TextButton( + onClick = { showDisappearingMenu = true }, + colors = ButtonDefaults.textButtonColors(contentColor = accentColor) + ) { + Text(disappearingMessages.displayName) + } + DropdownMenu( + expanded = showDisappearingMenu, + onDismissRequest = { showDisappearingMenu = false } + ) { + DisappearingMessages.entries.forEach { option -> + DropdownMenuItem( + text = { Text(option.displayName) }, + onClick = { + onDisappearingChange(option) + showDisappearingMenu = false + } + ) + } + } + } + } } } - Row( - horizontalArrangement = Arrangement.spacedBy(AshSpacing.xs), - modifier = Modifier.fillMaxWidth() - ) { - ConversationColor.entries.drop(5).forEach { color -> - ColorDot( - color = Color(color.toColorLong()), - isSelected = color == selectedColor, - onClick = { onColorChange(color) } + + // Relay Server Section + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Outlined.Cloud, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Relay Server", + style = MaterialTheme.typography.titleSmall + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = relayUrl, + onValueChange = onRelayUrlChange, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("Server URL") } ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + FilledTonalButton( + onClick = onTestConnection, + enabled = !isTestingConnection, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = accentColor.copy(alpha = 0.15f), + contentColor = accentColor + ) + ) { + if (isTestingConnection) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = accentColor + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text(if (isTestingConnection) "Testing..." else "Test Connection") + } + + connectionTestResult?.let { result -> + when (result) { + is InitiatorCeremonyViewModel.ConnectionTestResult.Success -> + Text("Connected", color = MaterialTheme.colorScheme.primary) + is InitiatorCeremonyViewModel.ConnectionTestResult.Failure -> + Text("Failed", color = MaterialTheme.colorScheme.error) + } + } + } } } - // Relay URL - OutlinedTextField( - value = relayUrl, - onValueChange = onRelayUrlChange, - label = { Text("Relay Server") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - - Row( - horizontalArrangement = Arrangement.spacedBy(AshSpacing.xs), - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedButton( - onClick = onTestConnection, - enabled = !isTestingConnection - ) { - if (isTestingConnection) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp + // Conversation Color Section + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Outlined.Palette, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Conversation Color", + style = MaterialTheme.typography.titleSmall ) - } else { - Text("Test Connection") } - } - connectionTestResult?.let { result -> - when (result) { - is InitiatorCeremonyViewModel.ConnectionTestResult.Success -> - Text("Connected", color = AshColors.ashSuccess) - is InitiatorCeremonyViewModel.ConnectionTestResult.Failure -> - Text("Failed: ${result.error}", color = AshColors.ashDanger) + Spacer(modifier = Modifier.height(12.dp)) + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + ConversationColor.entries.forEach { color -> + ColorButton( + color = Color(color.toColorLong()), + isSelected = color == selectedColor, + onClick = { onColorChange(color) } + ) + } } } } @@ -726,47 +1075,47 @@ private fun OptionsConfigurationContent( Button( onClick = onProceed, - modifier = Modifier - .fillMaxWidth() - .height(50.dp), - colors = ButtonDefaults.buttonColors(containerColor = accentColor), - shape = RoundedCornerShape(AshCornerRadius.md) + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = accentColor + ) ) { - Text("Continue", fontWeight = FontWeight.SemiBold) + Text("Continue") } } } @Composable -private fun ColorDot( +private fun ColorButton( color: Color, isSelected: Boolean, onClick: () -> Unit ) { - Box( - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(color) - .clickable { onClick() } - .then( - if (isSelected) Modifier.border(3.dp, MaterialTheme.colorScheme.onSurface, CircleShape) - else Modifier - ), - contentAlignment = Alignment.Center + Surface( + onClick = onClick, + modifier = Modifier.size(44.dp), + shape = CircleShape, + color = color, + border = if (isSelected) { + androidx.compose.foundation.BorderStroke(3.dp, MaterialTheme.colorScheme.outline) + } else null ) { if (isSelected) { - Icon( - Icons.Default.Check, - contentDescription = "Selected", - tint = Color.White, - modifier = Modifier.size(20.dp) - ) + Box(contentAlignment = Alignment.Center) { + Icon( + Icons.Default.Check, + contentDescription = "Selected", + tint = Color.White, + modifier = Modifier.size(22.dp) + ) + } } } } -// MARK: - Consent Screen (matches iOS ConsentView with all 7 items) +// ============================================================================ +// Consent Screen +// ============================================================================ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -778,282 +1127,315 @@ private fun ConsentContent( ) { var showEthicsSheet by remember { mutableStateOf(false) } val sheetState = rememberModalBottomSheetState() + val accentContainer = accentColor.copy(alpha = 0.15f) Column( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) - .padding(AshSpacing.lg), - verticalArrangement = Arrangement.spacedBy(AshSpacing.sm) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { + // Header + Surface( + modifier = Modifier.size(72.dp), + shape = CircleShape, + color = accentContainer + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.Shield, + contentDescription = null, + tint = accentColor, + modifier = Modifier.size(36.dp) + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + Text( - text = "Security Acknowledgment", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold + text = "Security Verification", + style = MaterialTheme.typography.headlineSmall ) Text( - text = "Please confirm you understand these critical security properties before proceeding.", + text = "Confirm you understand before proceeding", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(modifier = Modifier.height(AshSpacing.md)) + Spacer(modifier = Modifier.height(8.dp)) - // 1. Secure Environment - ConsentItem( - icon = Icons.Default.Shield, - iconTint = AshColors.ashAccent, - text = "I am in a secure environment where my screen cannot be observed", - checked = consent.secureEnvironment, - onCheckedChange = { onConsentChange(consent.copy(secureEnvironment = it)) } + // Progress bar + LinearProgressIndicator( + progress = { consent.confirmedCount.toFloat() / consent.totalCount }, + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(RoundedCornerShape(2.dp)), + color = accentColor, + trackColor = accentContainer ) - // 2. No Surveillance - ConsentItem( - icon = Icons.Default.VideocamOff, - iconTint = AshColors.ashDanger, - text = "No cameras or screens are recording this ceremony", - checked = consent.noSurveillance, - onCheckedChange = { onConsentChange(consent.copy(noSurveillance = it)) } + Text( + text = "${consent.confirmedCount} of ${consent.totalCount} confirmed", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) - // 3. Ethics Reviewed - ConsentItem( - icon = Icons.AutoMirrored.Filled.MenuBook, - iconTint = AshColors.blue, - text = "I have reviewed the ethics guidelines", - checked = consent.ethicsReviewed, - onCheckedChange = { onConsentChange(consent.copy(ethicsReviewed = it)) }, - actionText = "View Guidelines", - onAction = { showEthicsSheet = true } - ) + Spacer(modifier = Modifier.height(16.dp)) - // 4. Key Loss Understanding - ConsentItem( - icon = Icons.Default.VpnKey, - iconTint = AshColors.orange, - text = "I understand that lost keys cannot be recovered - there is no \"forgot password\"", - checked = consent.keyLossUnderstood, - onCheckedChange = { onConsentChange(consent.copy(keyLossUnderstood = it)) } - ) + // Environment Section + ConsentSection( + title = "Environment", + icon = Icons.Default.Warning + ) { + ConsentCheckItem( + title = "No one is watching my screen", + subtitle = "No cameras, mirrors, or people can see your display", + checked = consent.noOneWatching, + onCheckedChange = { onConsentChange(consent.copy(noOneWatching = it)) }, + accentColor = accentColor + ) + ConsentCheckItem( + title = "I am not under surveillance or coercion", + subtitle = "Do not proceed if being forced or monitored", + checked = consent.notUnderSurveillance, + onCheckedChange = { onConsentChange(consent.copy(notUnderSurveillance = it)) }, + accentColor = accentColor + ) + } - // 5. Relay Warning - ConsentItem( - icon = Icons.Default.Storage, - iconTint = AshColors.purple, - text = "I understand that relay servers can see message timing and metadata", - checked = consent.relayWarningUnderstood, - onCheckedChange = { onConsentChange(consent.copy(relayWarningUnderstood = it)) } - ) + Spacer(modifier = Modifier.height(12.dp)) - // 6. Data Loss Accepted - ConsentItem( - icon = Icons.Default.Warning, - iconTint = AshColors.ashWarning, - text = "I accept responsibility for any data loss resulting from device loss or app deletion", - checked = consent.dataLossAccepted, - onCheckedChange = { onConsentChange(consent.copy(dataLossAccepted = it)) } - ) + // Responsibilities Section + ConsentSection( + title = "Responsibilities", + icon = Icons.Outlined.TouchApp + ) { + ConsentCheckItem( + title = "I understand the ethical responsibilities", + subtitle = "This tool is for legitimate private communication", + checked = consent.ethicsUnderstood, + onCheckedChange = { onConsentChange(consent.copy(ethicsUnderstood = it)) }, + accentColor = accentColor + ) + ConsentCheckItem( + title = "Keys cannot be recovered", + subtitle = "If you lose access, messages are gone forever", + checked = consent.keysNotRecoverable, + onCheckedChange = { onConsentChange(consent.copy(keysNotRecoverable = it)) }, + accentColor = accentColor + ) + } - // 7. Burn Understanding - ConsentItem( - icon = Icons.Default.LocalFireDepartment, - iconTint = AshColors.ashDanger, - text = "I understand that \"Burn\" permanently destroys all conversation data", - checked = consent.burnUnderstood, - onCheckedChange = { onConsentChange(consent.copy(burnUnderstood = it)) } - ) + Spacer(modifier = Modifier.height(12.dp)) - Spacer(modifier = Modifier.weight(1f)) + // Limitations Section + ConsentSection( + title = "Limitations", + icon = Icons.Default.Info + ) { + ConsentCheckItem( + title = "Relay server may be unavailable", + subtitle = "Messages won't deliver without connectivity", + checked = consent.relayMayBeUnavailable, + onCheckedChange = { onConsentChange(consent.copy(relayMayBeUnavailable = it)) }, + accentColor = accentColor + ) + ConsentCheckItem( + title = "Relay data is not persisted", + subtitle = "Server restarts may cause unread message loss", + checked = consent.relayDataNotPersisted, + onCheckedChange = { onConsentChange(consent.copy(relayDataNotPersisted = it)) }, + accentColor = accentColor + ) + ConsentCheckItem( + title = "Burn destroys all key material", + subtitle = "Either party can burn, it cannot be undone", + checked = consent.burnDestroysAll, + onCheckedChange = { onConsentChange(consent.copy(burnDestroysAll = it)) }, + accentColor = accentColor, + icon = Icons.Default.LocalFireDepartment, + iconTint = MaterialTheme.colorScheme.error + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + TextButton( + onClick = { showEthicsSheet = true }, + colors = ButtonDefaults.textButtonColors(contentColor = accentColor) + ) { + Icon( + imageVector = Icons.Outlined.Description, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Read Ethics Guidelines") + } + + Spacer(modifier = Modifier.height(16.dp)) Button( onClick = onConfirm, - modifier = Modifier - .fillMaxWidth() - .height(50.dp), + modifier = Modifier.fillMaxWidth(), enabled = consent.allConfirmed, - colors = ButtonDefaults.buttonColors(containerColor = accentColor), - shape = RoundedCornerShape(AshCornerRadius.md) + colors = ButtonDefaults.buttonColors( + containerColor = accentColor + ) ) { - Text("I Understand & Accept", fontWeight = FontWeight.SemiBold) + Text("I Understand & Proceed") } } - // Ethics Guidelines Sheet if (showEthicsSheet) { ModalBottomSheet( onDismissRequest = { showEthicsSheet = false }, sheetState = sheetState ) { - EthicsGuidelinesContent( - onDismiss = { showEthicsSheet = false } - ) + EthicsGuidelinesContent(onDismiss = { showEthicsSheet = false }) } } } @Composable -private fun ConsentItem( +private fun ConsentSection( + title: String, icon: ImageVector, - iconTint: Color, - text: String, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit, - actionText: String? = null, - onAction: (() -> Unit)? = null + content: @Composable () -> Unit ) { - Card( - modifier = Modifier - .fillMaxWidth() - .clickable { onCheckedChange(!checked) }, - colors = CardDefaults.cardColors( - containerColor = if (checked) - iconTint.copy(alpha = 0.1f) - else - MaterialTheme.colorScheme.surfaceVariant - ), - shape = RoundedCornerShape(AshCornerRadius.md) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(AshSpacing.md), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = iconTint, - modifier = Modifier.size(24.dp) - ) - - Spacer(modifier = Modifier.width(AshSpacing.sm)) - - Column(modifier = Modifier.weight(1f)) { + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) Text( - text = text, - style = MaterialTheme.typography.bodyMedium + text = title, + style = MaterialTheme.typography.titleSmall ) - if (actionText != null && onAction != null) { - TextButton( - onClick = onAction, - contentPadding = androidx.compose.foundation.layout.PaddingValues(0.dp) - ) { - Text(actionText, style = MaterialTheme.typography.bodySmall) - } - } } + Spacer(modifier = Modifier.height(8.dp)) + content() + } + } +} - Checkbox( - checked = checked, - onCheckedChange = onCheckedChange, - colors = CheckboxDefaults.colors( - checkedColor = iconTint +@Composable +private fun ConsentCheckItem( + title: String, + subtitle: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + accentColor: Color, + icon: ImageVector? = null, + iconTint: Color? = null +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onCheckedChange(!checked) } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.Top + ) { + Checkbox( + checked = checked, + onCheckedChange = onCheckedChange, + colors = CheckboxDefaults.colors( + checkedColor = accentColor, + checkmarkColor = Color.White + ) + ) + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (icon != null && iconTint != null) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconTint, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + } + Text( + text = title, + style = MaterialTheme.typography.bodyMedium ) + } + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } } -// MARK: - Ethics Guidelines Sheet (matches iOS EthicsGuidelinesSheet) - @Composable -private fun EthicsGuidelinesContent( - onDismiss: () -> Unit -) { +private fun EthicsGuidelinesContent(onDismiss: () -> Unit) { Column( modifier = Modifier .fillMaxWidth() .verticalScroll(rememberScrollState()) - .padding(AshSpacing.lg) + .padding(24.dp) ) { Text( text = "Ethics Guidelines", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold - ) - - Spacer(modifier = Modifier.height(AshSpacing.lg)) - - EthicsItem( - number = 1, - title = "Lawful Use Only", - description = "ASH is designed for legitimate privacy needs. Do not use it for any illegal activities, including but not limited to: planning crimes, evading law enforcement, or facilitating harm to others." - ) - - EthicsItem( - number = 2, - title = "No Exploitation", - description = "Never use ASH to exploit, abuse, or harm vulnerable individuals. This includes but is not limited to: harassment, stalking, or any form of abuse." + style = MaterialTheme.typography.headlineSmall ) - EthicsItem( - number = 3, - title = "Responsible Communication", - description = "Use ASH for genuine private communication needs. The strong encryption is meant to protect legitimate privacy, not to enable irresponsible behavior." - ) - - EthicsItem( - number = 4, - title = "Transparency with Partners", - description = "Be honest with your communication partners about the nature of ASH. Ensure they understand the security model and the implications of key loss." - ) + Spacer(modifier = Modifier.height(16.dp)) - EthicsItem( - number = 5, - title = "Report Misuse", - description = "If you become aware of ASH being used for harmful purposes, consider reporting it to appropriate authorities while respecting the privacy of innocent parties." - ) + EthicsItem(1, "Lawful Use Only", "ASH is designed for legitimate privacy needs.") + EthicsItem(2, "No Exploitation", "Never use ASH to exploit or harm others.") + EthicsItem(3, "Responsible Communication", "Use ASH for genuine private communication.") + EthicsItem(4, "Transparency with Partners", "Be honest with your communication partners.") + EthicsItem(5, "Report Misuse", "Consider reporting harmful use of ASH.") - Spacer(modifier = Modifier.height(AshSpacing.lg)) + Spacer(modifier = Modifier.height(24.dp)) Button( onClick = onDismiss, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(AshCornerRadius.md) + modifier = Modifier.fillMaxWidth() ) { Text("I Understand") } - Spacer(modifier = Modifier.height(AshSpacing.xl)) + Spacer(modifier = Modifier.height(32.dp)) } } @Composable -private fun EthicsItem( - number: Int, - title: String, - description: String -) { +private fun EthicsItem(number: Int, title: String, description: String) { Row( modifier = Modifier .fillMaxWidth() - .padding(vertical = AshSpacing.sm) + .padding(vertical = 8.dp) ) { - Box( - modifier = Modifier - .size(28.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary), - contentAlignment = Alignment.Center + Surface( + modifier = Modifier.size(28.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.primary ) { - Text( - text = number.toString(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onPrimary, - fontWeight = FontWeight.Bold - ) + Box(contentAlignment = Alignment.Center) { + Text( + text = number.toString(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onPrimary + ) + } } - - Spacer(modifier = Modifier.width(AshSpacing.sm)) - + Spacer(modifier = Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { - Text( - text = title, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold - ) + Text(text = title, style = MaterialTheme.typography.titleSmall) Text( text = description, style = MaterialTheme.typography.bodySmall, @@ -1063,7 +1445,73 @@ private fun EthicsItem( } } -// MARK: - Loading Content +// ============================================================================ +// Entropy Collection +// ============================================================================ + +@Composable +private fun EntropyCollectionContent( + progress: Float, + onPointCollected: (Float, Float) -> Unit, + accentColor: Color +) { + val accentContainer = accentColor.copy(alpha = 0.15f) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Generate Entropy", + style = MaterialTheme.typography.titleLarge + ) + + Text( + text = "Draw random patterns below", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Large entropy drawing area - takes most of the screen + EntropyCollectionView( + progress = progress, + onPointCollected = onPointCollected, + accentColor = accentColor, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Compact progress indicator + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(4.dp)), + color = accentColor, + trackColor = accentContainer + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = if (progress < 1f) "${(progress * 100).toInt()}% - Keep drawing" else "Complete!", + style = MaterialTheme.typography.titleMedium, + color = if (progress < 1f) MaterialTheme.colorScheme.onSurfaceVariant else accentColor + ) + } +} + +// ============================================================================ +// Loading Content +// ============================================================================ @Composable private fun LoadingContent( @@ -1077,7 +1525,7 @@ private fun LoadingContent( ) { Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(AshSpacing.md) + verticalArrangement = Arrangement.spacedBy(16.dp) ) { if (progress != null) { CircularProgressIndicator(progress = { progress }) @@ -1088,8 +1536,7 @@ private fun LoadingContent( Text( text = title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold + style = MaterialTheme.typography.titleMedium ) Text( @@ -1101,61 +1548,186 @@ private fun LoadingContent( } } -// MARK: - Transfer Content (matches iOS QRDisplayView) +// ============================================================================ +// Transfer Content +// ============================================================================ @Composable private fun TransferringContent( bitmap: android.graphics.Bitmap?, currentFrame: Int, totalFrames: Int, + isPaused: Boolean, + fps: Int, + onTogglePause: () -> Unit, + onPreviousFrame: () -> Unit, + onNextFrame: () -> Unit, + onFirstFrame: () -> Unit, + onLastFrame: () -> Unit, + onReset: () -> Unit, + onFpsChange: (Int) -> Unit, onDone: () -> Unit, accentColor: Color ) { + val accentContainer = accentColor.copy(alpha = 0.15f) + val context = LocalContext.current + DisposableEffect(Unit) { + val window = (context as? Activity)?.window + val originalBrightness = window?.attributes?.screenBrightness ?: -1f + + window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + window?.attributes = window?.attributes?.apply { + screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL + } + + onDispose { + window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + window?.attributes = window?.attributes?.apply { + screenBrightness = originalBrightness + } + } + } + + var showFpsMenu by remember { mutableStateOf(false) } + val progressAnimation by animateFloatAsState( + targetValue = if (totalFrames > 0) (currentFrame + 1).toFloat() / totalFrames else 0f, + label = "progress" + ) + Column( modifier = Modifier .fillMaxSize() - .padding(AshSpacing.lg), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(AshSpacing.lg) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "Show QR Codes", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold + text = "Streaming QR Codes", + style = MaterialTheme.typography.titleLarge ) Text( - text = "Hold your phone steady while your partner scans all the QR codes.", + text = "Let the other device scan continuously", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center + color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(modifier = Modifier.height(AshSpacing.md)) + Spacer(modifier = Modifier.height(16.dp)) - QRCodeView(bitmap = bitmap, size = 300.dp) + // Larger QR code for better scanning - 320dp display size + QRCodeView(bitmap = bitmap, size = 320.dp) - QRCodeFrameCounter( - currentFrame = currentFrame, - totalFrames = totalFrames - ) + Spacer(modifier = Modifier.height(12.dp)) + + // Progress + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "Frame ${currentFrame + 1} of $totalFrames", + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(4.dp)) + LinearProgressIndicator( + progress = { progressAnimation }, + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(RoundedCornerShape(2.dp)), + strokeCap = StrokeCap.Round, + color = accentColor, + trackColor = accentContainer + ) + } Spacer(modifier = Modifier.weight(1f)) + // Playback controls + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onFirstFrame) { + Icon(Icons.Default.SkipPrevious, contentDescription = "First") + } + IconButton(onClick = onPreviousFrame) { + Icon(Icons.Default.FastRewind, contentDescription = "Previous") + } + FilledIconButton( + onClick = onTogglePause, + modifier = Modifier.size(56.dp), + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = accentColor + ) + ) { + Icon( + imageVector = if (isPaused) Icons.Default.PlayArrow else Icons.Default.Pause, + contentDescription = if (isPaused) "Play" else "Pause", + modifier = Modifier.size(28.dp) + ) + } + IconButton(onClick = onNextFrame) { + Icon(Icons.Default.FastForward, contentDescription = "Next") + } + IconButton(onClick = onLastFrame) { + Icon(Icons.Default.SkipNext, contentDescription = "Last") + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedButton( + onClick = onReset, + colors = ButtonDefaults.outlinedButtonColors(contentColor = accentColor) + ) { + Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Reset") + } + + Box { + OutlinedButton( + onClick = { showFpsMenu = true }, + colors = ButtonDefaults.outlinedButtonColors(contentColor = accentColor) + ) { + Icon(Icons.Default.Speed, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("$fps fps") + } + DropdownMenu( + expanded = showFpsMenu, + onDismissRequest = { showFpsMenu = false } + ) { + listOf(2, 3, 4, 5, 6, 8).forEach { fpsOption -> + DropdownMenuItem( + text = { Text("$fpsOption fps") }, + onClick = { + onFpsChange(fpsOption) + showFpsMenu = false + } + ) + } + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + Button( onClick = onDone, - modifier = Modifier - .fillMaxWidth() - .height(50.dp), - colors = ButtonDefaults.buttonColors(containerColor = accentColor), - shape = RoundedCornerShape(AshCornerRadius.md) + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = accentColor + ) ) { - Text("Partner Finished Scanning", fontWeight = FontWeight.SemiBold) + Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("Receiver Ready") } } } -// MARK: - Scanning Content (matches iOS QRScanView) +// ============================================================================ +// Scanning Content +// ============================================================================ @Composable private fun ScanningContent( @@ -1174,261 +1746,415 @@ private fun ScanningContent( totalBlocks = totalBlocks, modifier = Modifier .align(Alignment.BottomCenter) - .padding(AshSpacing.lg) + .padding(24.dp) ) } } -// MARK: - Receiver Setup Content (matches iOS ReceiverSetupView) +// ============================================================================ +// Receiver Setup Content +// ============================================================================ +@OptIn(ExperimentalLayoutApi::class) @Composable private fun ReceiverSetupContent( - conversationName: String, - onNameChange: (String) -> Unit, + passphraseEnabled: Boolean, + onPassphraseToggle: (Boolean) -> Unit, + passphrase: String, + onPassphraseChange: (String) -> Unit, + selectedColor: ConversationColor, + onColorChange: (ConversationColor) -> Unit, onStartScanning: () -> Unit ) { + val accentColor = Color(selectedColor.toColorLong()) + Column( modifier = Modifier .fillMaxSize() - .padding(AshSpacing.lg), - verticalArrangement = Arrangement.spacedBy(AshSpacing.lg) + .verticalScroll(rememberScrollState()) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { + Surface( + modifier = Modifier.size(72.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.tertiaryContainer + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.CameraAlt, + contentDescription = null, + tint = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.size(36.dp) + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + Text( - text = "Join Conversation", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold + text = "Ready to Scan", + style = MaterialTheme.typography.headlineSmall ) Text( - text = "Point your camera at the QR codes on your partner's screen. The transfer will happen automatically.", + text = "Point your camera at the sender's QR codes", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center ) - OutlinedTextField( - value = conversationName, - onValueChange = onNameChange, - label = { Text("Conversation Name") }, - placeholder = { Text("Optional - give this conversation a name") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) + Spacer(modifier = Modifier.height(24.dp)) + + // How it works + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "How it works", + style = MaterialTheme.typography.titleSmall + ) + } + Spacer(modifier = Modifier.height(12.dp)) + + HowItWorksStep(1, "Hold steady and point at the QR codes") + HowItWorksStep(2, "Frames are captured automatically") + HowItWorksStep(3, "Progress shows when complete") + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Passphrase + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Outlined.Lock, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Passphrase Protected", + style = MaterialTheme.typography.titleSmall + ) + Text( + text = "Enable if sender used a passphrase", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = passphraseEnabled, + onCheckedChange = onPassphraseToggle, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = accentColor, + checkedBorderColor = accentColor + ) + ) + } + + if (passphraseEnabled) { + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = passphrase, + onValueChange = onPassphraseChange, + label = { Text("Passphrase") }, + placeholder = { Text("Enter shared secret") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Color picker + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Outlined.Palette, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Conversation Color", + style = MaterialTheme.typography.titleSmall + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + ConversationColor.entries.forEach { color -> + ColorButton( + color = Color(color.toColorLong()), + isSelected = color == selectedColor, + onClick = { onColorChange(color) } + ) + } + } + } + } Spacer(modifier = Modifier.weight(1f)) Button( onClick = onStartScanning, - modifier = Modifier - .fillMaxWidth() - .height(50.dp), - shape = RoundedCornerShape(AshCornerRadius.md) - ) { - Icon( - Icons.Default.CameraAlt, - contentDescription = null, - modifier = Modifier.size(20.dp) + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = accentColor ) - Spacer(modifier = Modifier.width(AshSpacing.xs)) - Text("Start Scanning", fontWeight = FontWeight.SemiBold) + ) { + Icon(Icons.Default.CameraAlt, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("Start Scanning") } } } -// MARK: - Verification Content (matches iOS VerificationView) +@Composable +private fun HowItWorksStep(number: Int, text: String) { + Row( + modifier = Modifier.padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + modifier = Modifier.size(24.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.primary + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = number.toString(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary + ) + } + } + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +// ============================================================================ +// Verification Content +// ============================================================================ @Composable private fun VerificationContent( mnemonic: List, + conversationName: String, + onNameChange: (String) -> Unit, onConfirm: () -> Unit, onReject: () -> Unit, accentColor: Color ) { + // Derive container color from accent (lighter version) + val accentContainer = accentColor.copy(alpha = 0.15f) + Column( modifier = Modifier .fillMaxSize() - .padding(AshSpacing.lg), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(AshSpacing.lg) + .verticalScroll(rememberScrollState()) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - Icon( - Icons.Default.Security, - contentDescription = null, - tint = accentColor, - modifier = Modifier.size(48.dp) - ) + Surface( + modifier = Modifier.size(72.dp), + shape = CircleShape, + color = accentContainer + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.Shield, + contentDescription = null, + tint = accentColor, + modifier = Modifier.size(36.dp) + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) Text( text = "Verify Checksum", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold + style = MaterialTheme.typography.headlineSmall ) Text( - text = "Read these words aloud with your partner. They must match exactly on both devices.", + text = "Both devices must show the same words", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center + color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(modifier = Modifier.height(AshSpacing.lg)) + Spacer(modifier = Modifier.height(24.dp)) - // Mnemonic display - two rows of 3 words - Column( - verticalArrangement = Arrangement.spacedBy(AshSpacing.sm) + // Mnemonic words in a grid + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = accentContainer + ) ) { - Row( - horizontalArrangement = Arrangement.spacedBy(AshSpacing.xs), - modifier = Modifier.fillMaxWidth() - ) { - mnemonic.take(3).forEachIndexed { index, word -> - MnemonicWord( - index = index + 1, - word = word, - modifier = Modifier.weight(1f) - ) - } - } - Row( - horizontalArrangement = Arrangement.spacedBy(AshSpacing.xs), - modifier = Modifier.fillMaxWidth() - ) { - mnemonic.drop(3).take(3).forEachIndexed { index, word -> - MnemonicWord( - index = index + 4, - word = word, - modifier = Modifier.weight(1f) - ) + Column(modifier = Modifier.padding(16.dp)) { + Row(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.weight(1f)) { + mnemonic.take(3).forEachIndexed { index, word -> + MnemonicWord(index + 1, word, accentColor) + } + } + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + mnemonic.drop(3).take(3).forEachIndexed { index, word -> + MnemonicWord(index + 4, word, accentColor) + } + } } } } - Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.height(24.dp)) - Row( - horizontalArrangement = Arrangement.spacedBy(AshSpacing.md), - modifier = Modifier.fillMaxWidth() + OutlinedTextField( + value = conversationName, + onValueChange = onNameChange, + label = { Text("Conversation Name (Optional)") }, + placeholder = { Text("e.g., Alice") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = onConfirm, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = accentColor + ) ) { - OutlinedButton( - onClick = onReject, - modifier = Modifier - .weight(1f) - .height(50.dp), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = AshColors.ashDanger - ), - shape = RoundedCornerShape(AshCornerRadius.md) - ) { - Text("No Match", fontWeight = FontWeight.SemiBold) - } - Button( - onClick = onConfirm, - modifier = Modifier - .weight(1f) - .height(50.dp), - colors = ButtonDefaults.buttonColors(containerColor = AshColors.ashSuccess), - shape = RoundedCornerShape(AshCornerRadius.md) - ) { - Text("Words Match", fontWeight = FontWeight.SemiBold) - } + Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("Words Match") + } + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedButton( + onClick = onReject, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Icon(Icons.Default.Close, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("Words Don't Match") } } } @Composable -private fun MnemonicWord( - index: Int, - word: String, - modifier: Modifier = Modifier -) { - Card( - modifier = modifier, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ), - shape = RoundedCornerShape(AshCornerRadius.sm) +private fun MnemonicWord(number: Int, word: String, accentColor: Color = Color(0xFF5856D6)) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(AshSpacing.sm), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = index.toString(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = word, - style = MaterialTheme.typography.bodyLarge, - fontFamily = FontFamily.Monospace, - fontWeight = FontWeight.Medium - ) - } + Text( + text = "$number.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(24.dp) + ) + Text( + text = word, + style = MaterialTheme.typography.titleMedium, + color = accentColor + ) } } -// MARK: - Completed Content (matches iOS CompletedView) +// ============================================================================ +// Completed Content +// ============================================================================ @Composable private fun CompletedContent( conversationId: String, onDismiss: () -> Unit ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(AshSpacing.lg), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center ) { - Box( - modifier = Modifier - .size(100.dp) - .clip(CircleShape) - .background(AshColors.ashSuccess), - contentAlignment = Alignment.Center + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(24.dp) ) { - Icon( - Icons.Default.Check, - contentDescription = "Success", - tint = Color.White, - modifier = Modifier.size(56.dp) - ) - } - - Spacer(modifier = Modifier.height(AshSpacing.xl)) - - Text( - text = "Ceremony Complete", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold - ) + Surface( + modifier = Modifier.size(96.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(48.dp) + ) + } + } - Spacer(modifier = Modifier.height(AshSpacing.sm)) + Text( + text = "Conversation Created!", + style = MaterialTheme.typography.headlineSmall + ) - Text( - text = "Your secure channel has been established. You can now exchange encrypted messages.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) + Text( + text = "Your secure channel is ready", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) - Spacer(modifier = Modifier.height(AshSpacing.xxl)) + Spacer(modifier = Modifier.height(16.dp)) - Button( - onClick = onDismiss, - modifier = Modifier - .fillMaxWidth() - .height(50.dp), - shape = RoundedCornerShape(AshCornerRadius.md) - ) { - Text("Start Messaging", fontWeight = FontWeight.SemiBold) + Button(onClick = onDismiss) { + Text("Start Messaging") + } } } } -// MARK: - Failed Content (matches iOS FailedView) +// ============================================================================ +// Failed Content +// ============================================================================ @Composable private fun FailedContent( @@ -1436,123 +2162,85 @@ private fun FailedContent( onRetry: () -> Unit, onCancel: () -> Unit ) { - val (errorTitle, errorMessage) = when (error) { - CeremonyError.CANCELLED -> "Ceremony Cancelled" to "The ceremony was cancelled before completion." - CeremonyError.QR_GENERATION_FAILED -> "QR Generation Failed" to "Failed to generate QR codes. Please try again." - CeremonyError.PAD_RECONSTRUCTION_FAILED -> "Transfer Failed" to "Could not reconstruct the encryption pad. Please try again." - CeremonyError.CHECKSUM_MISMATCH -> "Verification Failed" to "The checksum words did not match. This may indicate tampering or transmission errors." - CeremonyError.PASSPHRASE_MISMATCH -> "Passphrase Mismatch" to "The passphrase did not match. Please verify with your partner." - CeremonyError.INVALID_FRAME -> "Invalid Data" to "Received invalid QR code data. Please try scanning again." - } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(AshSpacing.lg), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center ) { - Box( - modifier = Modifier - .size(100.dp) - .clip(CircleShape) - .background(AshColors.ashDanger), - contentAlignment = Alignment.Center + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(24.dp) ) { - Icon( - Icons.Default.Close, - contentDescription = "Error", - tint = Color.White, - modifier = Modifier.size(56.dp) - ) - } - - Spacer(modifier = Modifier.height(AshSpacing.xl)) - - Text( - text = errorTitle, - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold - ) + Surface( + modifier = Modifier.size(96.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.errorContainer + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.size(48.dp) + ) + } + } - Spacer(modifier = Modifier.height(AshSpacing.sm)) + Text( + text = "Ceremony Failed", + style = MaterialTheme.typography.headlineSmall + ) - Text( - text = errorMessage, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) + Text( + text = when (error) { + CeremonyError.CANCELLED -> "The ceremony was cancelled" + CeremonyError.QR_GENERATION_FAILED -> "Failed to generate QR codes" + CeremonyError.PAD_RECONSTRUCTION_FAILED -> "Failed to reconstruct pad" + CeremonyError.CHECKSUM_MISMATCH -> "Security words didn't match" + CeremonyError.PASSPHRASE_MISMATCH -> "Incorrect passphrase" + CeremonyError.INVALID_FRAME -> "Invalid QR code frame" + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) - Spacer(modifier = Modifier.height(AshSpacing.xxl)) + Spacer(modifier = Modifier.height(16.dp)) - Row( - horizontalArrangement = Arrangement.spacedBy(AshSpacing.md), - modifier = Modifier.fillMaxWidth() - ) { - OutlinedButton( - onClick = onCancel, - modifier = Modifier - .weight(1f) - .height(50.dp), - shape = RoundedCornerShape(AshCornerRadius.md) - ) { - Text("Cancel", fontWeight = FontWeight.SemiBold) + Button(onClick = onRetry) { + Text("Try Again") } - Button( - onClick = onRetry, - modifier = Modifier - .weight(1f) - .height(50.dp), - shape = RoundedCornerShape(AshCornerRadius.md) - ) { - Text("Try Again", fontWeight = FontWeight.SemiBold) + + TextButton(onClick = onCancel) { + Text("Cancel") } } } } -// MARK: - Helpers +// ============================================================================ +// Utilities +// ============================================================================ /** - * Returns a stable key for each phase type, so that AnimatedContent - * doesn't re-animate when only the progress/frame values change within a phase. + * Maps ceremony phases to content keys for AnimatedContent. + * Important: Scanning and Transferring use the same key in receiver flow + * to prevent camera recreation when progress updates. */ -private fun phaseToContentKey(phase: CeremonyPhase): String = when (phase) { - is CeremonyPhase.SelectingRole -> "selecting_role" - is CeremonyPhase.SelectingPadSize -> "selecting_pad_size" - is CeremonyPhase.ConfiguringOptions -> "configuring_options" - is CeremonyPhase.ConfirmingConsent -> "confirming_consent" - is CeremonyPhase.CollectingEntropy -> "collecting_entropy" - is CeremonyPhase.GeneratingPad -> "generating_pad" - is CeremonyPhase.GeneratingQRCodes -> "generating_qr_codes" // Same key regardless of progress - is CeremonyPhase.Transferring -> "transferring" // Same key regardless of frame - is CeremonyPhase.Verifying -> "verifying" - is CeremonyPhase.Completed -> "completed" - is CeremonyPhase.Failed -> "failed" - is CeremonyPhase.ConfiguringReceiver -> "configuring_receiver" - is CeremonyPhase.Scanning -> "scanning" -} - -private fun getInitiatorTitle(phase: CeremonyPhase): String = when (phase) { - is CeremonyPhase.SelectingPadSize -> "Pad Size" - is CeremonyPhase.ConfiguringOptions -> "Settings" - is CeremonyPhase.ConfirmingConsent -> "Acknowledgment" - is CeremonyPhase.CollectingEntropy -> "Generate Entropy" - is CeremonyPhase.GeneratingPad -> "Generating" - is CeremonyPhase.GeneratingQRCodes -> "Preparing" - is CeremonyPhase.Transferring -> "Transfer" - is CeremonyPhase.Verifying -> "Verify" - is CeremonyPhase.Completed -> "Complete" - is CeremonyPhase.Failed -> "Failed" - else -> "Create" -} - -private fun getReceiverTitle(phase: CeremonyPhase): String = when (phase) { - is CeremonyPhase.ConfiguringReceiver -> "Setup" - is CeremonyPhase.Scanning, is CeremonyPhase.Transferring -> "Scanning" - is CeremonyPhase.Verifying -> "Verify" - is CeremonyPhase.Completed -> "Complete" - is CeremonyPhase.Failed -> "Failed" - else -> "Join" +private fun phaseToContentKey(phase: CeremonyPhase): String { + return when (phase) { + is CeremonyPhase.SelectingRole -> "selecting_role" + is CeremonyPhase.SelectingPadSize -> "selecting_pad_size" + is CeremonyPhase.ConfiguringOptions -> "configuring_options" + is CeremonyPhase.ConfirmingConsent -> "confirming_consent" + is CeremonyPhase.CollectingEntropy -> "collecting_entropy" + is CeremonyPhase.GeneratingPad -> "generating_pad" + is CeremonyPhase.GeneratingQRCodes -> "generating_qr" + is CeremonyPhase.Transferring -> "scanning_transferring" // Same key as Scanning for receiver + is CeremonyPhase.Verifying -> "verifying" + is CeremonyPhase.Completed -> "completed" + is CeremonyPhase.Failed -> "failed" + is CeremonyPhase.ConfiguringReceiver -> "configuring_receiver" + is CeremonyPhase.Scanning -> "scanning_transferring" // Same key as Transferring for receiver + } } diff --git a/apps/android/app/src/main/java/com/monadial/ash/ui/screens/ConversationsScreen.kt b/apps/android/app/src/main/java/com/monadial/ash/ui/screens/ConversationsScreen.kt index ddccceb..86b2646 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/ui/screens/ConversationsScreen.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/ui/screens/ConversationsScreen.kt @@ -6,6 +6,8 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -141,7 +143,7 @@ fun ConversationsScreen( Icon( Icons.Default.LocalFireDepartment, contentDescription = null, - tint = Color(0xFFFF3B30) + tint = MaterialTheme.colorScheme.error ) }, title = { Text("Burn Conversation?") }, @@ -158,7 +160,7 @@ fun ConversationsScreen( conversationToBurn = null }, colors = ButtonDefaults.buttonColors( - containerColor = Color(0xFFFF3B30) + containerColor = MaterialTheme.colorScheme.error ) ) { Text("Burn") @@ -192,12 +194,14 @@ private fun SwipeableConversationCard( } ) + val errorColor = MaterialTheme.colorScheme.error + SwipeToDismissBox( state = dismissState, backgroundContent = { val color by animateColorAsState( when (dismissState.targetValue) { - SwipeToDismissBoxValue.EndToStart -> Color(0xFFFF3B30) + SwipeToDismissBoxValue.EndToStart -> errorColor else -> Color.Transparent }, label = "background" @@ -236,17 +240,83 @@ private fun EmptyConversationsView( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { + // Icon circle + Box( + modifier = Modifier + .size(100.dp) + .background( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Text( + text = "💬", + style = MaterialTheme.typography.displayMedium + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + Text( text = "No Conversations", style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface ) + Spacer(modifier = Modifier.height(8.dp)) + Text( - text = "Start a secure conversation by tapping the + button", + text = "Start a secure conversation by meeting with someone in person and performing a key exchange ceremony.", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = androidx.compose.ui.text.style.TextAlign.Center ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = onNewConversation, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Icon( + Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("New Conversation") + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun MnemonicTagsRow( + mnemonic: List, + accentColor: Color +) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + mnemonic.forEach { word -> + Surface( + shape = RoundedCornerShape(4.dp), + color = accentColor.copy(alpha = 0.15f) + ) { + Text( + text = word, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, + color = accentColor, + fontWeight = FontWeight.Medium + ) + } + } } } @@ -323,13 +393,12 @@ private fun ConversationCard( overflow = TextOverflow.Ellipsis ) - // Mnemonic + // Mnemonic tags if (conversation.mnemonic.isNotEmpty()) { - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = conversation.mnemonic.joinToString(" "), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f) + Spacer(modifier = Modifier.height(6.dp)) + MnemonicTagsRow( + mnemonic = conversation.mnemonic, + accentColor = accentColor ) } } diff --git a/apps/android/app/src/main/java/com/monadial/ash/ui/screens/MessagingScreen.kt b/apps/android/app/src/main/java/com/monadial/ash/ui/screens/MessagingScreen.kt index 0237a38..4337cba 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/ui/screens/MessagingScreen.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/ui/screens/MessagingScreen.kt @@ -1,9 +1,12 @@ package com.monadial.ash.ui.screens import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -159,14 +162,15 @@ fun MessagingScreen( ) { // Pad usage bar if (conversation != null) { + val progressColor = when { + viewModel.padUsagePercentage > 90 -> MaterialTheme.colorScheme.error + viewModel.padUsagePercentage > 70 -> MaterialTheme.colorScheme.tertiary + else -> accentColor + } LinearProgressIndicator( progress = { viewModel.padUsagePercentage / 100f }, modifier = Modifier.fillMaxWidth(), - color = when { - viewModel.padUsagePercentage > 90 -> Color(0xFFFF3B30) - viewModel.padUsagePercentage > 70 -> Color(0xFFFF9500) - else -> accentColor - } + color = progressColor ) } @@ -194,7 +198,10 @@ fun MessagingScreen( if (messages.isEmpty() && !isLoading) { item { - EmptyMessagesPlaceholder() + EmptyMessagesPlaceholder( + mnemonic = conversation?.mnemonic ?: emptyList(), + accentColor = accentColor + ) } } @@ -285,7 +292,7 @@ private fun MessageBubble( // Retry button for failed messages if (message.status.isFailed) { TextButton(onClick = onRetry) { - Text("Retry", color = Color(0xFFFF3B30)) + Text("Retry", color = MaterialTheme.colorScheme.error) } } } @@ -297,6 +304,9 @@ private fun MessageStatusIcon( status: DeliveryStatus, tint: Color ) { + val successColor = MaterialTheme.colorScheme.primary + val errorColor = MaterialTheme.colorScheme.error + when (status) { DeliveryStatus.SENDING -> CircularProgressIndicator( modifier = Modifier.size(12.dp), @@ -313,13 +323,13 @@ private fun MessageStatusIcon( Icons.Default.CheckCircle, contentDescription = "Delivered", modifier = Modifier.size(14.dp), - tint = Color(0xFF34C759) + tint = successColor ) is DeliveryStatus.FAILED -> Icon( Icons.Default.Error, contentDescription = "Failed", modifier = Modifier.size(14.dp), - tint = Color(0xFFFF3B30) + tint = errorColor ) DeliveryStatus.NONE -> Icon( Icons.Default.Schedule, @@ -423,8 +433,12 @@ private fun MessageInput( } } +@OptIn(ExperimentalLayoutApi::class) @Composable -private fun EmptyMessagesPlaceholder() { +private fun EmptyMessagesPlaceholder( + mnemonic: List = emptyList(), + accentColor: Color = MaterialTheme.colorScheme.primary +) { Column( modifier = Modifier .fillMaxWidth() @@ -432,24 +446,92 @@ private fun EmptyMessagesPlaceholder() { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { - Text( - text = "🔐", - style = MaterialTheme.typography.displayLarge - ) - Spacer(modifier = Modifier.height(16.dp)) + // Lock icon + Box( + modifier = Modifier + .size(80.dp) + .background( + color = accentColor.copy(alpha = 0.1f), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Text( + text = "🔐", + style = MaterialTheme.typography.displaySmall + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + Text( text = "Secure Channel Ready", - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center ) + Spacer(modifier = Modifier.height(8.dp)) + Text( - text = "Your messages are encrypted with a one-time pad. Send your first message!", + text = "Your messages are encrypted with a one-time pad that can never be recovered or compromised.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center ) + + // Mnemonic tags + if (mnemonic.isNotEmpty()) { + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "Security Words", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Medium + ) + + Spacer(modifier = Modifier.height(8.dp)) + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + mnemonic.forEach { word -> + MnemonicTag(word = word, accentColor = accentColor) + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "Send your first message!", + style = MaterialTheme.typography.bodyMedium, + color = accentColor, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center + ) + } +} + +@Composable +private fun MnemonicTag( + word: String, + accentColor: Color +) { + Surface( + shape = RoundedCornerShape(16.dp), + color = accentColor.copy(alpha = 0.1f) + ) { + Text( + text = word, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + style = MaterialTheme.typography.labelMedium, + color = accentColor, + fontWeight = FontWeight.Medium + ) } } diff --git a/apps/android/app/src/main/java/com/monadial/ash/ui/screens/SettingsScreen.kt b/apps/android/app/src/main/java/com/monadial/ash/ui/screens/SettingsScreen.kt index d7fed12..f3b1e21 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/ui/screens/SettingsScreen.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/ui/screens/SettingsScreen.kt @@ -1,5 +1,6 @@ package com.monadial.ash.ui.screens +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -13,8 +14,15 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material.icons.filled.Fingerprint +import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.LocalFireDepartment +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Shield +import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.outlined.Fingerprint import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -27,6 +35,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -54,7 +63,8 @@ fun SettingsScreen( ) { val isBiometricEnabled by viewModel.isBiometricEnabled.collectAsState() val lockOnBackground by viewModel.lockOnBackground.collectAsState() - val relayUrl by viewModel.relayUrl.collectAsState() + val editedRelayUrl by viewModel.editedRelayUrl.collectAsState() + val hasUnsavedChanges by viewModel.hasUnsavedChanges.collectAsState() val isTestingConnection by viewModel.isTestingConnection.collectAsState() val connectionTestResult by viewModel.connectionTestResult.collectAsState() val isBurningAll by viewModel.isBurningAll.collectAsState() @@ -81,11 +91,9 @@ fun SettingsScreen( .padding(16.dp) ) { // Security Section - Text( - text = "Security", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 8.dp) + SectionHeader( + title = "Security", + icon = Icons.Default.Shield ) Card( @@ -115,11 +123,9 @@ fun SettingsScreen( Spacer(modifier = Modifier.height(24.dp)) // Relay Section - Text( - text = "Relay Server", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 8.dp) + SectionHeader( + title = "Network", + icon = Icons.Default.Cloud ) Card( @@ -129,53 +135,107 @@ fun SettingsScreen( ) ) { Column(modifier = Modifier.padding(16.dp)) { - Text( - text = "Default Relay URL", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = relayUrl, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Relay Server URL", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + if (hasUnsavedChanges) { + Text( + text = "Modified", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = editedRelayUrl, + onValueChange = { viewModel.setEditedRelayUrl(it) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("https://relay.example.com") } ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(12.dp)) - OutlinedButton( - onClick = { viewModel.testConnection() }, - enabled = !isTestingConnection, - modifier = Modifier.fillMaxWidth() + // Action buttons row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - if (isTestingConnection) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp - ) - Spacer(modifier = Modifier.width(8.dp)) - } else { + // Test button + OutlinedButton( + onClick = { viewModel.testConnection() }, + enabled = !isTestingConnection && editedRelayUrl.isNotBlank(), + modifier = Modifier.weight(1f) + ) { + if (isTestingConnection) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Testing...") + } else { + Icon( + Icons.Default.Wifi, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Test") + } + } + + // Save button + Button( + onClick = { viewModel.saveRelayUrl() }, + enabled = hasUnsavedChanges && editedRelayUrl.isNotBlank() + ) { + Text("Save") + } + + // Reset button + TextButton( + onClick = { viewModel.resetRelayUrl() } + ) { + Text("Reset") + } + } + + // Connection test result + connectionTestResult?.let { result -> + Spacer(modifier = Modifier.height(12.dp)) + Row( + verticalAlignment = Alignment.CenterVertically + ) { Icon( - Icons.Default.Wifi, + imageVector = if (result.success) Icons.Default.Wifi else Icons.Default.Warning, contentDescription = null, - modifier = Modifier.size(16.dp) + modifier = Modifier.size(16.dp), + tint = if (result.success) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error ) Spacer(modifier = Modifier.width(8.dp)) + Text( + text = if (result.success) { + val latency = result.latencyMs?.let { "${it}ms" } ?: "" + val version = result.version ?: "OK" + "Connected ($version) $latency" + } else { + "Failed: ${result.error ?: "Unknown error"}" + }, + style = MaterialTheme.typography.bodySmall, + color = if (result.success) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error + ) } - Text(if (isTestingConnection) "Testing..." else "Test Connection") - } - - connectionTestResult?.let { result -> - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = if (result.success) { - "Connected! Version: ${result.version ?: "unknown"}" - } else { - "Failed: ${result.error ?: "Unknown error"}" - }, - style = MaterialTheme.typography.bodySmall, - color = if (result.success) Color(0xFF34C759) else Color(0xFFFF3B30) - ) } } } @@ -183,11 +243,9 @@ fun SettingsScreen( Spacer(modifier = Modifier.height(24.dp)) // About Section - Text( - text = "About", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 8.dp) + SectionHeader( + title = "About", + icon = Icons.Default.Info ) Card( @@ -219,30 +277,30 @@ fun SettingsScreen( Spacer(modifier = Modifier.height(32.dp)) // Danger Zone - Text( - text = "Danger Zone", - style = MaterialTheme.typography.titleSmall, - color = Color(0xFFFF3B30), - modifier = Modifier.padding(bottom = 8.dp) + SectionHeader( + title = "Danger Zone", + icon = Icons.Default.Warning, + color = MaterialTheme.colorScheme.error ) Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( - containerColor = Color(0xFFFF3B30).copy(alpha = 0.1f) + containerColor = MaterialTheme.colorScheme.errorContainer ) ) { Column(modifier = Modifier.padding(16.dp)) { Text( text = "Burn All Conversations", style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onErrorContainer ) Spacer(modifier = Modifier.height(4.dp)) Text( text = "Permanently destroy all encryption pads and messages. This cannot be undone.", style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.8f) ) Spacer(modifier = Modifier.height(12.dp)) @@ -251,14 +309,14 @@ fun SettingsScreen( enabled = !isBurningAll, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors( - containerColor = Color(0xFFFF3B30) + containerColor = MaterialTheme.colorScheme.error ) ) { if (isBurningAll) { CircularProgressIndicator( modifier = Modifier.size(16.dp), strokeWidth = 2.dp, - color = Color.White + color = MaterialTheme.colorScheme.onError ) Spacer(modifier = Modifier.width(8.dp)) } else { @@ -286,7 +344,7 @@ fun SettingsScreen( Icon( Icons.Default.LocalFireDepartment, contentDescription = null, - tint = Color(0xFFFF3B30), + tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(32.dp) ) }, @@ -304,7 +362,7 @@ fun SettingsScreen( viewModel.burnAllConversations() }, colors = ButtonDefaults.buttonColors( - containerColor = Color(0xFFFF3B30) + containerColor = MaterialTheme.colorScheme.error ) ) { Text("Burn All") @@ -319,6 +377,32 @@ fun SettingsScreen( } } +@Composable +private fun SectionHeader( + title: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + color: Color = MaterialTheme.colorScheme.primary +) { + Row( + modifier = Modifier.padding(bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = color, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = color, + fontWeight = FontWeight.Medium + ) + } +} + @Composable private fun SettingRow( title: String, diff --git a/apps/android/app/src/main/java/com/monadial/ash/ui/theme/DesignSystem.kt b/apps/android/app/src/main/java/com/monadial/ash/ui/theme/DesignSystem.kt deleted file mode 100644 index 8c8167d..0000000 --- a/apps/android/app/src/main/java/com/monadial/ash/ui/theme/DesignSystem.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.monadial.ash.ui.theme - -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp - -/** - * ASH Design System - matching iOS design tokens - */ -object AshSpacing { - val xxs = 4.dp - val xs = 8.dp - val sm = 12.dp - val md = 16.dp - val lg = 24.dp - val xl = 32.dp - val xxl = 48.dp -} - -object AshCornerRadius { - val sm = 8.dp - val md = 12.dp - val lg = 16.dp - val xl = 20.dp - val continuous = 22.dp -} - -object AshColors { - // Brand colors - val ashAccent = Color(0xFF5856D6) // systemIndigo - val ashDanger = Color(0xFFFF3B30) // systemRed - val ashSuccess = Color(0xFF34C759) // systemGreen - val ashWarning = Color(0xFFFF9500) // systemOrange/Amber - - // Conversation colors (matching iOS) - val indigo = Color(0xFF5856D6) - val blue = Color(0xFF007AFF) - val purple = Color(0xFFAF52DE) - val pink = Color(0xFFFF2D55) - val red = Color(0xFFFF3B30) - val orange = Color(0xFFFF9500) - val yellow = Color(0xFFFFCC00) - val green = Color(0xFF34C759) - val mint = Color(0xFF00C7BE) - val teal = Color(0xFF5AC8FA) -} - -object AshTypography { - val largeTitleSize = 34.sp - val titleSize = 28.sp - val title2Size = 22.sp - val title3Size = 20.sp - val headlineSize = 17.sp - val bodySize = 17.sp - val calloutSize = 16.sp - val subheadSize = 15.sp - val footnoteSize = 13.sp - val captionSize = 12.sp - val caption2Size = 11.sp -} - -object AshSizes { - val iconSmall = 16.dp - val iconMedium = 24.dp - val iconLarge = 32.dp - val iconXLarge = 48.dp - val iconXXLarge = 64.dp - - val buttonHeight = 50.dp - val buttonMinWidth = 88.dp - - val cardMinHeight = 72.dp - val qrCodeSize = 300.dp -} diff --git a/apps/android/app/src/main/java/com/monadial/ash/ui/theme/Theme.kt b/apps/android/app/src/main/java/com/monadial/ash/ui/theme/Theme.kt index 33a9036..203765e 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/ui/theme/Theme.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/ui/theme/Theme.kt @@ -3,6 +3,8 @@ package com.monadial.ash.ui.theme import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.Typography import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme @@ -10,40 +12,260 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.unit.dp + +/** + * ASH Theme - Material Design 3 compliant color scheme + * + * Color roles follow M3 guidelines: + * - primary: Brand color, high emphasis elements + * - onPrimary: Content on primary (must have sufficient contrast) + * - primaryContainer: Less prominent container using primary + * - onPrimaryContainer: Content on primaryContainer (must contrast with primaryContainer) + * + * See: https://m3.material.io/styles/color/roles + */ // ASH brand colors - Indigo theme -val AshIndigo = Color(0xFF5856D6) -val AshIndigoLight = Color(0xFF7A79E0) -val AshIndigoDark = Color(0xFF4240B0) +private val AshIndigo = Color(0xFF5856D6) +private val AshIndigoLight = Color(0xFFE8E7FF) // Light container color +private val AshIndigoDark = Color(0xFF4240B0) + +// Tertiary - Teal accent for visual interest +private val AshTeal = Color(0xFF00897B) +private val AshTealLight = Color(0xFFE0F2F1) +private val AshTealDark = Color(0xFF00695C) + +// Error colors +private val AshError = Color(0xFFBA1A1A) +private val AshErrorLight = Color(0xFFFFDAD6) +private val AshOnErrorLight = Color(0xFF410002) +// Dark theme colors +private val AshErrorDark = Color(0xFFFFB4AB) +private val AshErrorContainerDark = Color(0xFF93000A) +private val AshOnErrorDark = Color(0xFF690005) + +/** + * Dark color scheme following Material 3 guidelines + * Dark themes use lighter tones of colors + */ private val DarkColorScheme = darkColorScheme( - primary = AshIndigo, - onPrimary = Color.White, - primaryContainer = AshIndigoDark, - onPrimaryContainer = Color.White, - secondary = AshIndigoLight, - onSecondary = Color.White, - background = Color(0xFF121212), - surface = Color(0xFF1E1E1E), - onBackground = Color.White, - onSurface = Color.White, - error = Color(0xFFFF453A), - onError = Color.White, + // Primary + primary = Color(0xFFBFBDFF), // Lighter primary for dark theme + onPrimary = Color(0xFF2A2785), + primaryContainer = Color(0xFF413E9C), + onPrimaryContainer = Color(0xFFE2DFFF), + + // Secondary + secondary = Color(0xFFC6C3DC), + onSecondary = Color(0xFF2F2D42), + secondaryContainer = Color(0xFF454359), + onSecondaryContainer = Color(0xFFE3DFF9), + + // Tertiary + tertiary = Color(0xFF80CBC4), + onTertiary = Color(0xFF00382F), + tertiaryContainer = Color(0xFF005046), + onTertiaryContainer = Color(0xFFA1F0E7), + + // Error + error = AshErrorDark, + onError = AshOnErrorDark, + errorContainer = AshErrorContainerDark, + onErrorContainer = Color(0xFFFFDAD6), + + // Background & Surface + background = Color(0xFF1C1B1F), + onBackground = Color(0xFFE6E1E5), + surface = Color(0xFF1C1B1F), + onSurface = Color(0xFFE6E1E5), + surfaceVariant = Color(0xFF49454F), + onSurfaceVariant = Color(0xFFCAC4D0), + + // Outline + outline = Color(0xFF938F99), + outlineVariant = Color(0xFF49454F), + + // Inverse + inverseSurface = Color(0xFFE6E1E5), + inverseOnSurface = Color(0xFF313033), + inversePrimary = AshIndigo, + + // Surface tint + surfaceTint = Color(0xFFBFBDFF), ) +/** + * Light color scheme following Material 3 guidelines + * Always pair colors with their on- variants for proper contrast + */ private val LightColorScheme = lightColorScheme( + // Primary - main brand color primary = AshIndigo, onPrimary = Color.White, primaryContainer = AshIndigoLight, - onPrimaryContainer = Color.White, - secondary = AshIndigoDark, + onPrimaryContainer = Color(0xFF1A1764), // Dark text on light container + + // Secondary - less prominent than primary + secondary = Color(0xFF605D71), onSecondary = Color.White, + secondaryContainer = Color(0xFFE6E0F9), + onSecondaryContainer = Color(0xFF1D1A2C), + + // Tertiary - accent color for visual interest + tertiary = AshTeal, + onTertiary = Color.White, + tertiaryContainer = AshTealLight, + onTertiaryContainer = AshTealDark, + + // Error + error = AshError, + onError = Color.White, + errorContainer = AshErrorLight, + onErrorContainer = AshOnErrorLight, + + // Background & Surface background = Color(0xFFFFFBFE), - surface = Color.White, onBackground = Color(0xFF1C1B1F), + surface = Color(0xFFFFFBFE), onSurface = Color(0xFF1C1B1F), - error = Color(0xFFFF3B30), - onError = Color.White, + surfaceVariant = Color(0xFFE7E0EC), + onSurfaceVariant = Color(0xFF49454F), + + // Outline - for borders and dividers + outline = Color(0xFF79747E), + outlineVariant = Color(0xFFCAC4D0), + + // Inverse - for snackbars, tooltips + inverseSurface = Color(0xFF313033), + inverseOnSurface = Color(0xFFF4EFF4), + inversePrimary = Color(0xFFBFBDFF), + + // Surface tint - used for tonal elevation + surfaceTint = AshIndigo, +) + +/** + * Material 3 Typography scale + * See: https://m3.material.io/styles/typography/type-scale-tokens + */ +private val AshTypography = Typography( + // Display styles - large, expressive headlines + displayLarge = TextStyle( + fontSize = 57.sp, + lineHeight = 64.sp, + fontWeight = FontWeight.Normal, + letterSpacing = (-0.25).sp + ), + displayMedium = TextStyle( + fontSize = 45.sp, + lineHeight = 52.sp, + fontWeight = FontWeight.Normal, + letterSpacing = 0.sp + ), + displaySmall = TextStyle( + fontSize = 36.sp, + lineHeight = 44.sp, + fontWeight = FontWeight.Normal, + letterSpacing = 0.sp + ), + + // Headline styles - section headers + headlineLarge = TextStyle( + fontSize = 32.sp, + lineHeight = 40.sp, + fontWeight = FontWeight.Normal, + letterSpacing = 0.sp + ), + headlineMedium = TextStyle( + fontSize = 28.sp, + lineHeight = 36.sp, + fontWeight = FontWeight.Normal, + letterSpacing = 0.sp + ), + headlineSmall = TextStyle( + fontSize = 24.sp, + lineHeight = 32.sp, + fontWeight = FontWeight.Normal, + letterSpacing = 0.sp + ), + + // Title styles - cards, dialogs, app bars + titleLarge = TextStyle( + fontSize = 22.sp, + lineHeight = 28.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.sp + ), + titleMedium = TextStyle( + fontSize = 16.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.15.sp + ), + titleSmall = TextStyle( + fontSize = 14.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.1.sp + ), + + // Body styles - main content text + bodyLarge = TextStyle( + fontSize = 16.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Normal, + letterSpacing = 0.5.sp + ), + bodyMedium = TextStyle( + fontSize = 14.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.Normal, + letterSpacing = 0.25.sp + ), + bodySmall = TextStyle( + fontSize = 12.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Normal, + letterSpacing = 0.4.sp + ), + + // Label styles - buttons, chips, badges + labelLarge = TextStyle( + fontSize = 14.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.1.sp + ), + labelMedium = TextStyle( + fontSize = 12.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.5.sp + ), + labelSmall = TextStyle( + fontSize = 11.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.5.sp + ) +) + +/** + * Material 3 Shape scale + * See: https://m3.material.io/styles/shape/shape-scale-tokens + */ +private val AshShapes = Shapes( + extraSmall = RoundedCornerShape(4.dp), // Small interactive elements + small = RoundedCornerShape(8.dp), // Chips, small buttons + medium = RoundedCornerShape(12.dp), // Cards, dialogs + large = RoundedCornerShape(16.dp), // FAB, large surfaces + extraLarge = RoundedCornerShape(28.dp) // Bottom sheets, full-height containers ) @Composable @@ -63,6 +285,8 @@ fun AshTheme( MaterialTheme( colorScheme = colorScheme, + typography = AshTypography, + shapes = AshShapes, content = content ) } diff --git a/apps/android/app/src/main/java/com/monadial/ash/ui/viewmodels/InitiatorCeremonyViewModel.kt b/apps/android/app/src/main/java/com/monadial/ash/ui/viewmodels/InitiatorCeremonyViewModel.kt index 81783af..e10b34c 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/ui/viewmodels/InitiatorCeremonyViewModel.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/ui/viewmodels/InitiatorCeremonyViewModel.kt @@ -47,12 +47,13 @@ class InitiatorCeremonyViewModel @Inject constructor( companion object { private const val TAG = "InitiatorCeremonyVM" - // Block size for fountain codes - matches iOS - private const val FOUNTAIN_BLOCK_SIZE = 1000u - // QR code size in pixels - private const val QR_CODE_SIZE = 400 - // Frame display interval in milliseconds - private const val FRAME_DISPLAY_INTERVAL_MS = 250L + // Block size for fountain encoding (1500 bytes + 16 header, base64 ~2021 chars, fits Version 23-24 QR) + // Must match iOS: apps/ios/Ash/Ash/Presentation/ViewModels/InitiatorCeremonyViewModel.swift + private const val FOUNTAIN_BLOCK_SIZE = 1500u + // QR code size in pixels (larger for better scanning) + private const val QR_CODE_SIZE = 600 + // Frame display interval in milliseconds (matches iOS 0.15s = 150ms, ~6.67 FPS) + private const val FRAME_DISPLAY_INTERVAL_MS = 150L } // State @@ -98,6 +99,20 @@ class InitiatorCeremonyViewModel @Inject constructor( private val _isTestingConnection = MutableStateFlow(false) val isTestingConnection: StateFlow = _isTestingConnection.asStateFlow() + // Passphrase protection + private val _passphraseEnabled = MutableStateFlow(false) + val passphraseEnabled: StateFlow = _passphraseEnabled.asStateFlow() + + private val _passphrase = MutableStateFlow("") + val passphrase: StateFlow = _passphrase.asStateFlow() + + // Playback controls + private val _isPaused = MutableStateFlow(false) + val isPaused: StateFlow = _isPaused.asStateFlow() + + private val _fps = MutableStateFlow(7) // Default ~7 FPS (150ms interval, matches iOS) + val fps: StateFlow = _fps.asStateFlow() + // Private state private val collectedEntropy = mutableListOf() private var generatedPadBytes: ByteArray? = null @@ -124,6 +139,17 @@ class InitiatorCeremonyViewModel @Inject constructor( _selectedPadSize.value = size } + fun setPassphraseEnabled(enabled: Boolean) { + _passphraseEnabled.value = enabled + if (!enabled) { + _passphrase.value = "" + } + } + + fun setPassphrase(value: String) { + _passphrase.value = value + } + fun proceedToOptions() { _phase.value = CeremonyPhase.ConfiguringOptions } @@ -193,11 +219,17 @@ class InitiatorCeremonyViewModel @Inject constructor( if (isGeneratingPad) return val timestamp = System.currentTimeMillis() + val nanoTime = System.nanoTime() + + // Collect more data per touch point for stronger entropy collectedEntropy.add((x * 256).toInt().toByte()) collectedEntropy.add((y * 256).toInt().toByte()) collectedEntropy.add((timestamp and 0xFF).toByte()) + collectedEntropy.add(((timestamp shr 8) and 0xFF).toByte()) + collectedEntropy.add((nanoTime and 0xFF).toByte()) - _entropyProgress.value = minOf(1f, collectedEntropy.size / 500f) + // Require 750 bytes of entropy (~150 touch points with 5 bytes each) + _entropyProgress.value = minOf(1f, collectedEntropy.size / 750f) if (_entropyProgress.value >= 1f && !isGeneratingPad) { isGeneratingPad = true @@ -283,7 +315,10 @@ class InitiatorCeremonyViewModel @Inject constructor( relayUrl = _relayUrl.value ) - Log.d(TAG, "Creating fountain generator: blockSize=$FOUNTAIN_BLOCK_SIZE") + // Use passphrase if enabled, otherwise null + val passphraseToUse = if (_passphraseEnabled.value) _passphrase.value.ifEmpty { null } else null + + Log.d(TAG, "Creating fountain generator: blockSize=$FOUNTAIN_BLOCK_SIZE, passphraseEnabled=${_passphraseEnabled.value}") // Create fountain generator using FFI val generator = withContext(Dispatchers.Default) { @@ -291,7 +326,7 @@ class InitiatorCeremonyViewModel @Inject constructor( metadata = metadata, padBytes = padBytes, blockSize = FOUNTAIN_BLOCK_SIZE, - passphrase = null + passphrase = passphraseToUse ) } fountainGenerator = generator @@ -357,20 +392,25 @@ class InitiatorCeremonyViewModel @Inject constructor( private fun startDisplayCycling() { displayJob?.cancel() _currentFrameIndex.value = 0 + _isPaused.value = false _currentQRBitmap.value = preGeneratedQRImages.firstOrNull() displayJob = viewModelScope.launch { while (isActive && preGeneratedQRImages.isNotEmpty()) { - delay(FRAME_DISPLAY_INTERVAL_MS) - val nextIndex = (_currentFrameIndex.value + 1) % preGeneratedQRImages.size - _currentFrameIndex.value = nextIndex - _currentQRBitmap.value = preGeneratedQRImages[nextIndex] - - if (_phase.value is CeremonyPhase.Transferring) { - _phase.value = CeremonyPhase.Transferring( - currentFrame = nextIndex % _totalFrames.value, - totalFrames = _totalFrames.value - ) + val delayMs = 1000L / _fps.value + delay(delayMs) + + if (!_isPaused.value) { + val nextIndex = (_currentFrameIndex.value + 1) % preGeneratedQRImages.size + _currentFrameIndex.value = nextIndex + _currentQRBitmap.value = preGeneratedQRImages[nextIndex] + + if (_phase.value is CeremonyPhase.Transferring) { + _phase.value = CeremonyPhase.Transferring( + currentFrame = nextIndex % _totalFrames.value, + totalFrames = _totalFrames.value + ) + } } } } @@ -381,6 +421,67 @@ class InitiatorCeremonyViewModel @Inject constructor( displayJob = null } + // MARK: - Playback Controls + + fun togglePause() { + _isPaused.value = !_isPaused.value + } + + fun setFps(newFps: Int) { + _fps.value = newFps.coerceIn(1, 10) + } + + fun previousFrame() { + if (preGeneratedQRImages.isEmpty()) return + val prevIndex = if (_currentFrameIndex.value > 0) { + _currentFrameIndex.value - 1 + } else { + preGeneratedQRImages.size - 1 + } + _currentFrameIndex.value = prevIndex + _currentQRBitmap.value = preGeneratedQRImages[prevIndex] + updateTransferringPhase(prevIndex) + } + + fun nextFrame() { + if (preGeneratedQRImages.isEmpty()) return + val nextIndex = (_currentFrameIndex.value + 1) % preGeneratedQRImages.size + _currentFrameIndex.value = nextIndex + _currentQRBitmap.value = preGeneratedQRImages[nextIndex] + updateTransferringPhase(nextIndex) + } + + fun firstFrame() { + if (preGeneratedQRImages.isEmpty()) return + _currentFrameIndex.value = 0 + _currentQRBitmap.value = preGeneratedQRImages.first() + updateTransferringPhase(0) + } + + fun lastFrame() { + if (preGeneratedQRImages.isEmpty()) return + val lastIndex = preGeneratedQRImages.size - 1 + _currentFrameIndex.value = lastIndex + _currentQRBitmap.value = preGeneratedQRImages[lastIndex] + updateTransferringPhase(lastIndex) + } + + fun resetFrames() { + _currentFrameIndex.value = 0 + _currentQRBitmap.value = preGeneratedQRImages.firstOrNull() + _isPaused.value = false + updateTransferringPhase(0) + } + + private fun updateTransferringPhase(frameIndex: Int) { + if (_phase.value is CeremonyPhase.Transferring) { + _phase.value = CeremonyPhase.Transferring( + currentFrame = frameIndex % _totalFrames.value, + totalFrames = _totalFrames.value + ) + } + } + // MARK: - Verification fun finishSending() { @@ -397,7 +498,7 @@ class InitiatorCeremonyViewModel @Inject constructor( val conversation = Conversation( id = tokens.conversationId, - name = _conversationName.value.ifBlank { "New Conversation" }, + name = _conversationName.value.ifBlank { null }, relayUrl = _relayUrl.value, authToken = tokens.authToken, burnToken = tokens.burnToken, @@ -470,6 +571,10 @@ class InitiatorCeremonyViewModel @Inject constructor( _currentFrameIndex.value = 0 _totalFrames.value = 0 _connectionTestResult.value = null + _passphraseEnabled.value = false + _passphrase.value = "" + _isPaused.value = false + _fps.value = 7 // Reset to default ~7 FPS collectedEntropy.clear() generatedPadBytes = null preGeneratedQRImages = emptyList() diff --git a/apps/android/app/src/main/java/com/monadial/ash/ui/viewmodels/MessagingViewModel.kt b/apps/android/app/src/main/java/com/monadial/ash/ui/viewmodels/MessagingViewModel.kt index 1bd3ebc..0b1d5c1 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/ui/viewmodels/MessagingViewModel.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/ui/viewmodels/MessagingViewModel.kt @@ -1,5 +1,6 @@ package com.monadial.ash.ui.viewmodels +import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -26,6 +27,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import javax.inject.Inject +private const val TAG = "MessagingViewModel" + @HiltViewModel class MessagingViewModel @Inject constructor( savedStateHandle: SavedStateHandle, @@ -67,6 +70,14 @@ class MessagingViewModel @Inject constructor( private var pollingJob: Job? = null private var hasAttemptedRegistration: Boolean = false + // Track sent messages to filter out own message echoes from SSE (matching iOS) + private val sentSequences = mutableSetOf() + private val sentBlobIds = mutableSetOf() + private val processedBlobIds = mutableSetOf() + + // Short ID for logging (first 8 chars) + private val logId: String get() = conversationId.take(8) + init { loadConversation() } @@ -126,7 +137,31 @@ class MessagingViewModel @Inject constructor( sseService.events.collect { event -> when (event) { + is SSEEvent.Connected -> { + Log.i(TAG, "[$logId] SSE connected") + } is SSEEvent.MessageReceived -> { + Log.d(TAG, "[$logId] SSE raw: ${event.ciphertext.size} bytes, seq=${event.sequence}, blobId=${event.id.take(8)}") + Log.d(TAG, "[$logId] sentSequences=$sentSequences, sentBlobIds=${sentBlobIds.map { it.take(8) }}") + + // Filter out own messages (matching iOS: sentBlobIds.contains || sentSequences.contains) + if (sentBlobIds.contains(event.id)) { + Log.d(TAG, "[$logId] Skipping own message (blobId match)") + return@collect + } + if (event.sequence != null && sentSequences.contains(event.sequence)) { + Log.d(TAG, "[$logId] Skipping own message (sequence match: ${event.sequence})") + sentBlobIds.add(event.id) + return@collect + } + // Filter duplicates + if (processedBlobIds.contains(event.id)) { + Log.d(TAG, "[$logId] Skipping duplicate message") + return@collect + } + + Log.d(TAG, "[$logId] SSE processing: ${event.ciphertext.size} bytes, seq=${event.sequence}") + handleReceivedMessage( ReceivedMessage( id = event.id, @@ -135,16 +170,20 @@ class MessagingViewModel @Inject constructor( receivedAt = event.receivedAt ) ) + processedBlobIds.add(event.id) } is SSEEvent.DeliveryConfirmed -> { + Log.i(TAG, "[$logId] Delivery confirmed for ${event.blobIds.size} messages") event.blobIds.forEach { blobId -> handleDeliveryConfirmation(blobId) } } is SSEEvent.BurnSignal -> { + Log.w(TAG, "[$logId] Peer burned conversation") handlePeerBurn() } is SSEEvent.NotFound -> { + Log.w(TAG, "[$logId] Conversation not found on relay") // Conversation not found on relay - try to register and reconnect if (!hasAttemptedRegistration) { hasAttemptedRegistration = true @@ -154,7 +193,10 @@ class MessagingViewModel @Inject constructor( } } } - else -> { /* Ignore ping, connected, disconnected, error */ } + is SSEEvent.Error -> { + Log.e(TAG, "[$logId] SSE error: ${event.message}") + } + else -> { /* Ignore ping, disconnected */ } } } } @@ -183,29 +225,56 @@ class MessagingViewModel @Inject constructor( // Check for duplicates using blob ID if (_messages.value.any { it.blobId == received.id }) { + Log.d(TAG, "[$logId] Skipping duplicate (already in messages list)") return } // sequence is the sender's consumption offset, not absolute pad position - val senderOffset = received.sequence ?: return + val senderOffset = received.sequence ?: run { + Log.w(TAG, "[$logId] Received message without sequence, skipping") + return + } + + // Check if this is our OWN sent message (matching iOS processReceivedMessage) + // We must skip these to avoid corrupting peerConsumed state + val isOwnMessage = when (conv.role) { + ConversationRole.INITIATOR -> { + // Initiator sends from [0, sendOffset) - messages in this range are ours + senderOffset < conv.sendOffset + } + ConversationRole.RESPONDER -> { + // Responder sends from [totalBytes - sendOffset, totalBytes) - messages in this range are ours + senderOffset >= conv.padTotalSize - conv.sendOffset + } + } + + if (isOwnMessage) { + Log.d(TAG, "[$logId] Skipping own sent message seq=$senderOffset (sendOffset=${conv.sendOffset})") + return + } + + // Check if we already processed this sequence (stored with conversation) + if (conv.hasProcessedIncomingSequence(senderOffset)) { + Log.d(TAG, "[$logId] Skipping already-processed message seq=$senderOffset") + return + } + + Log.d(TAG, "[$logId] Processing received message: ${received.ciphertext.size} bytes, seq=$senderOffset") try { - // Calculate absolute pad position based on peer's role - // Initiator encrypts from front: absolute = senderOffset - // Responder encrypts from back: absolute = padSize - senderOffset - length - val absoluteOffset: Long - val peerRole: Role - - if (conv.role == ConversationRole.INITIATOR) { - // I'm initiator, peer is responder (encrypts from back) - peerRole = Role.RESPONDER - absoluteOffset = conv.padTotalSize - senderOffset - received.ciphertext.size + // The sequence IS the absolute pad offset where the key starts + // Matching iOS ReceiveMessageUseCase.swift:64-68: uses offset directly + // - Initiator sends sequence = nextSendOffset() = absolute position from front + // - Responder sends sequence = totalBytes - consumedBack - length = absolute position from back + val absoluteOffset = senderOffset + val peerRole: Role = if (conv.role == ConversationRole.INITIATOR) { + Role.RESPONDER } else { - // I'm responder, peer is initiator (encrypts from front) - peerRole = Role.INITIATOR - absoluteOffset = senderOffset + Role.INITIATOR } + Log.d(TAG, "[$logId] Decrypting: peerRole=$peerRole, absoluteOffset=$absoluteOffset") + // Get key bytes from pad at the absolute position val keyBytes = padManager.getBytesForDecryption( offset = absoluteOffset, @@ -217,6 +286,12 @@ class MessagingViewModel @Inject constructor( val plaintext = ashCoreService.decrypt(keyBytes, received.ciphertext) val content = MessageContent.fromBytes(plaintext) + val contentType = when (content) { + is MessageContent.Text -> "text" + is MessageContent.Location -> "location" + } + Log.i(TAG, "[$logId] Decrypted $contentType message, seq=$senderOffset") + val disappearingSeconds = conv.disappearingMessages.seconds?.toLong() val message = Message.incoming( @@ -229,14 +304,31 @@ class MessagingViewModel @Inject constructor( _messages.value = _messages.value + message - // Update peer consumption tracking using PadManager - val consumedAmount = senderOffset + received.ciphertext.size + // Update peer consumption tracking using PadManager (Rust Pad state) + // Calculation must match iOS's calculatePeerConsumed: + // - If I'm initiator (peer is responder): peerConsumed = totalBytes - sequence + // - If I'm responder (peer is initiator): peerConsumed = sequence + length + val consumedAmount = if (conv.role == ConversationRole.INITIATOR) { + // Peer is responder (consumes backward from end) + conv.padTotalSize - senderOffset + } else { + // Peer is initiator (consumes forward from start) + senderOffset + received.ciphertext.size + } + Log.d(TAG, "[$logId] Updating peer consumption: peerRole=$peerRole, consumed=$consumedAmount") padManager.updatePeerConsumption(peerRole, consumedAmount, conversationId) + // Update conversation state (Kotlin entity) and persist to storage + // This matches iOS's: conversation.afterReceiving + conversationRepository.save + val updatedConv = conv.afterReceiving(senderOffset, received.ciphertext.size.toLong()) + conversationStorage.saveConversation(updatedConv) + _conversation.value = updatedConv + Log.d(TAG, "[$logId] Conversation state saved. Remaining=${updatedConv.remainingBytes}") + // Send ACK sendAck(received.id) } catch (e: Exception) { - // Decryption failed + Log.e(TAG, "[$logId] Failed to process message: ${e.message}", e) } } @@ -328,28 +420,51 @@ class MessagingViewModel @Inject constructor( val plaintext = MessageContent.toBytes(content) val myRole = if (conv.role == ConversationRole.INITIATOR) Role.INITIATOR else Role.RESPONDER - // Get the next send offset from PadManager - val currentOffset = padManager.nextSendOffset(myRole, conversationId) + // Calculate sequence (offset where key material STARTS) + // Matching iOS SendMessageUseCase.swift:68-74 + // - Initiator: key starts at consumed_front (use nextSendOffset) + // - Responder: key starts at total_size - consumed_back - message_size + val sequence: Long = if (myRole == Role.RESPONDER) { + val padState = padManager.getPadState(conversationId) + padState.totalBytes - padState.consumedBack - plaintext.size + } else { + padManager.nextSendOffset(myRole, conversationId) + } + + Log.i(TAG, "[$logId] Sending message: ${plaintext.size} bytes, seq=$sequence, remaining=${conv.remainingBytes}") // Check if we can send if (!padManager.canSend(plaintext.size, myRole, conversationId)) { + Log.w(TAG, "[$logId] Pad exhausted - cannot send") _error.value = "Pad exhausted - cannot send message" return@launch } + // Track sent sequence BEFORE sending to filter out SSE echoes (matching iOS) + sentSequences.add(sequence) + Log.d(TAG, "[$logId] Tracked sent sequence: $sequence, sentSequences now: $sentSequences") + // Consume pad bytes for encryption (this updates state in PadManager) val keyBytes = padManager.consumeForSending(plaintext.size, myRole, conversationId) // Encrypt using FFI val ciphertext = ashCoreService.encrypt(keyBytes, plaintext) + Log.d(TAG, "[$logId] Encrypted: ${plaintext.size} → ${ciphertext.size} bytes") + + // Update conversation state after sending and persist to storage + // This matches iOS's: conversation.afterSending + conversationRepository.save + val updatedConv = conv.afterSending(plaintext.size.toLong()) + conversationStorage.saveConversation(updatedConv) + _conversation.value = updatedConv + // Create message val message = Message( conversationId = conversationId, content = content, direction = MessageDirection.SENT, status = DeliveryStatus.SENDING, - sequence = currentOffset, + sequence = sequence, serverExpiresAt = System.currentTimeMillis() + (conv.messageRetention.seconds * 1000L) ) @@ -361,18 +476,22 @@ class MessagingViewModel @Inject constructor( conversationId = conversationId, authToken = conv.authToken, ciphertext = ciphertext, - sequence = currentOffset, + sequence = sequence, ttlSeconds = conv.messageRetention.seconds, relayUrl = conv.relayUrl ) if (sendResult.success && sendResult.blobId != null) { + // Track sent blob ID to filter out SSE echoes (matching iOS) + sentBlobIds.add(sendResult.blobId) + // Update message with blob ID and status _messages.value = _messages.value.map { if (it.id == message.id) { it.withBlobId(sendResult.blobId).withDeliveryStatus(DeliveryStatus.SENT) } else it } + Log.i(TAG, "[$logId] Message sent: blobId=${sendResult.blobId.take(8)}") } else { // Mark as failed _messages.value = _messages.value.map { @@ -381,9 +500,11 @@ class MessagingViewModel @Inject constructor( } else it } _error.value = sendResult.error ?: "Failed to send message" + Log.e(TAG, "[$logId] Send failed: ${sendResult.error}") } } catch (e: Exception) { _error.value = "Failed to send message: ${e.message}" + Log.e(TAG, "[$logId] Send exception: ${e.message}", e) } finally { _isSending.value = false } diff --git a/apps/android/app/src/main/java/com/monadial/ash/ui/viewmodels/ReceiverCeremonyViewModel.kt b/apps/android/app/src/main/java/com/monadial/ash/ui/viewmodels/ReceiverCeremonyViewModel.kt index a584210..f5e50c6 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/ui/viewmodels/ReceiverCeremonyViewModel.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/ui/viewmodels/ReceiverCeremonyViewModel.kt @@ -56,6 +56,17 @@ class ReceiverCeremonyViewModel @Inject constructor( private val _progress = MutableStateFlow(0f) val progress: StateFlow = _progress.asStateFlow() + // Passphrase protection + private val _passphraseEnabled = MutableStateFlow(false) + val passphraseEnabled: StateFlow = _passphraseEnabled.asStateFlow() + + private val _passphrase = MutableStateFlow("") + val passphrase: StateFlow = _passphrase.asStateFlow() + + // Selected color (for receiver UI customization) + private val _selectedColor = MutableStateFlow(ConversationColor.INDIGO) + val selectedColor: StateFlow = _selectedColor.asStateFlow() + // Private state - now using FFI receiver private var fountainReceiver: FountainFrameReceiver? = null private var ceremonyResult: FountainCeremonyResult? = null @@ -65,9 +76,12 @@ class ReceiverCeremonyViewModel @Inject constructor( // MARK: - Scanning Setup fun startScanning() { + // Use passphrase if enabled, otherwise null + val passphraseToUse = if (_passphraseEnabled.value) _passphrase.value.ifEmpty { null } else null + // Create a new fountain receiver using FFI fountainReceiver?.close() - fountainReceiver = ashCoreService.createFountainReceiver(passphrase = null) + fountainReceiver = ashCoreService.createFountainReceiver(passphrase = passphraseToUse) _phase.value = CeremonyPhase.Scanning _receivedBlocks.value = 0 @@ -77,13 +91,28 @@ class ReceiverCeremonyViewModel @Inject constructor( reconstructedPadBytes = null mnemonic = emptyList() - Log.d(TAG, "Started scanning with new fountain receiver") + Log.d(TAG, "Started scanning with new fountain receiver, passphraseEnabled=${_passphraseEnabled.value}") } fun setConversationName(name: String) { _conversationName.value = name } + fun setPassphraseEnabled(enabled: Boolean) { + _passphraseEnabled.value = enabled + if (!enabled) { + _passphrase.value = "" + } + } + + fun setPassphrase(value: String) { + _passphrase.value = value + } + + fun setSelectedColor(color: ConversationColor) { + _selectedColor.value = color + } + // MARK: - Frame Processing fun processScannedFrame(base64String: String) { @@ -98,20 +127,20 @@ class ReceiverCeremonyViewModel @Inject constructor( receiver.addFrameBytes(frameBytes) } - // Update progress - val blocksReceived = receiver.blocksReceived().toInt() + // Update progress - use unique blocks for better UX (excludes duplicates) + val uniqueBlocks = receiver.uniqueBlocksReceived().toInt() val sourceCount = receiver.sourceCount().toInt() val progress = receiver.progress().toFloat() - _receivedBlocks.value = blocksReceived + _receivedBlocks.value = uniqueBlocks _totalBlocks.value = sourceCount _progress.value = progress - Log.d(TAG, "Frame processed: received=$blocksReceived, total=$sourceCount, progress=${(progress * 100).toInt()}%") + Log.d(TAG, "Frame processed: unique=$uniqueBlocks, sourceCount=$sourceCount, progress=${(progress * 100).toInt()}%") // Update phase _phase.value = CeremonyPhase.Transferring( - currentFrame = blocksReceived, + currentFrame = uniqueBlocks, totalFrames = sourceCount ) @@ -151,6 +180,12 @@ class ReceiverCeremonyViewModel @Inject constructor( Log.d(TAG, "Reconstructed pad: ${padUBytes.size} bytes, blocks used: ${result.blocksUsed}") Log.d(TAG, "Metadata: ttl=${result.metadata.ttlSeconds}, relayUrl=${result.metadata.relayUrl}") + // Extract and propagate color from notification flags to UI + val colorIndex = ((result.metadata.notificationFlags.toInt()) shr 12) and 0x0F + val decodedColor = ConversationColor.entries.getOrElse(colorIndex) { ConversationColor.INDIGO } + _selectedColor.value = decodedColor + Log.d(TAG, "Decoded conversation color: $decodedColor (index=$colorIndex)") + // Log first and last 16 bytes of pad for debugging (as hex) val firstBytes = padUBytes.take(16).map { String.format("%02X", it.toInt()) }.joinToString("") val lastBytes = padUBytes.takeLast(16).map { String.format("%02X", it.toInt()) }.joinToString("") @@ -187,9 +222,8 @@ class ReceiverCeremonyViewModel @Inject constructor( // Derive all tokens using FFI directly with UByte list val tokens = uniffi.ash.deriveAllTokens(padUBytes) - // Extract color from notification flags - val colorIndex = ((metadata.notificationFlags.toInt()) shr 12) and 0x0F - val color = ConversationColor.entries.getOrElse(colorIndex) { ConversationColor.INDIGO } + // Use the color already decoded and stored in _selectedColor (from reconstructAndVerify) + val color = _selectedColor.value // Map FFI metadata to domain entities val messageRetention = MessageRetention.fromSeconds(metadata.ttlSeconds.toLong()) @@ -197,7 +231,7 @@ class ReceiverCeremonyViewModel @Inject constructor( val conversation = Conversation( id = tokens.conversationId, - name = _conversationName.value.ifBlank { "New Conversation" }, + name = _conversationName.value.ifBlank { null }, relayUrl = metadata.relayUrl, authToken = tokens.authToken, burnToken = tokens.burnToken, @@ -264,6 +298,9 @@ class ReceiverCeremonyViewModel @Inject constructor( _receivedBlocks.value = 0 _totalBlocks.value = 0 _progress.value = 0f + _passphraseEnabled.value = false + _passphrase.value = "" + _selectedColor.value = ConversationColor.INDIGO ceremonyResult = null reconstructedPadBytes = null mnemonic = emptyList() diff --git a/apps/android/app/src/main/java/com/monadial/ash/ui/viewmodels/SettingsViewModel.kt b/apps/android/app/src/main/java/com/monadial/ash/ui/viewmodels/SettingsViewModel.kt index f836c55..8b83de7 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/ui/viewmodels/SettingsViewModel.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/ui/viewmodels/SettingsViewModel.kt @@ -2,6 +2,7 @@ package com.monadial.ash.ui.viewmodels import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.monadial.ash.BuildConfig import com.monadial.ash.core.services.ConnectionTestResult import com.monadial.ash.core.services.ConversationStorageService import com.monadial.ash.core.services.RelayService @@ -10,6 +11,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import javax.inject.Inject @@ -26,9 +28,18 @@ class SettingsViewModel @Inject constructor( private val _lockOnBackground = MutableStateFlow(true) val lockOnBackground: StateFlow = _lockOnBackground.asStateFlow() + // Saved relay URL (from settings) private val _relayUrl = MutableStateFlow("") val relayUrl: StateFlow = _relayUrl.asStateFlow() + // Edited relay URL (for UI editing) + private val _editedRelayUrl = MutableStateFlow("") + val editedRelayUrl: StateFlow = _editedRelayUrl.asStateFlow() + + // Track if there are unsaved changes + private val _hasUnsavedChanges = MutableStateFlow(false) + val hasUnsavedChanges: StateFlow = _hasUnsavedChanges.asStateFlow() + private val _isTestingConnection = MutableStateFlow(false) val isTestingConnection: StateFlow = _isTestingConnection.asStateFlow() @@ -45,12 +56,46 @@ class SettingsViewModel @Inject constructor( } } viewModelScope.launch { - settingsService.relayServerUrl.collect { - _relayUrl.value = it + settingsService.relayServerUrl.collect { url -> + _relayUrl.value = url + // Only update edited URL if there are no unsaved changes + if (!_hasUnsavedChanges.value) { + _editedRelayUrl.value = url + } + } + } + // Track unsaved changes + viewModelScope.launch { + combine(_relayUrl, _editedRelayUrl) { saved, edited -> + saved != edited + }.collect { + _hasUnsavedChanges.value = it } } } + fun setEditedRelayUrl(url: String) { + _editedRelayUrl.value = url + // Clear test result when URL changes + _connectionTestResult.value = null + } + + fun saveRelayUrl() { + viewModelScope.launch { + settingsService.setRelayServerUrl(_editedRelayUrl.value) + } + } + + fun resetRelayUrl() { + _editedRelayUrl.value = BuildConfig.DEFAULT_RELAY_URL + _connectionTestResult.value = null + } + + fun discardChanges() { + _editedRelayUrl.value = _relayUrl.value + _connectionTestResult.value = null + } + fun setBiometricEnabled(enabled: Boolean) { viewModelScope.launch { settingsService.setBiometricEnabled(enabled) @@ -68,7 +113,8 @@ class SettingsViewModel @Inject constructor( _isTestingConnection.value = true _connectionTestResult.value = null try { - val url = settingsService.relayServerUrl.first() + // Use the edited URL for testing (allows testing before saving) + val url = _editedRelayUrl.value val result = relayService.testConnection(url) _connectionTestResult.value = result } catch (e: Exception) { diff --git a/apps/android/gradle/libs.versions.toml b/apps/android/gradle/libs.versions.toml index 8fef05e..480d389 100644 --- a/apps/android/gradle/libs.versions.toml +++ b/apps/android/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.7.3" +agp = "8.13.2" kotlin = "2.1.0" coreKtx = "1.15.0" lifecycleRuntimeKtx = "2.8.7" diff --git a/apps/android/gradle/wrapper/gradle-wrapper.properties b/apps/android/gradle/wrapper/gradle-wrapper.properties index 09523c0..37f853b 100644 --- a/apps/android/gradle/wrapper/gradle-wrapper.properties +++ b/apps/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/apps/ios/Ash/Ash/Core/Services/SettingsService.swift b/apps/ios/Ash/Ash/Core/Services/SettingsService.swift index 887cffe..c46a5d9 100644 --- a/apps/ios/Ash/Ash/Core/Services/SettingsService.swift +++ b/apps/ios/Ash/Ash/Core/Services/SettingsService.swift @@ -50,7 +50,7 @@ final class SettingsService: SettingsServiceProtocol, @unchecked Sendable { return plistURL } // Fallback to hardcoded default - return "https://relay.ashprotocol.app" + return "https://eu.relay.ashprotocol.app" } var relayServerURL: String { diff --git a/apps/ios/Ash/Ash/Presentation/Screens/ConversationInfoScreen.swift b/apps/ios/Ash/Ash/Presentation/Screens/ConversationInfoScreen.swift index 743c39d..c54710b 100644 --- a/apps/ios/Ash/Ash/Presentation/Screens/ConversationInfoScreen.swift +++ b/apps/ios/Ash/Ash/Presentation/Screens/ConversationInfoScreen.swift @@ -618,7 +618,7 @@ private struct InfoRow: View { role: .initiator, sendOffset: 30_000, peerConsumed: 26_000, - relayURL: "https://relay.ashprotocol.app", + relayURL: "https://eu.relay.ashprotocol.app", disappearingMessages: .fiveMinutes, accentColor: .purple, persistenceConsent: true, diff --git a/bindings/src/ash.udl b/bindings/src/ash.udl index 9e22423..cd94764 100644 --- a/bindings/src/ash.udl +++ b/bindings/src/ash.udl @@ -296,6 +296,10 @@ interface FountainFrameReceiver { /// Number of blocks received (including duplicates). u32 blocks_received(); + /// Number of unique blocks received (excluding duplicates). + /// This is more useful for progress tracking. + u32 unique_blocks_received(); + /// Number of source blocks needed for complete decoding. /// Returns 0 before the first block is received. u32 source_count(); diff --git a/bindings/src/lib.rs b/bindings/src/lib.rs index b68dd55..5740460 100644 --- a/bindings/src/lib.rs +++ b/bindings/src/lib.rs @@ -327,12 +327,18 @@ impl FountainFrameReceiver { receiver.progress() } - /// Number of blocks received + /// Number of blocks received (including duplicates) pub fn blocks_received(&self) -> u32 { let receiver = self.inner.lock().unwrap(); receiver.blocks_received() as u32 } + /// Number of unique blocks received (excluding duplicates) + pub fn unique_blocks_received(&self) -> u32 { + let receiver = self.inner.lock().unwrap(); + receiver.unique_blocks_received() as u32 + } + /// Number of source blocks needed pub fn source_count(&self) -> u32 { let receiver = self.inner.lock().unwrap(); diff --git a/core/src/fountain.rs b/core/src/fountain.rs index f4cf632..f7fc824 100644 --- a/core/src/fountain.rs +++ b/core/src/fountain.rs @@ -523,6 +523,11 @@ impl LTDecoder { self.k } + /// Number of unique blocks received (excluding duplicates). + pub fn unique_blocks_received(&self) -> usize { + self.seen_indices.len() + } + /// Get decoded data (None if incomplete). pub fn get_data(&self) -> Option> { if !self.is_complete() { diff --git a/core/src/frame.rs b/core/src/frame.rs index 6935478..3851d17 100644 --- a/core/src/frame.rs +++ b/core/src/frame.rs @@ -409,6 +409,16 @@ impl FountainFrameReceiver { self.decoder.as_ref().map_or(0, |d| d.source_count()) } + /// Number of unique blocks received (excluding duplicates). + /// + /// This is more useful for progress tracking than [`blocks_received`] + /// since it excludes frames that were scanned multiple times. + pub fn unique_blocks_received(&self) -> usize { + self.decoder + .as_ref() + .map_or(0, |d| d.unique_blocks_received()) + } + /// Get the decoded ceremony result. /// /// Returns `None` if decoding is not complete.