diff --git a/apps/android/.editorconfig b/apps/android/.editorconfig new file mode 100644 index 0000000..f86de75 --- /dev/null +++ b/apps/android/.editorconfig @@ -0,0 +1,130 @@ +# EditorConfig for Android project +# https://editorconfig.org +# Follows Google Android Kotlin Style Guide + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +max_line_length = 120 +tab_width = 4 +trim_trailing_whitespace = true + +[*.{kt,kts}] +# ktlint configuration +ktlint_code_style = android_studio +ktlint_standard = enabled +ktlint_experimental = disabled + +# Standard rules +ktlint_standard_annotation = enabled +ktlint_standard_argument-list-wrapping = enabled +ktlint_standard_blank-line-before-declaration = enabled +ktlint_standard_block-comment-initial-star-alignment = enabled +ktlint_standard_chain-wrapping = enabled +ktlint_standard_class-naming = enabled +ktlint_standard_class-signature = enabled +ktlint_standard_comment-spacing = enabled +ktlint_standard_comment-wrapping = enabled +ktlint_standard_condition-wrapping = enabled +ktlint_standard_context-receiver-wrapping = enabled +ktlint_standard_discouraged-comment-location = enabled +ktlint_standard_enum-entry-name-case = enabled +ktlint_standard_enum-wrapping = enabled +ktlint_standard_filename = enabled +ktlint_standard_final-newline = enabled +ktlint_standard_function-expression-body = disabled +ktlint_standard_function-literal = enabled +ktlint_standard_function-naming = enabled +ktlint_standard_function-signature = enabled +ktlint_standard_function-start-of-body-spacing = enabled +ktlint_standard_function-type-modifier-spacing = enabled +ktlint_standard_function-type-reference-spacing = enabled +ktlint_standard_if-else-bracing = enabled +ktlint_standard_if-else-wrapping = enabled +ktlint_standard_import-ordering = enabled +ktlint_standard_indent = enabled +ktlint_standard_kdoc = enabled +ktlint_standard_kdoc-wrapping = enabled +ktlint_standard_max-line-length = enabled +ktlint_standard_mixed-condition-operators = enabled +ktlint_standard_modifier-list-spacing = enabled +ktlint_standard_modifier-order = enabled +ktlint_standard_multiline-expression-wrapping = disabled +ktlint_standard_multiline-loop = enabled +ktlint_standard_no-blank-line-before-rbrace = enabled +ktlint_standard_no-blank-line-in-list = enabled +ktlint_standard_no-blank-lines-in-chained-method-calls = enabled +ktlint_standard_no-consecutive-blank-lines = enabled +ktlint_standard_no-consecutive-comments = enabled +ktlint_standard_no-empty-class-body = enabled +ktlint_standard_no-empty-file = enabled +ktlint_standard_no-empty-first-line-in-class-body = enabled +ktlint_standard_no-empty-first-line-in-method-block = enabled +ktlint_standard_no-line-break-after-else = enabled +ktlint_standard_no-line-break-before-assignment = enabled +ktlint_standard_no-multi-spaces = enabled +ktlint_standard_no-semi = enabled +ktlint_standard_no-single-line-block-comment = enabled +ktlint_standard_no-trailing-spaces = enabled +ktlint_standard_no-unit-return = enabled +ktlint_standard_no-unused-imports = enabled +ktlint_standard_no-wildcard-imports = enabled +ktlint_standard_nullable-type-spacing = enabled +ktlint_standard_package-name = enabled +ktlint_standard_parameter-list-spacing = enabled +ktlint_standard_parameter-list-wrapping = enabled +ktlint_standard_parameter-wrapping = enabled +ktlint_standard_paren-spacing = enabled +ktlint_standard_property-naming = enabled +ktlint_standard_property-wrapping = enabled +ktlint_standard_range-spacing = enabled +ktlint_standard_spacing-around-angle-brackets = enabled +ktlint_standard_spacing-around-colon = enabled +ktlint_standard_spacing-around-comma = enabled +ktlint_standard_spacing-around-curly = enabled +ktlint_standard_spacing-around-dot = enabled +ktlint_standard_spacing-around-double-colon = enabled +ktlint_standard_spacing-around-keyword = enabled +ktlint_standard_spacing-around-operators = enabled +ktlint_standard_spacing-around-parens = enabled +ktlint_standard_spacing-around-range-operator = enabled +ktlint_standard_spacing-around-unary-operator = enabled +ktlint_standard_spacing-between-declarations-with-annotations = enabled +ktlint_standard_spacing-between-declarations-with-comments = enabled +ktlint_standard_spacing-between-function-name-and-opening-parenthesis = enabled +ktlint_standard_string-template = enabled +ktlint_standard_string-template-indent = disabled +ktlint_standard_trailing-comma-on-call-site = enabled +ktlint_standard_trailing-comma-on-declaration-site = enabled +ktlint_standard_try-catch-finally-spacing = enabled +ktlint_standard_type-argument-list-spacing = enabled +ktlint_standard_type-parameter-list-spacing = enabled +ktlint_standard_unnecessary-parentheses-before-trailing-lambda = enabled +# Allow inline comments for documenting parameters +ktlint_standard_value-argument-comment = disabled +ktlint_standard_value-parameter-comment = disabled +ktlint_standard_when-entry-bracing = enabled +ktlint_standard_wrapping = enabled + +# Compose-specific - disable function naming for composables +ktlint_function_naming_ignore_when_annotated_with = Composable + +[*.xml] +indent_size = 4 + +[*.json] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[*.gradle.kts] +indent_size = 4 + +[*.properties] +charset = latin1 diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 660089d..42dbacc 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -4,6 +4,8 @@ plugins { alias(libs.plugins.kotlin.compose) alias(libs.plugins.hilt) alias(libs.plugins.ksp) + alias(libs.plugins.detekt) + alias(libs.plugins.ktlint) kotlin("plugin.serialization") version "2.1.0" } @@ -72,6 +74,72 @@ android { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } + + testOptions { + unitTests.all { + it.useJUnitPlatform() + } + unitTests.isReturnDefaultValues = true + } + + lint { + warningsAsErrors = false + abortOnError = false + checkDependencies = true + checkReleaseBuilds = true + xmlReport = true + htmlReport = true + lintConfig = file("lint.xml") + disable += + setOf( + "ObsoleteLintCustomCheck", + "GradleDependency", + "OldTargetApi", + "AndroidGradlePluginVersion", + "Aligned16KB", + "MissingApplicationIcon" + ) + enable += + setOf( + "Interoperability", + "UnusedResources" + ) + } +} + +// Detekt configuration +detekt { + buildUponDefaultConfig = true + allRules = false + config.setFrom(files("$rootDir/config/detekt/detekt.yml")) + parallel = true + autoCorrect = true +} + +tasks.withType().configureEach { + jvmTarget = "17" + exclude("**/uniffi/**") + exclude("**/ash.kt") +} + +tasks.withType().configureEach { + jvmTarget = "17" +} + +// ktlint configuration +ktlint { + android = true + ignoreFailures = true + reporters { + reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.PLAIN) + reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.HTML) + } + filter { + exclude("**/generated/**") + exclude("**/uniffi/**") + exclude("**/ash.kt") + include("**/kotlin/**") + } } dependencies { @@ -135,4 +203,17 @@ dependencies { // Play Services Location implementation(libs.play.services.location) + + // Testing + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) + testImplementation(libs.junit.jupiter.params) + testImplementation(libs.mockk) + testImplementation(libs.turbine) + testImplementation(libs.truth) + testImplementation(libs.coroutines.test) + testImplementation(libs.arch.core.testing) + + // Android Instrumentation Tests + androidTestImplementation(libs.mockk.android) } diff --git a/apps/android/app/lint.xml b/apps/android/app/lint.xml new file mode 100644 index 0000000..055d738 --- /dev/null +++ b/apps/android/app/lint.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/android/app/src/main/java/com/monadial/ash/AshApplication.kt b/apps/android/app/src/main/java/com/monadial/ash/AshApplication.kt index a105b8a..8f3eb65 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/AshApplication.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/AshApplication.kt @@ -5,7 +5,6 @@ import dagger.hilt.android.HiltAndroidApp @HiltAndroidApp class AshApplication : Application() { - override fun onCreate() { super.onCreate() // Load native library diff --git a/apps/android/app/src/main/java/com/monadial/ash/MainActivity.kt b/apps/android/app/src/main/java/com/monadial/ash/MainActivity.kt index a86d1cc..1261c11 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/MainActivity.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/MainActivity.kt @@ -8,20 +8,17 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.ui.Modifier -import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle import com.monadial.ash.core.services.SettingsService import com.monadial.ash.ui.AshApp import com.monadial.ash.ui.theme.AshTheme import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { - @Inject lateinit var settingsService: SettingsService diff --git a/apps/android/app/src/main/java/com/monadial/ash/core/common/AppError.kt b/apps/android/app/src/main/java/com/monadial/ash/core/common/AppError.kt new file mode 100644 index 0000000..f578c00 --- /dev/null +++ b/apps/android/app/src/main/java/com/monadial/ash/core/common/AppError.kt @@ -0,0 +1,146 @@ +package com.monadial.ash.core.common + +import java.io.IOException +import java.net.SocketTimeoutException +import java.net.UnknownHostException + +/** + * Sealed hierarchy of application errors. + * Provides type-safe error handling across all layers. + */ +sealed class AppError( + open val message: String, + open val cause: Throwable? = null +) { + fun toException(): Exception = AppException(this) + + // Network errors + sealed class Network( + override val message: String, + override val cause: Throwable? = null + ) : AppError(message, cause) { + data class ConnectionFailed( + override val message: String = "Connection failed", + override val cause: Throwable? = null + ) : Network(message, cause) + + data class Timeout( + override val message: String = "Request timed out", + override val cause: Throwable? = null + ) : Network(message, cause) + + data class NoInternet( + override val message: String = "No internet connection", + override val cause: Throwable? = null + ) : Network(message, cause) + + data class ServerError( + val code: Int, + override val message: String + ) : Network(message) + + data class HttpError( + val code: Int, + override val message: String + ) : Network(message) + } + + // Relay-specific errors + sealed class Relay(override val message: String) : AppError(message) { + data object ConversationNotFound : Relay("Conversation not found on relay") + data object Unauthorized : Relay("Unauthorized access") + data object ConversationBurned : Relay("Conversation has been burned") + data object RegistrationFailed : Relay("Failed to register conversation") + data class SubmitFailed(override val message: String) : Relay(message) + } + + // Pad/encryption errors + sealed class Pad(override val message: String) : AppError(message) { + data object Exhausted : Pad("Pad is exhausted - no more bytes available") + data object NotFound : Pad("Pad not found for conversation") + data object InvalidState : Pad("Pad is in an invalid state") + data class ConsumptionFailed(override val message: String) : Pad(message) + } + + // Cryptography errors + sealed class Crypto( + override val message: String, + override val cause: Throwable? = null + ) : AppError(message, cause) { + data class EncryptionFailed( + override val message: String = "Encryption failed", + override val cause: Throwable? = null + ) : Crypto(message, cause) + + data class DecryptionFailed( + override val message: String = "Decryption failed", + override val cause: Throwable? = null + ) : Crypto(message, cause) + + data class TokenDerivationFailed( + override val message: String = "Failed to derive tokens", + override val cause: Throwable? = null + ) : Crypto(message, cause) + } + + // Storage errors + sealed class Storage( + override val message: String, + override val cause: Throwable? = null + ) : AppError(message, cause) { + data class ReadFailed( + override val message: String = "Failed to read from storage", + override val cause: Throwable? = null + ) : Storage(message, cause) + + data class WriteFailed( + override val message: String = "Failed to write to storage", + override val cause: Throwable? = null + ) : Storage(message, cause) + + data class NotFound( + override val message: String = "Data not found in storage" + ) : Storage(message) + } + + // Location errors + sealed class Location(override val message: String) : AppError(message) { + data object PermissionDenied : Location("Location permission denied") + data object Unavailable : Location("Location unavailable") + data object Timeout : Location("Location request timed out") + } + + // Ceremony errors + sealed class Ceremony(override val message: String) : AppError(message) { + data object QRGenerationFailed : Ceremony("Failed to generate QR code") + data object PadReconstructionFailed : Ceremony("Failed to reconstruct pad from QR codes") + data object ChecksumMismatch : Ceremony("Checksum mismatch - pads do not match") + data object Cancelled : Ceremony("Ceremony was cancelled") + data object InvalidFrame : Ceremony("Invalid QR frame received") + } + + // Generic errors + data class Unknown( + override val message: String, + override val cause: Throwable? = null + ) : AppError(message, cause) + + data class Validation( + override val message: String + ) : AppError(message) + + companion object { + fun fromException(throwable: Throwable): AppError = when (throwable) { + is AppException -> throwable.error + is SocketTimeoutException -> Network.Timeout(cause = throwable) + is UnknownHostException -> Network.NoInternet(cause = throwable) + is IOException -> Network.ConnectionFailed(throwable.message ?: "IO error", throwable) + else -> Unknown(throwable.message ?: "Unknown error", throwable) + } + } +} + +/** + * Exception wrapper for AppError to allow throwing in contexts that require exceptions. + */ +class AppException(val error: AppError) : Exception(error.message, error.cause) diff --git a/apps/android/app/src/main/java/com/monadial/ash/core/common/DispatcherProvider.kt b/apps/android/app/src/main/java/com/monadial/ash/core/common/DispatcherProvider.kt new file mode 100644 index 0000000..5853be2 --- /dev/null +++ b/apps/android/app/src/main/java/com/monadial/ash/core/common/DispatcherProvider.kt @@ -0,0 +1,30 @@ +package com.monadial.ash.core.common + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Provides coroutine dispatchers for dependency injection. + * Allows tests to override dispatchers with TestDispatcher. + */ +interface DispatcherProvider { + val main: CoroutineDispatcher + val io: CoroutineDispatcher + val default: CoroutineDispatcher + val unconfined: CoroutineDispatcher +} + +@Suppress("UseDataClass") // Implements interface, cannot be data class +@Singleton +class DefaultDispatcherProvider @Inject constructor() : DispatcherProvider { + override val main: CoroutineDispatcher + get() = Dispatchers.Main + override val io: CoroutineDispatcher + get() = Dispatchers.IO + override val default: CoroutineDispatcher + get() = Dispatchers.Default + override val unconfined: CoroutineDispatcher + get() = Dispatchers.Unconfined +} diff --git a/apps/android/app/src/main/java/com/monadial/ash/core/common/Result.kt b/apps/android/app/src/main/java/com/monadial/ash/core/common/Result.kt new file mode 100644 index 0000000..8106707 --- /dev/null +++ b/apps/android/app/src/main/java/com/monadial/ash/core/common/Result.kt @@ -0,0 +1,84 @@ +package com.monadial.ash.core.common + +/** + * A sealed class representing the result of an operation that can either succeed or fail. + * Provides functional operators for composing operations. + */ +sealed class AppResult { + data class Success(val data: T) : AppResult() + data class Error(val error: AppError) : AppResult() + + val isSuccess: Boolean get() = this is Success + val isError: Boolean get() = this is Error + + fun getOrNull(): T? = (this as? Success)?.data + fun errorOrNull(): AppError? = (this as? Error)?.error + + fun getOrThrow(): T = when (this) { + is Success -> data + is Error -> throw error.toException() + } + + inline fun map(transform: (T) -> R): AppResult = when (this) { + is Success -> Success(transform(data)) + is Error -> this + } + + inline fun flatMap(transform: (T) -> AppResult): AppResult = when (this) { + is Success -> transform(data) + is Error -> this + } + + inline fun onSuccess(action: (T) -> Unit): AppResult { + if (this is Success) action(data) + return this + } + + inline fun onError(action: (AppError) -> Unit): AppResult { + if (this is Error) action(error) + return this + } + + inline fun recover(transform: (AppError) -> @UnsafeVariance T): T = when (this) { + is Success -> data + is Error -> transform(error) + } + + inline fun recoverWith(transform: (AppError) -> AppResult<@UnsafeVariance T>): AppResult = when (this) { + is Success -> this + is Error -> transform(error) + } + + companion object { + fun success(data: T): AppResult = Success(data) + fun error(error: AppError): AppResult = Error(error) + fun error(message: String): AppResult = Error(AppError.Unknown(message)) + + inline fun runCatching(block: () -> T): AppResult = try { + Success(block()) + } catch (e: Exception) { + Error(AppError.fromException(e)) + } + + suspend inline fun runCatchingSuspend(crossinline block: suspend () -> T): AppResult = try { + Success(block()) + } catch (e: Exception) { + Error(AppError.fromException(e)) + } + } +} + +/** + * Combines two results, returning a pair if both succeed. + */ +fun AppResult.zip(other: AppResult): AppResult> = flatMap { a -> + other.map { b -> a to b } +} + +/** + * Extension to convert Kotlin Result to AppResult. + */ +fun Result.toAppResult(): AppResult = fold( + onSuccess = { AppResult.Success(it) }, + onFailure = { AppResult.Error(AppError.fromException(it)) } +) diff --git a/apps/android/app/src/main/java/com/monadial/ash/core/services/AshCoreService.kt b/apps/android/app/src/main/java/com/monadial/ash/core/services/AshCoreService.kt index c212f5d..8015438 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/core/services/AshCoreService.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/core/services/AshCoreService.kt @@ -1,5 +1,7 @@ package com.monadial.ash.core.services +import javax.inject.Inject +import javax.inject.Singleton import uniffi.ash.AuthTokens import uniffi.ash.CeremonyMetadata import uniffi.ash.FountainCeremonyResult @@ -17,8 +19,6 @@ import uniffi.ash.deriveConversationId import uniffi.ash.encrypt import uniffi.ash.generateMnemonic import uniffi.ash.validatePassphrase -import javax.inject.Inject -import javax.inject.Singleton /** * Service that wraps the ASH Core Rust FFI bindings. @@ -27,7 +27,6 @@ import javax.inject.Singleton */ @Singleton class AshCoreService @Inject constructor() { - // === Fountain Code Operations (QR Transfer) === /** @@ -199,11 +198,7 @@ class AshCoreService @Inject constructor() { * @param consumedBack Bytes consumed from end (by Responder) * @return A new Pad instance with restored state */ - fun createPadFromBytesWithState( - bytes: ByteArray, - consumedFront: ULong, - consumedBack: ULong - ): Pad { + fun createPadFromBytesWithState(bytes: ByteArray, consumedFront: ULong, consumedBack: ULong): Pad { return Pad.fromBytesWithState( bytes.map { it.toUByte() }, consumedFront, @@ -216,44 +211,34 @@ class AshCoreService @Inject constructor() { /** * Convert a FountainFrameGenerator frame to ByteArray. */ - fun FountainFrameGenerator.generateFrameBytes(index: UInt): ByteArray { - return this.generateFrame(index).map { it.toByte() }.toByteArray() - } + fun FountainFrameGenerator.generateFrameBytes(index: UInt): ByteArray = + this.generateFrame(index).map { it.toByte() }.toByteArray() /** * Convert a FountainFrameGenerator frame to ByteArray (next frame). */ - fun FountainFrameGenerator.nextFrameBytes(): ByteArray { - return this.nextFrame().map { it.toByte() }.toByteArray() - } + fun FountainFrameGenerator.nextFrameBytes(): ByteArray = this.nextFrame().map { it.toByte() }.toByteArray() /** * Add a frame to the receiver from ByteArray. */ - fun FountainFrameReceiver.addFrameBytes(frameBytes: ByteArray): Boolean { - return this.addFrame(frameBytes.map { it.toUByte() }) - } + fun FountainFrameReceiver.addFrameBytes(frameBytes: ByteArray): Boolean = + this.addFrame(frameBytes.map { it.toUByte() }) /** * Get the decoded pad bytes from a ceremony result. */ - fun FountainCeremonyResult.getPadBytes(): ByteArray { - return this.pad.map { it.toByte() }.toByteArray() - } + fun FountainCeremonyResult.getPadBytes(): ByteArray = this.pad.map { it.toByte() }.toByteArray() /** * Get pad bytes from a Pad. */ - fun Pad.getBytes(): ByteArray { - return this.asBytes().map { it.toByte() }.toByteArray() - } + fun Pad.getBytes(): ByteArray = this.asBytes().map { it.toByte() }.toByteArray() /** * Consume bytes from a Pad. */ - fun Pad.consumeBytes(n: UInt, role: Role): ByteArray { - return this.consume(n, role).map { it.toByte() }.toByteArray() - } + fun Pad.consumeBytes(n: UInt, role: Role): ByteArray = this.consume(n, role).map { it.toByte() }.toByteArray() } // === Extension functions for convenience === diff --git a/apps/android/app/src/main/java/com/monadial/ash/core/services/BiometricService.kt b/apps/android/app/src/main/java/com/monadial/ash/core/services/BiometricService.kt index 923d7ab..200f3ca 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/core/services/BiometricService.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/core/services/BiometricService.kt @@ -12,54 +12,55 @@ import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine @Singleton -class BiometricService @Inject constructor( - @ApplicationContext private val context: Context -) { +class BiometricService @Inject constructor(@ApplicationContext private val context: Context) { private val biometricManager = BiometricManager.from(context) val isAvailable: Boolean - get() = biometricManager.canAuthenticate( - BiometricManager.Authenticators.BIOMETRIC_STRONG or - BiometricManager.Authenticators.DEVICE_CREDENTIAL - ) == BiometricManager.BIOMETRIC_SUCCESS + get() = + biometricManager.canAuthenticate( + BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.DEVICE_CREDENTIAL + ) == BiometricManager.BIOMETRIC_SUCCESS val biometricType: String - get() = when { - biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) - == BiometricManager.BIOMETRIC_SUCCESS -> "Biometric" - else -> "Device Credential" - } + get() = + when { + biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) + == BiometricManager.BIOMETRIC_SUCCESS -> "Biometric" + else -> "Device Credential" + } - suspend fun authenticate(activity: FragmentActivity, title: String, subtitle: String): Boolean { - return suspendCoroutine { continuation -> + suspend fun authenticate(activity: FragmentActivity, title: String, subtitle: String): Boolean = + suspendCoroutine { continuation -> val executor = ContextCompat.getMainExecutor(context) - val callback = object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - continuation.resume(true) - } + val callback = + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + continuation.resume(true) + } - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { - continuation.resume(false) - } + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + continuation.resume(false) + } - override fun onAuthenticationFailed() { - // Don't resume yet - user can retry + override fun onAuthenticationFailed() { + // Don't resume yet - user can retry + } } - } val biometricPrompt = BiometricPrompt(activity, executor, callback) - val promptInfo = BiometricPrompt.PromptInfo.Builder() - .setTitle(title) - .setSubtitle(subtitle) - .setAllowedAuthenticators( - BiometricManager.Authenticators.BIOMETRIC_STRONG or - BiometricManager.Authenticators.DEVICE_CREDENTIAL - ) - .build() + val promptInfo = + BiometricPrompt.PromptInfo.Builder() + .setTitle(title) + .setSubtitle(subtitle) + .setAllowedAuthenticators( + BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.DEVICE_CREDENTIAL + ) + .build() biometricPrompt.authenticate(promptInfo) } - } } 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 be7895e..926c56f 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 @@ -1,10 +1,13 @@ package com.monadial.ash.core.services import android.content.Context +import androidx.core.content.edit import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import com.monadial.ash.domain.entities.Conversation import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -13,68 +16,71 @@ 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 -) +data class PadStorageData(val bytes: String, val consumedFront: Long, val consumedBack: Long) @Singleton -class ConversationStorageService @Inject constructor( - @ApplicationContext private val context: Context -) { - private val masterKey = MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - - private val encryptedPrefs = EncryptedSharedPreferences.create( - context, - "ash_conversations", - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) +class ConversationStorageService @Inject constructor(@ApplicationContext private val context: Context) { + private val masterKey = + MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + private val encryptedPrefs = + EncryptedSharedPreferences.create( + context, + "ash_conversations", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) private val json = Json { ignoreUnknownKeys = true } private val _conversations = MutableStateFlow>(emptyList()) val conversations: StateFlow> = _conversations.asStateFlow() - suspend fun loadConversations() = withContext(Dispatchers.IO) { - val all = encryptedPrefs.all - val loaded = all.mapNotNull { (key, value) -> - if (key.startsWith("conversation_") && value is String) { - try { - json.decodeFromString(value) - } catch (e: Exception) { - null - } - } else null - }.sortedByDescending { it.lastMessageAt ?: it.createdAt } - _conversations.value = loaded + suspend fun loadConversations() { + withContext(Dispatchers.IO) { + val all = encryptedPrefs.all + val loaded = + all.mapNotNull { (key, value) -> + if (key.startsWith("conversation_") && value is String) { + try { + json.decodeFromString(value) + } catch (e: Exception) { + null + } + } else { + null + } + }.sortedByDescending { it.lastMessageAt ?: it.createdAt } + _conversations.value = loaded + } } - suspend fun saveConversation(conversation: Conversation) = withContext(Dispatchers.IO) { - val serialized = json.encodeToString(conversation) - encryptedPrefs.edit() - .putString("conversation_${conversation.id}", serialized) - .apply() - loadConversations() + suspend fun saveConversation(conversation: Conversation) { + withContext(Dispatchers.IO) { + val serialized = json.encodeToString(conversation) + encryptedPrefs.edit { + putString("conversation_${conversation.id}", serialized) + } + loadConversations() + } } - suspend fun deleteConversation(conversationId: String) = withContext(Dispatchers.IO) { - encryptedPrefs.edit() - .remove("conversation_$conversationId") - .apply() - loadConversations() + suspend fun deleteConversation(conversationId: String) { + withContext(Dispatchers.IO) { + encryptedPrefs.edit { + remove("conversation_$conversationId") + } + loadConversations() + } } suspend fun getConversation(conversationId: String): Conversation? = withContext(Dispatchers.IO) { @@ -95,52 +101,53 @@ class ConversationStorageService @Inject constructor( * Save pad with initial state (after ceremony). * Matching iOS: PadManager.storePad */ - suspend fun savePadBytes(conversationId: String, padBytes: ByteArray) = withContext(Dispatchers.IO) { - 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() + suspend fun savePadBytes(conversationId: String, padBytes: ByteArray) { + withContext(Dispatchers.IO) { + 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) + } + } } /** * 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", serialized) - .apply() + 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", serialized) + } + } } /** * Get pad bytes only (for decryption/token derivation). */ suspend fun getPadBytes(conversationId: String): ByteArray? = withContext(Dispatchers.IO) { - val serialized = encryptedPrefs.getString("pad_$conversationId", null) ?: return@withContext null + 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(serialized, android.util.Base64.NO_WRAP) - } catch (e2: Exception) { + } catch (ignored: Exception) { null } } @@ -151,11 +158,11 @@ class ConversationStorageService @Inject constructor( * 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 + 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) @@ -164,38 +171,37 @@ class ConversationStorageService @Inject constructor( consumedFront = conversation?.padConsumedFront ?: 0, consumedBack = conversation?.padConsumedBack ?: 0 ) - } catch (e2: Exception) { + } catch (ignored: Exception) { null } } } - suspend fun deletePadBytes(conversationId: String) = withContext(Dispatchers.IO) { - encryptedPrefs.edit() - .remove("pad_$conversationId") - .apply() + suspend fun deletePadBytes(conversationId: String) { + withContext(Dispatchers.IO) { + encryptedPrefs.edit { + remove("pad_$conversationId") + } + } } /** * Update consumption state in pad storage. * Matching iOS: PadManager.savePadState */ - suspend fun updatePadConsumption( - conversationId: String, - consumedFront: Long, - consumedBack: Long - ) = withContext(Dispatchers.IO) { - // Load existing pad data - val existing = getPadStorageData(conversationId) ?: return@withContext - - // Save with updated consumption state - val updated = existing.copy( - consumedFront = consumedFront, - consumedBack = consumedBack - ) - val serialized = json.encodeToString(updated) - encryptedPrefs.edit() - .putString("pad_$conversationId", serialized) - .apply() + suspend fun updatePadConsumption(conversationId: String, consumedFront: Long, consumedBack: Long) { + withContext(Dispatchers.IO) { + val existing = getPadStorageData(conversationId) ?: return@withContext + + val updated = + existing.copy( + consumedFront = consumedFront, + consumedBack = consumedBack + ) + val serialized = json.encodeToString(updated) + encryptedPrefs.edit { + putString("pad_$conversationId", serialized) + } + } } } diff --git a/apps/android/app/src/main/java/com/monadial/ash/core/services/LocationService.kt b/apps/android/app/src/main/java/com/monadial/ash/core/services/LocationService.kt index 1052f3f..9178eeb 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/core/services/LocationService.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/core/services/LocationService.kt @@ -13,19 +13,16 @@ import com.google.android.gms.location.LocationResult import com.google.android.gms.location.LocationServices import com.google.android.gms.location.Priority import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.suspendCancellableCoroutine import javax.inject.Inject import javax.inject.Singleton import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.suspendCancellableCoroutine -data class AshLocationResult( - val latitude: Double, - val longitude: Double -) { +data class AshLocationResult(val latitude: Double, val longitude: Double) { // Format to 6 decimal places (~10cm precision as per spec) val formattedLatitude: String get() = "%.6f".format(latitude) val formattedLongitude: String get() = "%.6f".format(longitude) @@ -33,28 +30,30 @@ data class AshLocationResult( sealed class LocationError : Exception() { data object PermissionDenied : LocationError() + data object LocationUnavailable : LocationError() + data object Timeout : LocationError() } @Singleton -class LocationService @Inject constructor( - @ApplicationContext private val context: Context -) { +class LocationService @Inject constructor(@ApplicationContext private val context: Context) { private val fusedLocationClient: FusedLocationProviderClient = LocationServices.getFusedLocationProviderClient(context) val hasLocationPermission: Boolean - get() = ContextCompat.checkSelfPermission( - context, - Manifest.permission.ACCESS_FINE_LOCATION - ) == PackageManager.PERMISSION_GRANTED + get() = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED val hasCoarseLocationPermission: Boolean - get() = ContextCompat.checkSelfPermission( - context, - Manifest.permission.ACCESS_COARSE_LOCATION - ) == PackageManager.PERMISSION_GRANTED + get() = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_COARSE_LOCATION + ) == PackageManager.PERMISSION_GRANTED suspend fun getCurrentLocation(): Result { if (!hasLocationPermission && !hasCoarseLocationPermission) { @@ -80,24 +79,23 @@ class LocationService @Inject constructor( } } - private suspend fun getLastKnownLocation(): Location? = - suspendCancellableCoroutine { continuation -> - try { - fusedLocationClient.lastLocation - .addOnSuccessListener { location -> - continuation.resume(location) - } - .addOnFailureListener { e -> - continuation.resumeWithException(e) - } - } catch (e: SecurityException) { - continuation.resumeWithException(e) - } + private suspend fun getLastKnownLocation(): Location? = suspendCancellableCoroutine { continuation -> + try { + fusedLocationClient.lastLocation + .addOnSuccessListener { location -> + continuation.resume(location) + } + .addOnFailureListener { e -> + continuation.resumeWithException(e) + } + } catch (e: SecurityException) { + continuation.resumeWithException(e) } + } - private suspend fun requestFreshLocation(): Location? = - suspendCancellableCoroutine { continuation -> - val locationRequest = LocationRequest.Builder( + private suspend fun requestFreshLocation(): Location? = suspendCancellableCoroutine { continuation -> + val locationRequest = + LocationRequest.Builder( Priority.PRIORITY_HIGH_ACCURACY, 1000L ) @@ -105,27 +103,28 @@ class LocationService @Inject constructor( .setWaitForAccurateLocation(true) .build() - val callback = object : LocationCallback() { + val callback = + object : LocationCallback() { override fun onLocationResult(result: LocationResult) { fusedLocationClient.removeLocationUpdates(this) continuation.resume(result.lastLocation) } } - try { - fusedLocationClient.requestLocationUpdates( - locationRequest, - callback, - Looper.getMainLooper() - ) + try { + fusedLocationClient.requestLocationUpdates( + locationRequest, + callback, + Looper.getMainLooper() + ) - continuation.invokeOnCancellation { - fusedLocationClient.removeLocationUpdates(callback) - } - } catch (e: SecurityException) { - continuation.resumeWithException(e) + continuation.invokeOnCancellation { + fusedLocationClient.removeLocationUpdates(callback) } + } catch (e: SecurityException) { + continuation.resumeWithException(e) } + } fun observeLocationUpdates(): Flow = callbackFlow { if (!hasLocationPermission && !hasCoarseLocationPermission) { @@ -133,23 +132,25 @@ class LocationService @Inject constructor( return@callbackFlow } - val locationRequest = LocationRequest.Builder( - Priority.PRIORITY_HIGH_ACCURACY, - 10000L - ).build() - - val callback = object : LocationCallback() { - override fun onLocationResult(result: com.google.android.gms.location.LocationResult) { - result.lastLocation?.let { location -> - trySend( - AshLocationResult( - latitude = location.latitude, - longitude = location.longitude + val locationRequest = + LocationRequest.Builder( + Priority.PRIORITY_HIGH_ACCURACY, + 10000L + ).build() + + val callback = + object : LocationCallback() { + override fun onLocationResult(result: com.google.android.gms.location.LocationResult) { + result.lastLocation?.let { location -> + trySend( + AshLocationResult( + latitude = location.latitude, + longitude = location.longitude + ) ) - ) + } } } - } try { fusedLocationClient.requestLocationUpdates( 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 51f696f..500efb6 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 @@ -1,12 +1,12 @@ package com.monadial.ash.core.services import android.util.Log +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import uniffi.ash.Pad import uniffi.ash.Role -import javax.inject.Inject -import javax.inject.Singleton /** * Pad state for UI display @@ -28,9 +28,7 @@ data class PadState( * Matching iOS: apps/ios/Ash/Ash/Core/Services/PadManager.swift */ @Singleton -class PadManager @Inject constructor( - private val conversationStorage: ConversationStorageService -) { +class PadManager @Inject constructor(private val conversationStorage: ConversationStorageService) { companion object { private const val TAG = "PadManager" } @@ -52,19 +50,26 @@ class PadManager @Inject constructor( padCache[conversationId]?.let { return@withLock it } // Load from storage (matching iOS: PadStorageData from Keychain) - val storageData = conversationStorage.getPadStorageData(conversationId) - ?: throw IllegalStateException("Pad not found for conversation $conversationId") + val storageData = + conversationStorage.getPadStorageData(conversationId) + ?: throw IllegalStateException("Pad not found for conversation $conversationId") val padBytes = android.util.Base64.decode(storageData.bytes, android.util.Base64.NO_WRAP) // Create Rust Pad with state (matching iOS: Pad.fromBytesWithState) - val pad = Pad.fromBytesWithState( - padBytes.map { it.toUByte() }, - storageData.consumedFront.toULong(), - storageData.consumedBack.toULong() - ) + val pad = + Pad.fromBytesWithState( + padBytes.map { it.toUByte() }, + storageData.consumedFront.toULong(), + storageData.consumedBack.toULong() + ) - Log.d(TAG, "Loaded pad for ${conversationId.take(8)}: front=${storageData.consumedFront}, back=${storageData.consumedBack}") + Log.d( + TAG, + "Loaded pad for ${conversationId.take( + 8 + )}: front=${storageData.consumedFront}, back=${storageData.consumedBack}" + ) padCache[conversationId] = pad pad @@ -134,16 +139,18 @@ class PadManager @Inject constructor( */ suspend fun consumeForSending(length: Int, role: Role, conversationId: String): ByteArray = mutex.withLock { // Get cached pad or load it (matching iOS pattern) - val pad = padCache[conversationId] ?: run { - val storageData = conversationStorage.getPadStorageData(conversationId) - ?: throw IllegalStateException("Pad not found for conversation $conversationId") - val padBytes = android.util.Base64.decode(storageData.bytes, android.util.Base64.NO_WRAP) - Pad.fromBytesWithState( - padBytes.map { it.toUByte() }, - storageData.consumedFront.toULong(), - storageData.consumedBack.toULong() - ).also { padCache[conversationId] = it } - } + val pad = + padCache[conversationId] ?: run { + val storageData = + conversationStorage.getPadStorageData(conversationId) + ?: throw IllegalStateException("Pad not found for conversation $conversationId") + val padBytes = android.util.Base64.decode(storageData.bytes, android.util.Base64.NO_WRAP) + Pad.fromBytesWithState( + padBytes.map { it.toUByte() }, + storageData.consumedFront.toULong(), + storageData.consumedBack.toULong() + ).also { padCache[conversationId] = it } + } Log.d(TAG, "Consuming $length bytes for sending (role=$role, conv=${conversationId.take(8)})") @@ -165,16 +172,18 @@ class PadManager @Inject constructor( */ suspend fun updatePeerConsumption(peerRole: Role, consumed: Long, conversationId: String) = mutex.withLock { // Get cached pad or load it (matching iOS pattern) - val pad = padCache[conversationId] ?: run { - val storageData = conversationStorage.getPadStorageData(conversationId) - ?: throw IllegalStateException("Pad not found for conversation $conversationId") - val padBytes = android.util.Base64.decode(storageData.bytes, android.util.Base64.NO_WRAP) - Pad.fromBytesWithState( - padBytes.map { it.toUByte() }, - storageData.consumedFront.toULong(), - storageData.consumedBack.toULong() - ).also { padCache[conversationId] = it } - } + val pad = + padCache[conversationId] ?: run { + val storageData = + conversationStorage.getPadStorageData(conversationId) + ?: throw IllegalStateException("Pad not found for conversation $conversationId") + val padBytes = android.util.Base64.decode(storageData.bytes, android.util.Base64.NO_WRAP) + Pad.fromBytesWithState( + padBytes.map { it.toUByte() }, + storageData.consumedFront.toULong(), + storageData.consumedBack.toULong() + ).also { padCache[conversationId] = it } + } pad.updatePeerConsumption(peerRole, consumed.toULong()) @@ -225,16 +234,18 @@ class PadManager @Inject constructor( */ suspend fun zeroPadBytes(offset: Long, length: Int, conversationId: String) = mutex.withLock { // Get cached pad or load it (matching iOS pattern) - val pad = padCache[conversationId] ?: run { - val storageData = conversationStorage.getPadStorageData(conversationId) - ?: throw IllegalStateException("Pad not found for conversation $conversationId") - val padBytes = android.util.Base64.decode(storageData.bytes, android.util.Base64.NO_WRAP) - Pad.fromBytesWithState( - padBytes.map { it.toUByte() }, - storageData.consumedFront.toULong(), - storageData.consumedBack.toULong() - ).also { padCache[conversationId] = it } - } + val pad = + padCache[conversationId] ?: run { + val storageData = + conversationStorage.getPadStorageData(conversationId) + ?: throw IllegalStateException("Pad not found for conversation $conversationId") + val padBytes = android.util.Base64.decode(storageData.bytes, android.util.Base64.NO_WRAP) + Pad.fromBytesWithState( + padBytes.map { it.toUByte() }, + storageData.consumedFront.toULong(), + storageData.consumedBack.toULong() + ).also { padCache[conversationId] = it } + } val success = pad.zeroBytesAt(offset.toULong(), length.toULong()) 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 d6c0782..5b9c4be 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 @@ -4,6 +4,7 @@ import android.graphics.Bitmap import android.graphics.Color import android.util.Base64 import android.util.Log +import androidx.core.graphics.createBitmap import com.google.zxing.BarcodeFormat import com.google.zxing.EncodeHintType import com.google.zxing.qrcode.QRCodeWriter @@ -13,7 +14,6 @@ import javax.inject.Singleton @Singleton class QRCodeService @Inject constructor() { - companion object { private const val TAG = "QRCodeService" } @@ -46,11 +46,12 @@ class QRCodeService @Inject constructor() { 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 "ISO-8859-1" // Binary-safe encoding - ) + val hints = + mapOf( + EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.L, + EncodeHintType.MARGIN to 1, + EncodeHintType.CHARACTER_SET to "ISO-8859-1" + ) val bitMatrix = writer.encode(base64, BarcodeFormat.QR_CODE, size, size, hints) @@ -67,10 +68,10 @@ class QRCodeService @Inject constructor() { } // Use ARGB_8888 for better quality - val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val 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") + 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) @@ -92,10 +93,11 @@ class QRCodeService @Inject constructor() { } val writer = QRCodeWriter() - val hints = mapOf( - EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.L, - EncodeHintType.MARGIN to 1 - ) + val hints = + mapOf( + EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.L, + EncodeHintType.MARGIN to 1 + ) // Let ZXing determine optimal size val bitMatrix = writer.encode(base64, BarcodeFormat.QR_CODE, 0, 0, hints) @@ -111,7 +113,7 @@ class QRCodeService @Inject constructor() { } } - val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565) + val bitmap = createBitmap(width, height, Bitmap.Config.RGB_565) bitmap.setPixels(pixels, 0, width, 0, 0, width, height) bitmap } catch (e: Exception) { @@ -124,14 +126,12 @@ class QRCodeService @Inject constructor() { * Decode base64 string from QR code back to raw bytes. * Uses DEFAULT flag for maximum compatibility with iOS base64 encoding. */ - fun decodeBase64(base64String: String): ByteArray? { - return try { - // Use DEFAULT for decoding - it's more permissive and handles - // both with/without line breaks and padding variations - Base64.decode(base64String, Base64.DEFAULT) - } catch (e: Exception) { - Log.e(TAG, "Failed to decode base64: ${e.message}") - null - } + fun decodeBase64(base64String: String): ByteArray? = try { + // Use DEFAULT for decoding - it's more permissive and handles + // both with/without line breaks and padding variations + Base64.decode(base64String, Base64.DEFAULT) + } catch (e: Exception) { + Log.e(TAG, "Failed to decode base64: ${e.message}") + null } } 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 23b9561..401fae2 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 @@ -13,14 +13,14 @@ import io.ktor.client.statement.HttpResponse import io.ktor.http.ContentType import io.ktor.http.contentType import io.ktor.http.isSuccess +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import javax.inject.Inject -import javax.inject.Singleton // === Request DTOs (matching iOS) === @@ -35,10 +35,7 @@ data class SubmitMessageRequest( ) @Serializable -data class SubmitMessageResponse( - val accepted: Boolean, - @SerialName("blob_id") val blobId: String -) +data class SubmitMessageResponse(val accepted: Boolean, @SerialName("blob_id") val blobId: String) @Serializable data class AckMessageRequest( @@ -47,9 +44,7 @@ data class AckMessageRequest( ) @Serializable -data class AckMessageResponse( - val acknowledged: Int -) +data class AckMessageResponse(val acknowledged: Int) @Serializable data class RegisterConversationRequest( @@ -72,18 +67,12 @@ data class BurnConversationRequest( ) @Serializable -data class BurnStatusResponse( - val burned: Boolean, - @SerialName("burned_at") val burnedAt: String? = null -) +data class BurnStatusResponse(val burned: Boolean, @SerialName("burned_at") val burnedAt: String? = null) // === Response DTOs === @Serializable -data class HealthResponse( - val status: String, - val version: String? = null -) +data class HealthResponse(val status: String, val version: String? = null) @Serializable data class PollMessageItem( @@ -109,11 +98,7 @@ data class ConnectionTestResult( val error: String? = null ) -data class SendResult( - val success: Boolean, - val blobId: String? = null, - val error: String? = null -) +data class SendResult(val success: Boolean, val blobId: String? = null, val error: String? = null) data class PollResult( val success: Boolean, @@ -123,12 +108,7 @@ data class PollResult( val error: String? = null ) -data class ReceivedMessage( - val id: String, - val ciphertext: ByteArray, - val sequence: Long?, - val receivedAt: String -) { +data class ReceivedMessage(val id: String, val ciphertext: ByteArray, val sequence: Long?, val receivedAt: String) { override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false @@ -201,20 +181,22 @@ class RelayService @Inject constructor( Log.d(TAG, "[$id] Submitting: ${ciphertext.size} bytes, seq=${sequence ?: 0}, ttl=${ttlSeconds ?: 0}s") - val request = SubmitMessageRequest( - conversationId = conversationId, - ciphertext = encoded, - sequence = sequence, - ttlSeconds = ttlSeconds, - extendedTTL = extendedTTL, - persistent = persistent - ) - - val response: HttpResponse = httpClient.post("$url/v1/messages") { - header("Authorization", "Bearer $authToken") - contentType(ContentType.Application.Json) - setBody(request) - } + val request = + SubmitMessageRequest( + conversationId = conversationId, + ciphertext = encoded, + sequence = sequence, + ttlSeconds = ttlSeconds, + extendedTTL = extendedTTL, + persistent = persistent + ) + + val response: HttpResponse = + httpClient.post("$url/v1/messages") { + header("Authorization", "Bearer $authToken") + contentType(ContentType.Application.Json) + setBody(request) + } if (response.status.isSuccess()) { val result: SubmitMessageResponse = response.body() @@ -244,22 +226,24 @@ class RelayService @Inject constructor( val id = logId(conversationId) return try { val url = relayUrl ?: settingsService.relayServerUrl.first() - val response: HttpResponse = httpClient.get("$url/v1/messages") { - header("Authorization", "Bearer $authToken") - parameter("conversation_id", conversationId) - cursor?.let { parameter("cursor", it) } - } + val response: HttpResponse = + httpClient.get("$url/v1/messages") { + header("Authorization", "Bearer $authToken") + parameter("conversation_id", conversationId) + cursor?.let { parameter("cursor", it) } + } if (response.status.isSuccess()) { val result: PollMessagesResponse = response.body() - val receivedMessages = result.messages.map { msg -> - ReceivedMessage( - id = msg.id, - ciphertext = Base64.decode(msg.ciphertext, Base64.DEFAULT), - sequence = msg.sequence, - receivedAt = msg.receivedAt - ) - } + val receivedMessages = + result.messages.map { msg -> + ReceivedMessage( + id = msg.id, + ciphertext = Base64.decode(msg.ciphertext, Base64.DEFAULT), + sequence = msg.sequence, + receivedAt = msg.receivedAt + ) + } if (result.messages.isNotEmpty()) { Log.d(TAG, "[$id] Poll returned ${result.messages.size} messages, burned=${result.burned}") } @@ -310,16 +294,18 @@ class RelayService @Inject constructor( Log.d(TAG, "[$id] Acknowledging ${blobIds.size} messages") - val request = AckMessageRequest( - conversationId = conversationId, - blobIds = blobIds - ) + val request = + AckMessageRequest( + conversationId = conversationId, + blobIds = blobIds + ) - val response: HttpResponse = httpClient.post("$url/v1/messages/ack") { - header("Authorization", "Bearer $authToken") - contentType(ContentType.Application.Json) - setBody(request) - } + val response: HttpResponse = + httpClient.post("$url/v1/messages/ack") { + header("Authorization", "Bearer $authToken") + contentType(ContentType.Application.Json) + setBody(request) + } if (response.status.isSuccess()) { val result: AckMessageResponse = response.body() @@ -347,16 +333,18 @@ class RelayService @Inject constructor( Log.d(TAG, "[$id] Registering conversation with relay") - val request = RegisterConversationRequest( - conversationId = conversationId, - authTokenHash = authTokenHash, - burnTokenHash = burnTokenHash - ) + val request = + RegisterConversationRequest( + conversationId = conversationId, + authTokenHash = authTokenHash, + burnTokenHash = burnTokenHash + ) - val response: HttpResponse = httpClient.post("$url/v1/conversations") { - contentType(ContentType.Application.Json) - setBody(request) - } + val response: HttpResponse = + httpClient.post("$url/v1/conversations") { + contentType(ContentType.Application.Json) + setBody(request) + } if (response.status.isSuccess()) { Log.d(TAG, "[$id] Conversation registered successfully") @@ -383,17 +371,19 @@ class RelayService @Inject constructor( Log.d(TAG, "[$id] Registering device for push notifications") - val request = RegisterDeviceRequest( - conversationId = conversationId, - deviceToken = deviceToken, - platform = "android" - ) + val request = + RegisterDeviceRequest( + conversationId = conversationId, + deviceToken = deviceToken, + platform = "android" + ) - val response: HttpResponse = httpClient.post("$url/v1/register") { - header("Authorization", "Bearer $authToken") - contentType(ContentType.Application.Json) - setBody(request) - } + val response: HttpResponse = + httpClient.post("$url/v1/register") { + header("Authorization", "Bearer $authToken") + contentType(ContentType.Application.Json) + setBody(request) + } if (response.status.isSuccess()) { Log.d(TAG, "[$id] Device registered successfully") @@ -408,26 +398,24 @@ class RelayService @Inject constructor( // === Burn Conversation (matching iOS: POST /v1/burn) === - suspend fun burnConversation( - conversationId: String, - burnToken: String, - relayUrl: String? = null - ): Result { + suspend fun burnConversation(conversationId: String, burnToken: String, relayUrl: String? = null): Result { val id = logId(conversationId) return try { val url = relayUrl ?: settingsService.relayServerUrl.first() Log.w(TAG, "[$id] Burning conversation on relay") - val request = BurnConversationRequest( - conversationId = conversationId, - burnToken = burnToken - ) + val request = + BurnConversationRequest( + conversationId = conversationId, + burnToken = burnToken + ) - val response: HttpResponse = httpClient.post("$url/v1/burn") { - contentType(ContentType.Application.Json) - setBody(request) - } + val response: HttpResponse = + httpClient.post("$url/v1/burn") { + contentType(ContentType.Application.Json) + setBody(request) + } if (response.status.isSuccess()) { Log.w(TAG, "[$id] Conversation burned on relay") @@ -449,10 +437,11 @@ class RelayService @Inject constructor( ): Result { return try { val url = relayUrl ?: settingsService.relayServerUrl.first() - val response: HttpResponse = httpClient.get("$url/v1/burn") { - header("Authorization", "Bearer $authToken") - parameter("conversation_id", conversationId) - } + val response: HttpResponse = + httpClient.get("$url/v1/burn") { + header("Authorization", "Bearer $authToken") + parameter("conversation_id", conversationId) + } if (response.status.isSuccess()) { Result.success(response.body()) } else { diff --git a/apps/android/app/src/main/java/com/monadial/ash/core/services/SSEService.kt b/apps/android/app/src/main/java/com/monadial/ash/core/services/SSEService.kt index b7daf06..083cd08 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/core/services/SSEService.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/core/services/SSEService.kt @@ -1,6 +1,12 @@ package com.monadial.ash.core.services import android.util.Log +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.HttpURLConnection +import java.net.URL +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -17,38 +23,35 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -import java.io.BufferedReader -import java.io.InputStreamReader -import java.net.HttpURLConnection -import java.net.URL -import javax.inject.Inject -import javax.inject.Singleton sealed class SSEEvent { /** New message received (matching iOS SSEMessageEvent) */ - data class MessageReceived( - val id: String, - val sequence: Long?, - val ciphertext: ByteArray, - val receivedAt: String - ) : SSEEvent() { + data class MessageReceived(val id: String, val sequence: Long?, val ciphertext: ByteArray, val receivedAt: String) : + SSEEvent() { override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false other as MessageReceived return id == other.id && ciphertext.contentEquals(other.ciphertext) } + override fun hashCode(): Int = id.hashCode() } /** Delivery confirmation (matching iOS SSEDeliveredEvent) */ data class DeliveryConfirmed(val blobIds: List, val deliveredAt: String) : SSEEvent() + data class BurnSignal(val burnedAt: String) : SSEEvent() + data object Ping : SSEEvent() + data class Error(val message: String) : SSEEvent() + /** Conversation not found on relay - needs registration */ data object NotFound : SSEEvent() + data object Connected : SSEEvent() + data object Disconnected : SSEEvent() } @@ -96,11 +99,7 @@ class SSEService @Inject constructor() { private var currentAuthToken: String? = null private var retryAttempts = 0 - fun connect( - relayUrl: String, - conversationId: String, - authToken: String - ) { + fun connect(relayUrl: String, conversationId: String, authToken: String) { // Disconnect existing connection if different conversation if (currentConversationId != conversationId) { disconnect() @@ -116,9 +115,10 @@ class SSEService @Inject constructor() { private fun startConnection() { connectionJob?.cancel() - connectionJob = scope.launch { - connectInternal() - } + connectionJob = + scope.launch { + connectInternal() + } } private suspend fun connectInternal() { @@ -133,17 +133,18 @@ class SSEService @Inject constructor() { // URL format matching iOS: {baseURL}/v1/messages/stream?conversation_id={conversationId} val url = URL("$relayUrl/v1/messages/stream?conversation_id=$conversationId") Log.d(TAG, "Connecting to SSE: $url") - connection = withContext(Dispatchers.IO) { - (url.openConnection() as HttpURLConnection).apply { - requestMethod = "GET" - setRequestProperty("Authorization", "Bearer $authToken") - setRequestProperty("Accept", "text/event-stream") - setRequestProperty("Cache-Control", "no-cache") - connectTimeout = 10000 - readTimeout = 0 // No timeout for SSE - doInput = true + connection = + withContext(Dispatchers.IO) { + (url.openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + setRequestProperty("Authorization", "Bearer $authToken") + setRequestProperty("Accept", "text/event-stream") + setRequestProperty("Cache-Control", "no-cache") + connectTimeout = 10000 + readTimeout = 0 // No timeout for SSE + doInput = true + } } - } val responseCode = connection.responseCode if (responseCode == HttpURLConnection.HTTP_NOT_FOUND) { @@ -151,10 +152,10 @@ class SSEService @Inject constructor() { Log.w(TAG, "SSE connection returned 404 - conversation not found on relay") _events.emit(SSEEvent.NotFound) _connectionState.value = SSEConnectionState.DISCONNECTED - return // Don't retry automatically - let caller handle registration + return // Don't retry automatically - let caller handle registration } if (responseCode != HttpURLConnection.HTTP_OK) { - throw Exception("SSE connection failed with code: $responseCode") + throw java.io.IOException("SSE connection failed with code: $responseCode") } _connectionState.value = SSEConnectionState.CONNECTED @@ -166,9 +167,10 @@ class SSEService @Inject constructor() { val dataBuilder = StringBuilder() while (scope.isActive) { - val line = withContext(Dispatchers.IO) { - reader.readLine() - } ?: break + val line = + withContext(Dispatchers.IO) { + reader.readLine() + } ?: break when { line.startsWith("event:") -> { @@ -200,10 +202,11 @@ class SSEService @Inject constructor() { // Attempt reconnection with exponential backoff if (scope.isActive && retryAttempts < MAX_RETRY_ATTEMPTS) { retryAttempts++ - val delayMs = minOf( - INITIAL_RETRY_DELAY_MS * (1 shl (retryAttempts - 1)), - MAX_RETRY_DELAY_MS - ) + val delayMs = + minOf( + INITIAL_RETRY_DELAY_MS * (1 shl (retryAttempts - 1)), + MAX_RETRY_DELAY_MS + ) Log.d(TAG, "Reconnecting in ${delayMs}ms (attempt $retryAttempts)") _connectionState.value = SSEConnectionState.RECONNECTING delay(delayMs) @@ -223,10 +226,11 @@ class SSEService @Inject constructor() { val receivedAt = rawEvent.received_at if (id != null && ciphertextBase64 != null && receivedAt != null) { - val ciphertext = android.util.Base64.decode( - ciphertextBase64, - android.util.Base64.DEFAULT - ) + val ciphertext = + android.util.Base64.decode( + ciphertextBase64, + android.util.Base64.DEFAULT + ) Log.d(TAG, "Received message: ${ciphertext.size} bytes, seq=${rawEvent.sequence ?: 0}") _events.emit( SSEEvent.MessageReceived( @@ -277,6 +281,5 @@ class SSEService @Inject constructor() { fun isConnected(): Boolean = _connectionState.value == SSEConnectionState.CONNECTED - fun isConnectedTo(conversationId: String): Boolean = - isConnected() && currentConversationId == conversationId + fun isConnectedTo(conversationId: String): Boolean = isConnected() && currentConversationId == conversationId } diff --git a/apps/android/app/src/main/java/com/monadial/ash/core/services/SettingsService.kt b/apps/android/app/src/main/java/com/monadial/ash/core/services/SettingsService.kt index 4da1f3f..6f051eb 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/core/services/SettingsService.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/core/services/SettingsService.kt @@ -9,21 +9,19 @@ import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.monadial.ash.BuildConfig import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map -import javax.inject.Inject -import javax.inject.Singleton private val Context.dataStore: DataStore by preferencesDataStore(name = "ash_settings") @Singleton -class SettingsService @Inject constructor( - @ApplicationContext private val context: Context -) { +class SettingsService @Inject constructor(@ApplicationContext private val context: Context) { private object Keys { val BIOMETRIC_ENABLED = booleanPreferencesKey("biometric_enabled") val LOCK_ON_BACKGROUND = booleanPreferencesKey("lock_on_background") @@ -37,14 +35,17 @@ class SettingsService @Inject constructor( private val _shouldLock = MutableStateFlow(false) val shouldLock: StateFlow = _shouldLock.asStateFlow() - val lockOnBackground: Flow = context.dataStore.data - .map { preferences -> preferences[Keys.LOCK_ON_BACKGROUND] ?: true } + val lockOnBackground: Flow = + context.dataStore.data + .map { preferences -> preferences[Keys.LOCK_ON_BACKGROUND] ?: true } - val relayServerUrl: Flow = context.dataStore.data - .map { preferences -> preferences[Keys.RELAY_SERVER_URL] ?: BuildConfig.DEFAULT_RELAY_URL } + val relayServerUrl: Flow = + context.dataStore.data + .map { preferences -> preferences[Keys.RELAY_SERVER_URL] ?: BuildConfig.DEFAULT_RELAY_URL } - val defaultExtendedTtl: Flow = context.dataStore.data - .map { preferences -> preferences[Keys.DEFAULT_EXTENDED_TTL] ?: false } + val defaultExtendedTtl: Flow = + context.dataStore.data + .map { preferences -> preferences[Keys.DEFAULT_EXTENDED_TTL] ?: false } suspend fun initialize() { _isBiometricEnabled.value = context.dataStore.data.first()[Keys.BIOMETRIC_ENABLED] ?: false @@ -75,13 +76,11 @@ class SettingsService @Inject constructor( } } - suspend fun getRelayServerUrlSync(): String { - return context.dataStore.data.first()[Keys.RELAY_SERVER_URL] ?: BuildConfig.DEFAULT_RELAY_URL - } + suspend fun getRelayServerUrlSync(): String = + context.dataStore.data.first()[Keys.RELAY_SERVER_URL] ?: BuildConfig.DEFAULT_RELAY_URL - suspend fun getRelayUrl(): String { - return context.dataStore.data.first()[Keys.RELAY_SERVER_URL] ?: BuildConfig.DEFAULT_RELAY_URL - } + suspend fun getRelayUrl(): String = + context.dataStore.data.first()[Keys.RELAY_SERVER_URL] ?: BuildConfig.DEFAULT_RELAY_URL fun triggerLock() { _shouldLock.value = true diff --git a/apps/android/app/src/main/java/com/monadial/ash/data/repositories/ConversationRepositoryImpl.kt b/apps/android/app/src/main/java/com/monadial/ash/data/repositories/ConversationRepositoryImpl.kt new file mode 100644 index 0000000..50dc5ab --- /dev/null +++ b/apps/android/app/src/main/java/com/monadial/ash/data/repositories/ConversationRepositoryImpl.kt @@ -0,0 +1,117 @@ +package com.monadial.ash.data.repositories + +import com.monadial.ash.core.common.AppError +import com.monadial.ash.core.common.AppResult +import com.monadial.ash.core.services.ConversationStorageService +import com.monadial.ash.domain.entities.Conversation +import com.monadial.ash.domain.repositories.ConversationRepository +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.StateFlow + +/** + * Implementation of ConversationRepository using ConversationStorageService. + * Provides a clean interface for conversation data operations. + */ +@Singleton +class ConversationRepositoryImpl @Inject constructor( + private val storageService: ConversationStorageService +) : ConversationRepository { + + override val conversations: StateFlow> + get() = storageService.conversations + + override suspend fun loadConversations(): AppResult> { + return try { + storageService.loadConversations() + AppResult.Success(storageService.conversations.value) + } catch (e: Exception) { + AppResult.Error(AppError.Storage.ReadFailed("Failed to load conversations", e)) + } + } + + override suspend fun getConversation(id: String): AppResult { + return try { + val conversation = storageService.getConversation(id) + if (conversation != null) { + AppResult.Success(conversation) + } else { + AppResult.Error(AppError.Storage.NotFound("Conversation not found: $id")) + } + } catch (e: Exception) { + AppResult.Error(AppError.Storage.ReadFailed("Failed to get conversation", e)) + } + } + + override suspend fun saveConversation(conversation: Conversation): AppResult { + return try { + storageService.saveConversation(conversation) + AppResult.Success(Unit) + } catch (e: Exception) { + AppResult.Error(AppError.Storage.WriteFailed("Failed to save conversation", e)) + } + } + + override suspend fun deleteConversation(id: String): AppResult { + return try { + storageService.deleteConversation(id) + AppResult.Success(Unit) + } catch (e: Exception) { + AppResult.Error(AppError.Storage.WriteFailed("Failed to delete conversation", e)) + } + } + + override suspend fun updateConversation( + id: String, + update: (Conversation) -> Conversation + ): AppResult { + return try { + val existing = storageService.getConversation(id) + ?: return AppResult.Error(AppError.Storage.NotFound("Conversation not found: $id")) + + val updated = update(existing) + storageService.saveConversation(updated) + AppResult.Success(updated) + } catch (e: Exception) { + AppResult.Error(AppError.Storage.WriteFailed("Failed to update conversation", e)) + } + } + + override suspend fun updateLastMessage( + id: String, + preview: String, + timestamp: Long + ): AppResult { + return updateConversation(id) { conversation -> + conversation.copy( + lastMessagePreview = preview, + lastMessageAt = timestamp + ) + }.map { } + } + + override suspend fun updateCursor(id: String, cursor: String?): AppResult { + return updateConversation(id) { conversation -> + conversation.withCursor(cursor) + }.map { } + } + + override suspend fun markPeerBurned(id: String, timestamp: Long): AppResult { + return updateConversation(id) { conversation -> + conversation.copy(peerBurnedAt = timestamp) + }.map { } + } + + override suspend fun updatePadConsumption( + id: String, + consumedFront: Long, + consumedBack: Long + ): AppResult { + return updateConversation(id) { conversation -> + conversation.copy( + padConsumedFront = consumedFront, + padConsumedBack = consumedBack + ) + }.map { } + } +} diff --git a/apps/android/app/src/main/java/com/monadial/ash/data/repositories/PadRepositoryImpl.kt b/apps/android/app/src/main/java/com/monadial/ash/data/repositories/PadRepositoryImpl.kt new file mode 100644 index 0000000..fc39de6 --- /dev/null +++ b/apps/android/app/src/main/java/com/monadial/ash/data/repositories/PadRepositoryImpl.kt @@ -0,0 +1,183 @@ +package com.monadial.ash.data.repositories + +import com.monadial.ash.core.common.AppError +import com.monadial.ash.core.common.AppResult +import com.monadial.ash.core.services.PadManager +import com.monadial.ash.core.services.PadState +import com.monadial.ash.domain.entities.ConversationRole +import com.monadial.ash.domain.repositories.PadRepository +import uniffi.ash.Role +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Implementation of PadRepository using PadManager. + * Provides a clean interface for pad operations. + */ +@Singleton +class PadRepositoryImpl @Inject constructor( + private val padManager: PadManager +) : PadRepository { + + override suspend fun storePad(conversationId: String, padBytes: ByteArray): AppResult { + return try { + padManager.storePad(padBytes, conversationId) + AppResult.Success(Unit) + } catch (e: Exception) { + AppResult.Error(AppError.Pad.ConsumptionFailed("Failed to store pad: ${e.message}")) + } + } + + override suspend fun getPadBytes(conversationId: String): AppResult { + return try { + val bytes = padManager.getPadBytes(conversationId) + AppResult.Success(bytes) + } catch (e: IllegalStateException) { + AppResult.Error(AppError.Pad.NotFound) + } catch (e: Exception) { + AppResult.Error(AppError.Pad.ConsumptionFailed("Failed to get pad bytes: ${e.message}")) + } + } + + override suspend fun getPadState(conversationId: String): AppResult { + return try { + val state = padManager.getPadState(conversationId) + AppResult.Success(state) + } catch (e: IllegalStateException) { + AppResult.Error(AppError.Pad.NotFound) + } catch (e: Exception) { + AppResult.Error(AppError.Pad.ConsumptionFailed("Failed to get pad state: ${e.message}")) + } + } + + override suspend fun canSend( + conversationId: String, + length: Int, + role: ConversationRole + ): AppResult { + return try { + val canSend = padManager.canSend(length, role.toFfiRole(), conversationId) + AppResult.Success(canSend) + } catch (e: IllegalStateException) { + AppResult.Error(AppError.Pad.NotFound) + } catch (e: Exception) { + AppResult.Error(AppError.Pad.ConsumptionFailed("Failed to check send availability: ${e.message}")) + } + } + + override suspend fun availableForSending( + conversationId: String, + role: ConversationRole + ): AppResult { + return try { + val available = padManager.availableForSending(role.toFfiRole(), conversationId) + AppResult.Success(available) + } catch (e: IllegalStateException) { + AppResult.Error(AppError.Pad.NotFound) + } catch (e: Exception) { + AppResult.Error(AppError.Pad.ConsumptionFailed("Failed to get available bytes: ${e.message}")) + } + } + + override suspend fun nextSendOffset( + conversationId: String, + role: ConversationRole + ): AppResult { + return try { + val offset = padManager.nextSendOffset(role.toFfiRole(), conversationId) + AppResult.Success(offset) + } catch (e: IllegalStateException) { + AppResult.Error(AppError.Pad.NotFound) + } catch (e: Exception) { + AppResult.Error(AppError.Pad.ConsumptionFailed("Failed to get next send offset: ${e.message}")) + } + } + + override suspend fun consumeForSending( + conversationId: String, + length: Int, + role: ConversationRole + ): AppResult { + return try { + val keyBytes = padManager.consumeForSending(length, role.toFfiRole(), conversationId) + AppResult.Success(keyBytes) + } catch (e: IllegalStateException) { + if (e.message?.contains("not found") == true) { + AppResult.Error(AppError.Pad.NotFound) + } else { + AppResult.Error(AppError.Pad.Exhausted) + } + } catch (e: Exception) { + AppResult.Error(AppError.Pad.ConsumptionFailed("Failed to consume pad: ${e.message}")) + } + } + + override suspend fun getBytesForDecryption( + conversationId: String, + offset: Long, + length: Int + ): AppResult { + return try { + val bytes = padManager.getBytesForDecryption(offset, length, conversationId) + AppResult.Success(bytes) + } catch (e: IllegalStateException) { + if (e.message?.contains("not found") == true) { + AppResult.Error(AppError.Pad.NotFound) + } else { + AppResult.Error(AppError.Pad.InvalidState) + } + } catch (e: Exception) { + AppResult.Error(AppError.Pad.ConsumptionFailed("Failed to get decryption bytes: ${e.message}")) + } + } + + override suspend fun updatePeerConsumption( + conversationId: String, + peerRole: ConversationRole, + consumed: Long + ): AppResult { + return try { + padManager.updatePeerConsumption(peerRole.toFfiRole(), consumed, conversationId) + AppResult.Success(Unit) + } catch (e: IllegalStateException) { + AppResult.Error(AppError.Pad.NotFound) + } catch (e: Exception) { + AppResult.Error(AppError.Pad.ConsumptionFailed("Failed to update peer consumption: ${e.message}")) + } + } + + override suspend fun zeroPadBytes( + conversationId: String, + offset: Long, + length: Int + ): AppResult { + return try { + padManager.zeroPadBytes(offset, length, conversationId) + AppResult.Success(Unit) + } catch (e: Exception) { + AppResult.Error(AppError.Pad.ConsumptionFailed("Failed to zero pad bytes: ${e.message}")) + } + } + + override suspend fun wipePad(conversationId: String): AppResult { + return try { + padManager.wipePad(conversationId) + AppResult.Success(Unit) + } catch (e: Exception) { + AppResult.Error(AppError.Pad.ConsumptionFailed("Failed to wipe pad: ${e.message}")) + } + } + + override fun invalidateCache(conversationId: String) { + padManager.invalidateCache(conversationId) + } + + override fun clearCache() { + padManager.clearCache() + } + + private fun ConversationRole.toFfiRole(): Role = when (this) { + ConversationRole.INITIATOR -> Role.INITIATOR + ConversationRole.RESPONDER -> Role.RESPONDER + } +} diff --git a/apps/android/app/src/main/java/com/monadial/ash/data/repositories/SettingsRepositoryImpl.kt b/apps/android/app/src/main/java/com/monadial/ash/data/repositories/SettingsRepositoryImpl.kt new file mode 100644 index 0000000..604db6f --- /dev/null +++ b/apps/android/app/src/main/java/com/monadial/ash/data/repositories/SettingsRepositoryImpl.kt @@ -0,0 +1,73 @@ +package com.monadial.ash.data.repositories + +import com.monadial.ash.core.common.AppError +import com.monadial.ash.core.common.AppResult +import com.monadial.ash.core.services.SettingsService +import com.monadial.ash.domain.repositories.SettingsRepository +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first + +/** + * Implementation of SettingsRepository using SettingsService. + * Provides a clean interface for settings operations. + */ +@Singleton +class SettingsRepositoryImpl @Inject constructor( + private val settingsService: SettingsService +) : SettingsRepository { + + override val relayServerUrl: Flow + get() = settingsService.relayServerUrl + + override val isBiometricEnabled: StateFlow + get() = settingsService.isBiometricEnabled + + override val lockOnBackground: Flow + get() = settingsService.lockOnBackground + + override suspend fun getRelayUrl(): String { + return settingsService.getRelayUrl() + } + + override suspend fun setRelayUrl(url: String): AppResult { + return try { + settingsService.setRelayServerUrl(url) + AppResult.Success(Unit) + } catch (e: Exception) { + AppResult.Error(AppError.Storage.WriteFailed("Failed to save relay URL", e)) + } + } + + override fun getDefaultRelayUrl(): String { + return com.monadial.ash.BuildConfig.DEFAULT_RELAY_URL + } + + override suspend fun getBiometricEnabled(): Boolean { + return settingsService.isBiometricEnabled.value + } + + override suspend fun setBiometricEnabled(enabled: Boolean): AppResult { + return try { + settingsService.setBiometricEnabled(enabled) + AppResult.Success(Unit) + } catch (e: Exception) { + AppResult.Error(AppError.Storage.WriteFailed("Failed to save biometric setting", e)) + } + } + + override suspend fun getLockOnBackground(): Boolean { + return settingsService.lockOnBackground.first() + } + + override suspend fun setLockOnBackground(enabled: Boolean): AppResult { + return try { + settingsService.setLockOnBackground(enabled) + AppResult.Success(Unit) + } catch (e: Exception) { + AppResult.Error(AppError.Storage.WriteFailed("Failed to save lock setting", e)) + } + } +} diff --git a/apps/android/app/src/main/java/com/monadial/ash/data/services/CryptoServiceImpl.kt b/apps/android/app/src/main/java/com/monadial/ash/data/services/CryptoServiceImpl.kt new file mode 100644 index 0000000..3f77b14 --- /dev/null +++ b/apps/android/app/src/main/java/com/monadial/ash/data/services/CryptoServiceImpl.kt @@ -0,0 +1,97 @@ +package com.monadial.ash.data.services + +import com.monadial.ash.core.services.AshCoreService +import com.monadial.ash.domain.services.CryptoService +import uniffi.ash.AuthTokens +import uniffi.ash.CeremonyMetadata +import uniffi.ash.FountainFrameGenerator +import uniffi.ash.FountainFrameReceiver +import uniffi.ash.Pad +import uniffi.ash.PadSize +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Implementation of CryptoService that delegates to AshCoreService. + * This is the trusted cryptographic authority. + */ +@Singleton +class CryptoServiceImpl @Inject constructor( + private val ashCoreService: AshCoreService +) : CryptoService { + + override fun createFountainGenerator( + metadata: CeremonyMetadata, + padBytes: ByteArray, + blockSize: UInt, + passphrase: String? + ): FountainFrameGenerator { + return ashCoreService.createFountainGenerator(metadata, padBytes, blockSize, passphrase) + } + + override fun createFountainReceiver(passphrase: String?): FountainFrameReceiver { + return ashCoreService.createFountainReceiver(passphrase) + } + + override fun generateMnemonic(padBytes: ByteArray): List { + return ashCoreService.generateMnemonic(padBytes) + } + + override fun deriveAllTokens(padBytes: ByteArray): AuthTokens { + return ashCoreService.deriveAllTokens(padBytes) + } + + override fun deriveConversationId(padBytes: ByteArray): String { + return ashCoreService.deriveConversationId(padBytes) + } + + override fun deriveAuthToken(padBytes: ByteArray): String { + return ashCoreService.deriveAuthToken(padBytes) + } + + override fun deriveBurnToken(padBytes: ByteArray): String { + return ashCoreService.deriveBurnToken(padBytes) + } + + override fun encrypt(key: ByteArray, plaintext: ByteArray): ByteArray { + return ashCoreService.encrypt(key, plaintext) + } + + override fun decrypt(key: ByteArray, ciphertext: ByteArray): ByteArray { + return ashCoreService.decrypt(key, ciphertext) + } + + override fun validatePassphrase(passphrase: String): Boolean { + return ashCoreService.validatePassphrase(passphrase) + } + + override fun createPadFromEntropy(entropy: ByteArray, size: PadSize): Pad { + return ashCoreService.createPadFromEntropy(entropy, size) + } + + override fun createPadFromBytes(bytes: ByteArray): Pad { + return ashCoreService.createPadFromBytes(bytes) + } + + override fun createPadFromBytesWithState( + bytes: ByteArray, + consumedFront: ULong, + consumedBack: ULong + ): Pad { + return ashCoreService.createPadFromBytesWithState(bytes, consumedFront, consumedBack) + } + + override fun hashToken(token: String): String { + val digest = java.security.MessageDigest.getInstance("SHA-256") + val hashBytes = digest.digest(token.toByteArray(Charsets.UTF_8)) + return hashBytes.joinToString("") { String.format("%02x", it) } + } + + override fun generateFrameBytes(generator: FountainFrameGenerator, index: UInt): ByteArray { + return with(ashCoreService) { generator.generateFrameBytes(index) } + } + + override fun addFrameBytes(receiver: FountainFrameReceiver, frameBytes: ByteArray): Boolean { + return with(ashCoreService) { receiver.addFrameBytes(frameBytes) } + } +} diff --git a/apps/android/app/src/main/java/com/monadial/ash/data/services/LocationServiceImpl.kt b/apps/android/app/src/main/java/com/monadial/ash/data/services/LocationServiceImpl.kt new file mode 100644 index 0000000..c4984a4 --- /dev/null +++ b/apps/android/app/src/main/java/com/monadial/ash/data/services/LocationServiceImpl.kt @@ -0,0 +1,157 @@ +package com.monadial.ash.data.services + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.location.Location +import android.os.Looper +import androidx.core.content.ContextCompat +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationCallback +import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority +import com.monadial.ash.domain.services.LocationError +import com.monadial.ash.domain.services.LocationResult +import com.monadial.ash.domain.services.LocationService +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.suspendCancellableCoroutine + +/** + * Android implementation of LocationService using Google Play Services. + */ +@Singleton +class LocationServiceImpl @Inject constructor( + @ApplicationContext private val context: Context +) : LocationService { + + private val fusedLocationClient: FusedLocationProviderClient = + LocationServices.getFusedLocationProviderClient(context) + + override val hasLocationPermission: Boolean + get() = ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + + override val hasCoarseLocationPermission: Boolean + get() = ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_COARSE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + + override suspend fun getCurrentLocation(): Result { + if (!hasLocationPermission && !hasCoarseLocationPermission) { + return Result.failure(LocationError.PermissionDenied) + } + + return try { + val location = getLastKnownLocation() ?: requestFreshLocation() + if (location != null) { + Result.success( + LocationResult( + latitude = location.latitude, + longitude = location.longitude + ) + ) + } else { + Result.failure(LocationError.Unavailable) + } + } catch (e: SecurityException) { + Result.failure(LocationError.PermissionDenied) + } catch (e: Exception) { + Result.failure(e) + } + } + + override fun observeLocationUpdates(): Flow = callbackFlow { + if (!hasLocationPermission && !hasCoarseLocationPermission) { + close(LocationError.PermissionDenied) + return@callbackFlow + } + + val locationRequest = LocationRequest.Builder( + Priority.PRIORITY_HIGH_ACCURACY, + 10000L + ).build() + + val callback = object : LocationCallback() { + override fun onLocationResult(result: com.google.android.gms.location.LocationResult) { + result.lastLocation?.let { location -> + trySend( + LocationResult( + latitude = location.latitude, + longitude = location.longitude + ) + ) + } + } + } + + try { + fusedLocationClient.requestLocationUpdates( + locationRequest, + callback, + Looper.getMainLooper() + ) + } catch (e: SecurityException) { + close(e) + } + + awaitClose { + fusedLocationClient.removeLocationUpdates(callback) + } + } + + private suspend fun getLastKnownLocation(): Location? = suspendCancellableCoroutine { continuation -> + try { + fusedLocationClient.lastLocation + .addOnSuccessListener { location -> + continuation.resume(location) + } + .addOnFailureListener { e -> + continuation.resumeWithException(e) + } + } catch (e: SecurityException) { + continuation.resumeWithException(e) + } + } + + private suspend fun requestFreshLocation(): Location? = suspendCancellableCoroutine { continuation -> + val locationRequest = LocationRequest.Builder( + Priority.PRIORITY_HIGH_ACCURACY, + 1000L + ) + .setMaxUpdates(1) + .setWaitForAccurateLocation(true) + .build() + + val callback = object : LocationCallback() { + override fun onLocationResult(result: com.google.android.gms.location.LocationResult) { + fusedLocationClient.removeLocationUpdates(this) + continuation.resume(result.lastLocation) + } + } + + try { + fusedLocationClient.requestLocationUpdates( + locationRequest, + callback, + Looper.getMainLooper() + ) + + continuation.invokeOnCancellation { + fusedLocationClient.removeLocationUpdates(callback) + } + } catch (e: SecurityException) { + continuation.resumeWithException(e) + } + } +} diff --git a/apps/android/app/src/main/java/com/monadial/ash/data/services/QRCodeServiceImpl.kt b/apps/android/app/src/main/java/com/monadial/ash/data/services/QRCodeServiceImpl.kt new file mode 100644 index 0000000..0c4e30f --- /dev/null +++ b/apps/android/app/src/main/java/com/monadial/ash/data/services/QRCodeServiceImpl.kt @@ -0,0 +1,109 @@ +package com.monadial.ash.data.services + +import android.graphics.Bitmap +import android.graphics.Color +import android.util.Base64 +import android.util.Log +import androidx.core.graphics.createBitmap +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.qrcode.QRCodeWriter +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel +import com.monadial.ash.domain.services.QRCodeService +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Implementation of QR code generation and decoding service. + * + * Uses ZXing library for QR code generation with optimized settings: + * - L error correction (7%) for maximum capacity (fountain codes provide redundancy) + * - Base64 encoding for QR string compatibility + * - Bulk pixel operations for performance + */ +@Singleton +class QRCodeServiceImpl @Inject constructor() : QRCodeService { + + companion object { + private const val TAG = "QRCodeService" + private const val MAX_BASE64_LENGTH = 2900 + } + + override fun generate(data: ByteArray, size: Int): Bitmap? { + return try { + val base64 = Base64.encodeToString(data, Base64.NO_WRAP) + + if (base64.length > MAX_BASE64_LENGTH) { + 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 "ISO-8859-1" + ) + + val bitMatrix = writer.encode(base64, BarcodeFormat.QR_CODE, size, size, hints) + createBitmapFromMatrix(bitMatrix, Bitmap.Config.ARGB_8888) + } catch (e: Exception) { + Log.e(TAG, "Failed to generate QR code: ${e.message}", e) + null + } + } + + override fun generateCompact(data: ByteArray): Bitmap? { + return try { + val base64 = Base64.encodeToString(data, Base64.NO_WRAP) + + if (base64.length > MAX_BASE64_LENGTH) { + Log.e(TAG, "Data too large for QR code: ${base64.length} chars") + return null + } + + val writer = QRCodeWriter() + val hints = mapOf( + EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.L, + EncodeHintType.MARGIN to 1 + ) + + val bitMatrix = writer.encode(base64, BarcodeFormat.QR_CODE, 0, 0, hints) + createBitmapFromMatrix(bitMatrix, Bitmap.Config.RGB_565) + } catch (e: Exception) { + Log.e(TAG, "Failed to generate compact QR code: ${e.message}", e) + null + } + } + + override fun decodeBase64(base64String: String): ByteArray? = try { + Base64.decode(base64String, Base64.DEFAULT) + } catch (e: Exception) { + Log.e(TAG, "Failed to decode base64: ${e.message}") + null + } + + private fun createBitmapFromMatrix( + bitMatrix: com.google.zxing.common.BitMatrix, + config: Bitmap.Config + ): Bitmap { + val width = bitMatrix.width + val height = bitMatrix.height + + val pixels = IntArray(width * height) + for (y in 0 until height) { + val offset = y * width + for (x in 0 until width) { + pixels[offset + x] = if (bitMatrix[x, y]) Color.BLACK else Color.WHITE + } + } + + val bitmap = createBitmap(width, height, config) + bitmap.setPixels(pixels, 0, width, 0, 0, width, height) + + Log.d(TAG, "QR code generated: ${width}x$height pixels") + return bitmap + } +} diff --git a/apps/android/app/src/main/java/com/monadial/ash/data/services/RealtimeServiceImpl.kt b/apps/android/app/src/main/java/com/monadial/ash/data/services/RealtimeServiceImpl.kt new file mode 100644 index 0000000..169fe92 --- /dev/null +++ b/apps/android/app/src/main/java/com/monadial/ash/data/services/RealtimeServiceImpl.kt @@ -0,0 +1,44 @@ +package com.monadial.ash.data.services + +import com.monadial.ash.core.services.SSEConnectionState +import com.monadial.ash.core.services.SSEEvent +import com.monadial.ash.core.services.SSEService +import com.monadial.ash.domain.services.RealtimeService +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Implementation of RealtimeService that delegates to SSEService. + * Provides real-time message streaming via Server-Sent Events. + */ +@Singleton +class RealtimeServiceImpl @Inject constructor( + private val sseService: SSEService +) : RealtimeService { + + override val connectionState: StateFlow + get() = sseService.connectionState + + override val events: SharedFlow + get() = sseService.events + + override fun connect(relayUrl: String, conversationId: String, authToken: String) { + sseService.connect(relayUrl, conversationId, authToken) + } + + override fun disconnect() { + sseService.disconnect() + } + + override fun isConnectedTo(conversationId: String): Boolean { + return sseService.isConnectedTo(conversationId) + } + + override fun currentConversationId(): String? { + // SSEService doesn't expose this directly, but we can infer from isConnectedTo + // For now, return null as we don't have direct access + return null + } +} diff --git a/apps/android/app/src/main/java/com/monadial/ash/data/services/RelayServiceImpl.kt b/apps/android/app/src/main/java/com/monadial/ash/data/services/RelayServiceImpl.kt new file mode 100644 index 0000000..e20a8b3 --- /dev/null +++ b/apps/android/app/src/main/java/com/monadial/ash/data/services/RelayServiceImpl.kt @@ -0,0 +1,175 @@ +package com.monadial.ash.data.services + +import com.monadial.ash.core.common.AppError +import com.monadial.ash.core.common.AppResult +import com.monadial.ash.core.services.BurnStatusResponse +import com.monadial.ash.core.services.ConnectionTestResult +import com.monadial.ash.core.services.HealthResponse +import com.monadial.ash.core.services.PollResult +import com.monadial.ash.core.services.SendResult +import com.monadial.ash.domain.services.RelayService +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import javax.inject.Singleton +import com.monadial.ash.core.services.RelayService as CoreRelayService + +/** + * Implementation of RelayService that delegates to the core RelayService. + * Converts kotlin.Result to AppResult for consistent error handling. + */ +@Singleton +class RelayServiceImpl @Inject constructor( + private val coreRelayService: CoreRelayService +) : RelayService { + + override suspend fun checkHealth(relayUrl: String?): AppResult { + return coreRelayService.checkHealth(relayUrl).toAppResult() + } + + override suspend fun testConnection(relayUrl: String): ConnectionTestResult { + return coreRelayService.testConnection(relayUrl) + } + + override suspend fun submitMessage( + conversationId: String, + authToken: String, + ciphertext: ByteArray, + sequence: Long?, + ttlSeconds: Long?, + extendedTTL: Boolean, + persistent: Boolean, + relayUrl: String? + ): AppResult { + val result = coreRelayService.submitMessage( + conversationId = conversationId, + authToken = authToken, + ciphertext = ciphertext, + sequence = sequence, + ttlSeconds = ttlSeconds, + extendedTTL = extendedTTL, + persistent = persistent, + relayUrl = relayUrl + ) + return if (result.success) { + AppResult.Success(result) + } else { + AppResult.Error( + AppError.Relay.SubmitFailed(result.error ?: "Unknown submit error") + ) + } + } + + override suspend fun fetchMessages( + conversationId: String, + authToken: String, + cursor: String?, + relayUrl: String? + ): AppResult { + val result = coreRelayService.fetchMessages( + conversationId = conversationId, + authToken = authToken, + cursor = cursor, + relayUrl = relayUrl + ) + return if (result.success) { + if (result.burned) { + AppResult.Error(AppError.Relay.ConversationBurned) + } else { + AppResult.Success(result) + } + } else { + AppResult.Error( + AppError.Network.ConnectionFailed(result.error ?: "Failed to fetch messages") + ) + } + } + + override fun pollMessages( + conversationId: String, + authToken: String, + cursor: String?, + relayUrl: String? + ): Flow { + return coreRelayService.pollMessages(conversationId, authToken, cursor, relayUrl) + } + + override suspend fun acknowledgeMessages( + conversationId: String, + authToken: String, + blobIds: List, + relayUrl: String? + ): AppResult { + return coreRelayService.acknowledgeMessages( + conversationId = conversationId, + authToken = authToken, + blobIds = blobIds, + relayUrl = relayUrl + ).toAppResult() + } + + override suspend fun registerConversation( + conversationId: String, + authTokenHash: String, + burnTokenHash: String, + relayUrl: String? + ): AppResult { + return coreRelayService.registerConversation( + conversationId = conversationId, + authTokenHash = authTokenHash, + burnTokenHash = burnTokenHash, + relayUrl = relayUrl + ).fold( + onSuccess = { AppResult.Success(Unit) }, + onFailure = { AppResult.Error(AppError.Relay.RegistrationFailed) } + ) + } + + override suspend fun registerDevice( + conversationId: String, + authToken: String, + deviceToken: String, + relayUrl: String? + ): AppResult { + return coreRelayService.registerDevice( + conversationId = conversationId, + authToken = authToken, + deviceToken = deviceToken, + relayUrl = relayUrl + ).toAppResult() + } + + override suspend fun burnConversation( + conversationId: String, + burnToken: String, + relayUrl: String? + ): AppResult { + return coreRelayService.burnConversation( + conversationId = conversationId, + burnToken = burnToken, + relayUrl = relayUrl + ).toAppResult() + } + + override suspend fun checkBurnStatus( + conversationId: String, + authToken: String, + relayUrl: String? + ): AppResult { + return coreRelayService.checkBurnStatus( + conversationId = conversationId, + authToken = authToken, + relayUrl = relayUrl + ).toAppResult() + } + + override fun hashToken(token: String): String { + return coreRelayService.hashToken(token) + } + + private fun Result.toAppResult(): AppResult { + return fold( + onSuccess = { AppResult.Success(it) }, + onFailure = { AppResult.Error(AppError.fromException(it)) } + ) + } +} diff --git a/apps/android/app/src/main/java/com/monadial/ash/di/AppModule.kt b/apps/android/app/src/main/java/com/monadial/ash/di/AppModule.kt index cc48f36..211f188 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/di/AppModule.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/di/AppModule.kt @@ -17,76 +17,56 @@ import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.serialization.kotlinx.json.json -import kotlinx.serialization.json.Json import javax.inject.Singleton +import kotlinx.serialization.json.Json @Module @InstallIn(SingletonComponent::class) object AppModule { - @Provides @Singleton fun provideHttpClient(): HttpClient { return HttpClient(OkHttp) { install(ContentNegotiation) { - json(Json { - ignoreUnknownKeys = true - isLenient = true - }) + json( + Json { + ignoreUnknownKeys = true + isLenient = true + } + ) } } } @Provides @Singleton - fun provideSettingsService( - @ApplicationContext context: Context - ): SettingsService { + fun provideSettingsService(@ApplicationContext context: Context): SettingsService { return SettingsService(context) } @Provides @Singleton - fun provideBiometricService( - @ApplicationContext context: Context - ): BiometricService { - return BiometricService(context) - } + fun provideBiometricService(@ApplicationContext context: Context): BiometricService = BiometricService(context) @Provides @Singleton - fun provideRelayService( - httpClient: HttpClient, - settingsService: SettingsService - ): RelayService { - return RelayService(httpClient, settingsService) - } + fun provideRelayService(httpClient: HttpClient, settingsService: SettingsService): RelayService = + RelayService(httpClient, settingsService) @Provides @Singleton - fun provideConversationStorageService( - @ApplicationContext context: Context - ): ConversationStorageService { - return ConversationStorageService(context) - } + fun provideConversationStorageService(@ApplicationContext context: Context): ConversationStorageService = + ConversationStorageService(context) @Provides @Singleton - fun provideSSEService(): SSEService { - return SSEService() - } + fun provideSSEService(): SSEService = SSEService() @Provides @Singleton - fun provideLocationService( - @ApplicationContext context: Context - ): LocationService { - return LocationService(context) - } + fun provideLocationService(@ApplicationContext context: Context): LocationService = LocationService(context) @Provides @Singleton - fun provideAshCoreService(): AshCoreService { - return AshCoreService() - } + fun provideAshCoreService(): AshCoreService = AshCoreService() } diff --git a/apps/android/app/src/main/java/com/monadial/ash/di/DataModule.kt b/apps/android/app/src/main/java/com/monadial/ash/di/DataModule.kt new file mode 100644 index 0000000..81e2f60 --- /dev/null +++ b/apps/android/app/src/main/java/com/monadial/ash/di/DataModule.kt @@ -0,0 +1,46 @@ +package com.monadial.ash.di + +import com.monadial.ash.data.services.CryptoServiceImpl +import com.monadial.ash.data.services.LocationServiceImpl +import com.monadial.ash.data.services.QRCodeServiceImpl +import com.monadial.ash.data.services.RealtimeServiceImpl +import com.monadial.ash.data.services.RelayServiceImpl +import com.monadial.ash.domain.services.CryptoService +import com.monadial.ash.domain.services.LocationService +import com.monadial.ash.domain.services.QRCodeService +import com.monadial.ash.domain.services.RealtimeService +import com.monadial.ash.domain.services.RelayService +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * DI module for data layer service bindings. + * Binds service interfaces to their implementations. + */ +@Module +@InstallIn(SingletonComponent::class) +abstract class DataModule { + + @Binds + @Singleton + abstract fun bindRelayService(impl: RelayServiceImpl): RelayService + + @Binds + @Singleton + abstract fun bindCryptoService(impl: CryptoServiceImpl): CryptoService + + @Binds + @Singleton + abstract fun bindRealtimeService(impl: RealtimeServiceImpl): RealtimeService + + @Binds + @Singleton + abstract fun bindQRCodeService(impl: QRCodeServiceImpl): QRCodeService + + @Binds + @Singleton + abstract fun bindLocationService(impl: LocationServiceImpl): LocationService +} diff --git a/apps/android/app/src/main/java/com/monadial/ash/di/DomainModule.kt b/apps/android/app/src/main/java/com/monadial/ash/di/DomainModule.kt new file mode 100644 index 0000000..43fcdd4 --- /dev/null +++ b/apps/android/app/src/main/java/com/monadial/ash/di/DomainModule.kt @@ -0,0 +1,22 @@ +package com.monadial.ash.di + +import com.monadial.ash.core.common.DefaultDispatcherProvider +import com.monadial.ash.core.common.DispatcherProvider +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * DI module for domain layer dependencies. + * Provides common utilities and dispatcher providers. + */ +@Module +@InstallIn(SingletonComponent::class) +abstract class DomainModule { + + @Binds + @Singleton + abstract fun bindDispatcherProvider(impl: DefaultDispatcherProvider): DispatcherProvider +} diff --git a/apps/android/app/src/main/java/com/monadial/ash/di/RepositoryModule.kt b/apps/android/app/src/main/java/com/monadial/ash/di/RepositoryModule.kt new file mode 100644 index 0000000..80e176e --- /dev/null +++ b/apps/android/app/src/main/java/com/monadial/ash/di/RepositoryModule.kt @@ -0,0 +1,34 @@ +package com.monadial.ash.di + +import com.monadial.ash.data.repositories.ConversationRepositoryImpl +import com.monadial.ash.data.repositories.PadRepositoryImpl +import com.monadial.ash.data.repositories.SettingsRepositoryImpl +import com.monadial.ash.domain.repositories.ConversationRepository +import com.monadial.ash.domain.repositories.PadRepository +import com.monadial.ash.domain.repositories.SettingsRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * DI module for repository bindings. + * Binds repository interfaces to their implementations. + */ +@Module +@InstallIn(SingletonComponent::class) +abstract class RepositoryModule { + + @Binds + @Singleton + abstract fun bindConversationRepository(impl: ConversationRepositoryImpl): ConversationRepository + + @Binds + @Singleton + abstract fun bindPadRepository(impl: PadRepositoryImpl): PadRepository + + @Binds + @Singleton + abstract fun bindSettingsRepository(impl: SettingsRepositoryImpl): SettingsRepository +} 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 4ba8add..08909fc 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 @@ -6,18 +6,28 @@ sealed class CeremonyPhase { // Initiator phases data object SelectingPadSize : CeremonyPhase() + data object ConfiguringOptions : CeremonyPhase() + data object ConfirmingConsent : CeremonyPhase() + data object CollectingEntropy : CeremonyPhase() + data object GeneratingPad : CeremonyPhase() + data class GeneratingQRCodes(val progress: Float, val total: Int) : CeremonyPhase() + data class Transferring(val currentFrame: Int, val totalFrames: Int) : CeremonyPhase() + data class Verifying(val mnemonic: List) : CeremonyPhase() + data class Completed(val conversation: Conversation) : CeremonyPhase() + data class Failed(val error: CeremonyError) : CeremonyPhase() // Receiver phases data object ConfiguringReceiver : CeremonyPhase() + data object Scanning : CeremonyPhase() } @@ -30,12 +40,7 @@ enum class CeremonyError { INVALID_FRAME } -enum class PadSize( - val bytes: Long, - val displayName: String, - val messageEstimate: Int, - val frameCount: Int -) { +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), @@ -46,13 +51,14 @@ enum class PadSize( get() = "~$messageEstimate messages" val transferTime: String - get() = when (this) { - TINY -> "~10 seconds" - SMALL -> "~15 seconds" - MEDIUM -> "~45 seconds" - LARGE -> "~1.5 minutes" - HUGE -> "~3 minutes" - } + get() = + when (this) { + TINY -> "~10 seconds" + SMALL -> "~15 seconds" + MEDIUM -> "~45 seconds" + LARGE -> "~1.5 minutes" + HUGE -> "~3 minutes" + } } data class ConsentState( @@ -72,11 +78,16 @@ data class ConsentState( 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 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/domain/entities/Conversation.kt b/apps/android/app/src/main/java/com/monadial/ash/domain/entities/Conversation.kt index 38e1854..9379e41 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/domain/entities/Conversation.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/domain/entities/Conversation.kt @@ -15,27 +15,20 @@ data class Conversation( val lastMessageAt: Long? = null, val lastMessagePreview: String? = null, val unreadCount: Int = 0, - // Pad state - bidirectional consumption - val padConsumedFront: Long = 0, // Bytes consumed by initiator (from start) - val padConsumedBack: Long = 0, // Bytes consumed by responder (from end) + val padConsumedFront: Long = 0, + val padConsumedBack: Long = 0, val padTotalSize: Long = 0, val mnemonic: List = emptyList(), - // Message settings val messageRetention: MessageRetention = MessageRetention.FIVE_MINUTES, val disappearingMessages: DisappearingMessages = DisappearingMessages.OFF, - // Notification preferences (encoded in ceremony) val notifyNewMessage: Boolean = true, val notifyMessageExpiring: Boolean = false, val notifyMessageExpired: Boolean = false, val notifyDeliveryFailed: Boolean = true, - // Persistence val persistenceConsent: Boolean = false, - // Relay state val relayCursor: String? = null, val activitySequence: Long = 0, - // Burn state val peerBurnedAt: Long? = null, - // Deduplication - track processed incoming sequences val processedIncomingSequences: Set = emptySet() ) { // Computed properties @@ -49,19 +42,28 @@ data class Conversation( get() = padTotalSize - padConsumedFront - padConsumedBack val usagePercentage: Double - get() = if (padTotalSize > 0) { - ((padConsumedFront + padConsumedBack).toDouble() / padTotalSize) * 100 - } else 0.0 + get() = + if (padTotalSize > 0) { + ((padConsumedFront + padConsumedBack).toDouble() / padTotalSize) * 100 + } else { + 0.0 + } val myUsagePercentage: Double - get() = if (padTotalSize > 0) { - (sendOffset.toDouble() / padTotalSize) * 100 - } else 0.0 + get() = + if (padTotalSize > 0) { + (sendOffset.toDouble() / padTotalSize) * 100 + } else { + 0.0 + } val peerUsagePercentage: Double - get() = if (padTotalSize > 0) { - (peerConsumed.toDouble() / padTotalSize) * 100 - } else 0.0 + get() = + if (padTotalSize > 0) { + (peerConsumed.toDouble() / padTotalSize) * 100 + } else { + 0.0 + } val isExhausted: Boolean get() = remainingBytes <= 0 @@ -80,9 +82,17 @@ data class Conversation( val displayText = displayName val words = displayText.split(" ") return when { - words.size >= 2 -> "${words[0].firstOrNull()?.uppercase() ?: ""}${words[1].firstOrNull()?.uppercase() ?: ""}" - displayText.length >= 2 -> displayText.take(2).uppercase() - else -> displayText.uppercase() + words.size >= 2 -> { + val first = words[0].firstOrNull()?.uppercase() ?: "" + val second = words[1].firstOrNull()?.uppercase() ?: "" + "$first$second" + } + displayText.length >= 2 -> { + displayText.take(2).uppercase() + } + else -> { + displayText.uppercase() + } } } @@ -105,9 +115,8 @@ data class Conversation( fun withCursor(cursor: String?): Conversation = copy(relayCursor = cursor) - fun withProcessedSequence(sequence: Long): Conversation = copy( - processedIncomingSequences = processedIncomingSequences + sequence - ) + fun withProcessedSequence(sequence: Long): Conversation = + copy(processedIncomingSequences = processedIncomingSequences + sequence) fun afterSending(bytes: Long): Conversation { return if (role == ConversationRole.INITIATOR) { @@ -119,14 +128,15 @@ data class Conversation( fun afterReceiving(sequence: Long, length: Long): Conversation { // Update peer's consumption based on role - val newPeerConsumed = if (role == ConversationRole.INITIATOR) { - // Peer is responder, consuming from back - // sequence is start offset from end - maxOf(padConsumedBack, padTotalSize - sequence) - } else { - // Peer is initiator, consuming from front - maxOf(padConsumedFront, sequence + length) - } + val newPeerConsumed = + if (role == ConversationRole.INITIATOR) { + // Peer is responder, consuming from back + // sequence is start offset from end + maxOf(padConsumedBack, padTotalSize - sequence) + } else { + // Peer is initiator, consuming from front + maxOf(padConsumedFront, sequence + length) + } return if (role == ConversationRole.INITIATOR) { copy( diff --git a/apps/android/app/src/main/java/com/monadial/ash/domain/entities/Message.kt b/apps/android/app/src/main/java/com/monadial/ash/domain/entities/Message.kt index 85e0dc2..321ef1a 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/domain/entities/Message.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/domain/entities/Message.kt @@ -1,8 +1,8 @@ package com.monadial.ash.domain.entities +import java.util.UUID import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import java.util.UUID @Serializable data class Message( @@ -12,11 +12,15 @@ data class Message( val direction: MessageDirection, val timestamp: Long = System.currentTimeMillis(), val status: DeliveryStatus = DeliveryStatus.NONE, - val sequence: Long? = null, // Pad offset for deduplication - val blobId: String? = null, // Server blob ID for ACK - val expiresAt: Long? = null, // Display TTL (disappearing messages) - val serverExpiresAt: Long? = null, // Server TTL (for sent messages awaiting delivery) - val isContentWiped: Boolean = false // Message expired, show placeholder + /** Pad offset for deduplication */ + val sequence: Long? = null, + /** Server blob ID for ACK */ + val blobId: String? = null, + /** Display TTL (disappearing messages) */ + val expiresAt: Long? = null, + /** Server TTL (for sent messages awaiting delivery) */ + val serverExpiresAt: Long? = null, + val isContentWiped: Boolean = false ) { // Computed properties val isExpired: Boolean @@ -29,7 +33,8 @@ data class Message( get() = serverExpiresAt?.let { maxOf(0, it - System.currentTimeMillis()) } val isAwaitingDelivery: Boolean - get() = direction == MessageDirection.SENT && + get() = + direction == MessageDirection.SENT && (status == DeliveryStatus.SENDING || status == DeliveryStatus.SENT) && serverExpiresAt != null @@ -47,10 +52,11 @@ data class Message( } val displayContent: String - get() = when { - isContentWiped -> "[Message Expired]" - else -> content.displayText - } + get() = + when { + isContentWiped -> "[Message Expired]" + else -> content.displayText + } fun withDeliveryStatus(status: DeliveryStatus): Message = copy(status = status) @@ -59,12 +65,7 @@ data class Message( fun withContentWiped(): Message = copy(isContentWiped = true) companion object { - fun outgoing( - conversationId: String, - text: String, - sequence: Long, - serverTTLSeconds: Long - ): Message = Message( + fun outgoing(conversationId: String, text: String, sequence: Long, serverTTLSeconds: Long): Message = Message( conversationId = conversationId, content = MessageContent.Text(text), direction = MessageDirection.SENT, @@ -153,11 +154,9 @@ sealed class MessageContent { return Text(text) } - fun toBytes(content: MessageContent): ByteArray { - return when (content) { - is Text -> content.text.toByteArray(Charsets.UTF_8) - is Location -> content.toEncodedString().toByteArray(Charsets.UTF_8) - } + fun toBytes(content: MessageContent): ByteArray = when (content) { + is Text -> content.text.toByteArray(Charsets.UTF_8) + is Location -> content.toEncodedString().toByteArray(Charsets.UTF_8) } } } @@ -194,11 +193,12 @@ sealed class DeliveryStatus { get() = this is FAILED val displayName: String - get() = when (this) { - NONE -> "" - SENDING -> "Sending..." - SENT -> "Sent" - DELIVERED -> "Delivered" - is FAILED -> "Failed" - } + get() = + when (this) { + NONE -> "" + SENDING -> "Sending..." + SENT -> "Sent" + DELIVERED -> "Delivered" + is FAILED -> "Failed" + } } diff --git a/apps/android/app/src/main/java/com/monadial/ash/domain/repositories/ConversationRepository.kt b/apps/android/app/src/main/java/com/monadial/ash/domain/repositories/ConversationRepository.kt new file mode 100644 index 0000000..8700ee3 --- /dev/null +++ b/apps/android/app/src/main/java/com/monadial/ash/domain/repositories/ConversationRepository.kt @@ -0,0 +1,74 @@ +package com.monadial.ash.domain.repositories + +import com.monadial.ash.core.common.AppResult +import com.monadial.ash.domain.entities.Conversation +import kotlinx.coroutines.flow.StateFlow + +/** + * Repository interface for conversation data operations. + * Abstracts the data source (encrypted SharedPreferences) from the domain layer. + */ +interface ConversationRepository { + + /** + * Observable list of all conversations, sorted by last activity. + */ + val conversations: StateFlow> + + /** + * Load all conversations from storage. + */ + suspend fun loadConversations(): AppResult> + + /** + * Get a specific conversation by ID. + */ + suspend fun getConversation(id: String): AppResult + + /** + * Save a new or updated conversation. + */ + suspend fun saveConversation(conversation: Conversation): AppResult + + /** + * Delete a conversation by ID. + */ + suspend fun deleteConversation(id: String): AppResult + + /** + * Update a conversation using a transform function. + * Returns the updated conversation. + */ + suspend fun updateConversation( + id: String, + update: (Conversation) -> Conversation + ): AppResult + + /** + * Update the last message preview and timestamp. + */ + suspend fun updateLastMessage( + id: String, + preview: String, + timestamp: Long + ): AppResult + + /** + * Update the relay cursor for pagination. + */ + suspend fun updateCursor(id: String, cursor: String?): AppResult + + /** + * Mark a conversation as burned by peer. + */ + suspend fun markPeerBurned(id: String, timestamp: Long): AppResult + + /** + * Update pad consumption after sending/receiving. + */ + suspend fun updatePadConsumption( + id: String, + consumedFront: Long, + consumedBack: Long + ): AppResult +} diff --git a/apps/android/app/src/main/java/com/monadial/ash/domain/repositories/PadRepository.kt b/apps/android/app/src/main/java/com/monadial/ash/domain/repositories/PadRepository.kt new file mode 100644 index 0000000..d72e5b4 --- /dev/null +++ b/apps/android/app/src/main/java/com/monadial/ash/domain/repositories/PadRepository.kt @@ -0,0 +1,98 @@ +package com.monadial.ash.domain.repositories + +import com.monadial.ash.core.common.AppResult +import com.monadial.ash.core.services.PadState +import com.monadial.ash.domain.entities.ConversationRole + +/** + * Repository interface for pad (one-time pad) operations. + * Wraps the PadManager to provide a clean interface for the domain layer. + */ +interface PadRepository { + + /** + * Store pad bytes for a new conversation (after ceremony). + */ + suspend fun storePad(conversationId: String, padBytes: ByteArray): AppResult + + /** + * Get the raw pad bytes for a conversation. + */ + suspend fun getPadBytes(conversationId: String): AppResult + + /** + * Get the current pad state (for UI display). + */ + suspend fun getPadState(conversationId: String): AppResult + + /** + * Check if a message of given length can be sent. + */ + suspend fun canSend(conversationId: String, length: Int, role: ConversationRole): AppResult + + /** + * Get the number of bytes available for sending. + */ + suspend fun availableForSending(conversationId: String, role: ConversationRole): AppResult + + /** + * Get the next send offset (sequence number for message). + */ + suspend fun nextSendOffset(conversationId: String, role: ConversationRole): AppResult + + /** + * Consume pad bytes for sending a message. + * Returns the key bytes for encryption. + * + * IMPORTANT: This updates consumption state - call only once per message! + */ + suspend fun consumeForSending( + conversationId: String, + length: Int, + role: ConversationRole + ): AppResult + + /** + * Get pad bytes for decryption at a specific offset. + * Does NOT update consumption state. + */ + suspend fun getBytesForDecryption( + conversationId: String, + offset: Long, + length: Int + ): AppResult + + /** + * Update peer's consumption based on received message. + */ + suspend fun updatePeerConsumption( + conversationId: String, + peerRole: ConversationRole, + consumed: Long + ): AppResult + + /** + * Zero pad bytes at specific offset (for forward secrecy). + * When a message expires, the key material is zeroed to prevent future decryption. + */ + suspend fun zeroPadBytes( + conversationId: String, + offset: Long, + length: Int + ): AppResult + + /** + * Securely wipe pad for a conversation. + */ + suspend fun wipePad(conversationId: String): AppResult + + /** + * Invalidate cached pad (call when conversation is deleted). + */ + fun invalidateCache(conversationId: String) + + /** + * Clear all cached pads. + */ + fun clearCache() +} diff --git a/apps/android/app/src/main/java/com/monadial/ash/domain/repositories/SettingsRepository.kt b/apps/android/app/src/main/java/com/monadial/ash/domain/repositories/SettingsRepository.kt new file mode 100644 index 0000000..80bab4e --- /dev/null +++ b/apps/android/app/src/main/java/com/monadial/ash/domain/repositories/SettingsRepository.kt @@ -0,0 +1,63 @@ +package com.monadial.ash.domain.repositories + +import com.monadial.ash.core.common.AppResult +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +/** + * Repository interface for application settings. + * Abstracts DataStore/SharedPreferences from the domain layer. + */ +interface SettingsRepository { + + /** + * Observable relay server URL. + */ + val relayServerUrl: Flow + + /** + * Observable biometric authentication enabled state. + * StateFlow is used as this setting always has a value. + */ + val isBiometricEnabled: StateFlow + + /** + * Observable lock on background state. + */ + val lockOnBackground: Flow + + /** + * Get the current relay server URL. + */ + suspend fun getRelayUrl(): String + + /** + * Set the relay server URL. + */ + suspend fun setRelayUrl(url: String): AppResult + + /** + * Get the default relay URL. + */ + fun getDefaultRelayUrl(): String + + /** + * Check if biometric authentication is enabled. + */ + suspend fun getBiometricEnabled(): Boolean + + /** + * Enable or disable biometric authentication. + */ + suspend fun setBiometricEnabled(enabled: Boolean): AppResult + + /** + * Check if lock on background is enabled. + */ + suspend fun getLockOnBackground(): Boolean + + /** + * Enable or disable lock on background. + */ + suspend fun setLockOnBackground(enabled: Boolean): AppResult +} diff --git a/apps/android/app/src/main/java/com/monadial/ash/domain/services/CryptoService.kt b/apps/android/app/src/main/java/com/monadial/ash/domain/services/CryptoService.kt new file mode 100644 index 0000000..c83e4ac --- /dev/null +++ b/apps/android/app/src/main/java/com/monadial/ash/domain/services/CryptoService.kt @@ -0,0 +1,117 @@ +package com.monadial.ash.domain.services + +import uniffi.ash.AuthTokens +import uniffi.ash.CeremonyMetadata +import uniffi.ash.FountainFrameGenerator +import uniffi.ash.FountainFrameReceiver +import uniffi.ash.Pad +import uniffi.ash.PadSize + +/** + * Interface for cryptographic operations. + * Wraps the ASH Core Rust FFI bindings. + * This is the trusted cryptographic authority. + */ +interface CryptoService { + + // === Fountain Code Operations (QR Transfer) === + + /** + * Create a fountain frame generator for QR display during ceremony. + */ + fun createFountainGenerator( + metadata: CeremonyMetadata, + padBytes: ByteArray, + blockSize: UInt = 1000u, + passphrase: String? = null + ): FountainFrameGenerator + + /** + * Create a fountain frame receiver for QR scanning during ceremony. + */ + fun createFountainReceiver(passphrase: String? = null): FountainFrameReceiver + + // === Mnemonic Operations === + + /** + * Generate a 6-word mnemonic checksum from pad bytes. + */ + fun generateMnemonic(padBytes: ByteArray): List + + // === Authorization Token Operations === + + /** + * Derive all authorization tokens from pad bytes. + */ + fun deriveAllTokens(padBytes: ByteArray): AuthTokens + + /** + * Derive the conversation ID from pad bytes. + */ + fun deriveConversationId(padBytes: ByteArray): String + + /** + * Derive the auth token from pad bytes. + */ + fun deriveAuthToken(padBytes: ByteArray): String + + /** + * Derive the burn token from pad bytes. + */ + fun deriveBurnToken(padBytes: ByteArray): String + + // === OTP Encryption === + + /** + * Encrypt plaintext using OTP (XOR with key). + */ + fun encrypt(key: ByteArray, plaintext: ByteArray): ByteArray + + /** + * Decrypt ciphertext using OTP (XOR with key). + */ + fun decrypt(key: ByteArray, ciphertext: ByteArray): ByteArray + + // === Passphrase Validation === + + /** + * Validate that a passphrase meets requirements. + */ + fun validatePassphrase(passphrase: String): Boolean + + // === Pad Operations === + + /** + * Create a Pad from entropy bytes. + */ + fun createPadFromEntropy(entropy: ByteArray, size: PadSize): Pad + + /** + * Create a Pad from raw bytes. + */ + fun createPadFromBytes(bytes: ByteArray): Pad + + /** + * Create a Pad from raw bytes with existing consumption state. + */ + fun createPadFromBytesWithState(bytes: ByteArray, consumedFront: ULong, consumedBack: ULong): Pad + + // === Utility === + + /** + * Hash a token using SHA-256. + */ + fun hashToken(token: String): String + + // === Frame Helper Extensions === + + /** + * Generate frame bytes from generator at index. + */ + fun generateFrameBytes(generator: FountainFrameGenerator, index: UInt): ByteArray + + /** + * Add frame bytes to receiver. + */ + fun addFrameBytes(receiver: FountainFrameReceiver, frameBytes: ByteArray): Boolean +} diff --git a/apps/android/app/src/main/java/com/monadial/ash/domain/services/LocationService.kt b/apps/android/app/src/main/java/com/monadial/ash/domain/services/LocationService.kt new file mode 100644 index 0000000..0c1fa15 --- /dev/null +++ b/apps/android/app/src/main/java/com/monadial/ash/domain/services/LocationService.kt @@ -0,0 +1,55 @@ +package com.monadial.ash.domain.services + +import kotlinx.coroutines.flow.Flow + +/** + * Location result with latitude and longitude. + * Precision is limited to 6 decimal places (~10cm) as per security spec. + */ +data class LocationResult( + val latitude: Double, + val longitude: Double +) { + val formattedLatitude: String get() = "%.6f".format(latitude) + val formattedLongitude: String get() = "%.6f".format(longitude) +} + +/** + * Location-specific errors. + */ +sealed class LocationError : Exception() { + data object PermissionDenied : LocationError() + data object Unavailable : LocationError() + data object Timeout : LocationError() +} + +/** + * Service interface for device location access. + * + * Abstracts platform-specific location APIs for testability. + */ +interface LocationService { + /** + * Whether the app has fine location permission. + */ + val hasLocationPermission: Boolean + + /** + * Whether the app has coarse location permission. + */ + val hasCoarseLocationPermission: Boolean + + /** + * Get the current device location. + * + * @return Result containing location or error + */ + suspend fun getCurrentLocation(): Result + + /** + * Observe continuous location updates. + * + * @return Flow of location updates + */ + fun observeLocationUpdates(): Flow +} diff --git a/apps/android/app/src/main/java/com/monadial/ash/domain/services/QRCodeService.kt b/apps/android/app/src/main/java/com/monadial/ash/domain/services/QRCodeService.kt new file mode 100644 index 0000000..5e12f63 --- /dev/null +++ b/apps/android/app/src/main/java/com/monadial/ash/domain/services/QRCodeService.kt @@ -0,0 +1,37 @@ +package com.monadial.ash.domain.services + +import android.graphics.Bitmap + +/** + * Service interface for QR code generation and decoding. + * + * This is a domain-level abstraction that allows for testing and mocking. + * Note: Uses Android Bitmap type - this is a pragmatic trade-off for Android-only apps + * to avoid over-engineering platform-neutral abstractions. + */ +interface QRCodeService { + /** + * Generate a QR code bitmap from raw bytes. + * + * @param data Raw bytes to encode + * @param size Target size in pixels + * @return QR code bitmap or null on failure + */ + fun generate(data: ByteArray, size: Int = 600): Bitmap? + + /** + * Generate a compact QR code (smaller file size). + * + * @param data Raw bytes to encode + * @return QR code bitmap or null on failure + */ + fun generateCompact(data: ByteArray): Bitmap? + + /** + * Decode base64 string from QR code back to raw bytes. + * + * @param base64String Base64 encoded string from scanned QR code + * @return Decoded bytes or null on failure + */ + fun decodeBase64(base64String: String): ByteArray? +} diff --git a/apps/android/app/src/main/java/com/monadial/ash/domain/services/RealtimeService.kt b/apps/android/app/src/main/java/com/monadial/ash/domain/services/RealtimeService.kt new file mode 100644 index 0000000..e5bec7d --- /dev/null +++ b/apps/android/app/src/main/java/com/monadial/ash/domain/services/RealtimeService.kt @@ -0,0 +1,47 @@ +package com.monadial.ash.domain.services + +import com.monadial.ash.core.services.SSEConnectionState +import com.monadial.ash.core.services.SSEEvent +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Interface for real-time messaging via Server-Sent Events. + * Abstracts SSE connection management for testability. + */ +interface RealtimeService { + + /** + * Observable connection state. + */ + val connectionState: StateFlow + + /** + * Observable event stream. + */ + val events: SharedFlow + + /** + * Connect to the SSE stream for a conversation. + * + * @param relayUrl The relay server URL + * @param conversationId The conversation ID + * @param authToken The auth token for authentication + */ + fun connect(relayUrl: String, conversationId: String, authToken: String) + + /** + * Disconnect from the current SSE stream. + */ + fun disconnect() + + /** + * Check if currently connected to a specific conversation. + */ + fun isConnectedTo(conversationId: String): Boolean + + /** + * Get the currently connected conversation ID, if any. + */ + fun currentConversationId(): String? +} diff --git a/apps/android/app/src/main/java/com/monadial/ash/domain/services/RelayService.kt b/apps/android/app/src/main/java/com/monadial/ash/domain/services/RelayService.kt new file mode 100644 index 0000000..aaafee7 --- /dev/null +++ b/apps/android/app/src/main/java/com/monadial/ash/domain/services/RelayService.kt @@ -0,0 +1,125 @@ +package com.monadial.ash.domain.services + +import com.monadial.ash.core.common.AppResult +import com.monadial.ash.core.services.BurnStatusResponse +import com.monadial.ash.core.services.ConnectionTestResult +import com.monadial.ash.core.services.HealthResponse +import com.monadial.ash.core.services.PollResult +import com.monadial.ash.core.services.SendResult +import kotlinx.coroutines.flow.Flow + +/** + * Interface for relay server communication. + * Abstracts HTTP operations for testability. + */ +interface RelayService { + + // === Health Check === + + /** + * Check relay server health. + */ + suspend fun checkHealth(relayUrl: String? = null): AppResult + + /** + * Test connection to relay server with latency measurement. + */ + suspend fun testConnection(relayUrl: String): ConnectionTestResult + + // === Messages === + + /** + * Submit an encrypted message to the relay. + */ + suspend fun submitMessage( + conversationId: String, + authToken: String, + ciphertext: ByteArray, + sequence: Long? = null, + ttlSeconds: Long? = null, + extendedTTL: Boolean = false, + persistent: Boolean = false, + relayUrl: String? = null + ): AppResult + + /** + * Fetch messages from the relay. + */ + suspend fun fetchMessages( + conversationId: String, + authToken: String, + cursor: String? = null, + relayUrl: String? = null + ): AppResult + + /** + * Poll messages continuously as a Flow. + */ + fun pollMessages( + conversationId: String, + authToken: String, + cursor: String? = null, + relayUrl: String? = null + ): Flow + + /** + * Acknowledge receipt of messages. + */ + suspend fun acknowledgeMessages( + conversationId: String, + authToken: String, + blobIds: List, + relayUrl: String? = null + ): AppResult + + // === Conversation Management === + + /** + * Register a new conversation with the relay. + */ + suspend fun registerConversation( + conversationId: String, + authTokenHash: String, + burnTokenHash: String, + relayUrl: String? = null + ): AppResult + + // === Device Registration === + + /** + * Register device for push notifications. + */ + suspend fun registerDevice( + conversationId: String, + authToken: String, + deviceToken: String, + relayUrl: String? = null + ): AppResult + + // === Burn Operations === + + /** + * Burn (permanently delete) a conversation on the relay. + */ + suspend fun burnConversation( + conversationId: String, + burnToken: String, + relayUrl: String? = null + ): AppResult + + /** + * Check if a conversation has been burned. + */ + suspend fun checkBurnStatus( + conversationId: String, + authToken: String, + relayUrl: String? = null + ): AppResult + + // === Utility === + + /** + * Hash a token using SHA-256. + */ + fun hashToken(token: String): String +} diff --git a/apps/android/app/src/main/java/com/monadial/ash/domain/usecases/conversation/BurnConversationUseCase.kt b/apps/android/app/src/main/java/com/monadial/ash/domain/usecases/conversation/BurnConversationUseCase.kt new file mode 100644 index 0000000..82cb911 --- /dev/null +++ b/apps/android/app/src/main/java/com/monadial/ash/domain/usecases/conversation/BurnConversationUseCase.kt @@ -0,0 +1,76 @@ +package com.monadial.ash.domain.usecases.conversation + +import com.monadial.ash.core.common.AppError +import com.monadial.ash.core.common.AppResult +import com.monadial.ash.domain.entities.Conversation +import com.monadial.ash.domain.repositories.ConversationRepository +import com.monadial.ash.domain.repositories.PadRepository +import com.monadial.ash.domain.services.RelayService +import javax.inject.Inject + +/** + * Use case for burning (permanently destroying) a conversation. + * + * This consolidates the burn logic from: + * - ConversationsViewModel.burnConversation() + * - ConversationInfoViewModel.burnConversation() + * - SettingsViewModel.burnAllConversations() (per-conversation logic) + * + * The burn process: + * 1. Notifies relay server (fire-and-forget - continue even if fails) + * 2. Wipes pad bytes securely + * 3. Deletes conversation record + */ +class BurnConversationUseCase @Inject constructor( + private val relayService: RelayService, + private val conversationRepository: ConversationRepository, + private val padRepository: PadRepository +) { + /** + * Burns a conversation. + * + * @param conversation The conversation to burn + * @return Success if local cleanup succeeded, Error only on critical failures + */ + suspend operator fun invoke(conversation: Conversation): AppResult { + return try { + // 1. Notify relay (fire-and-forget - continue even if fails) + try { + relayService.burnConversation( + conversationId = conversation.id, + burnToken = conversation.burnToken, + relayUrl = conversation.relayUrl + ) + } catch (_: Exception) { + // Continue even if relay notification fails + } + + // 2. Wipe pad bytes securely + padRepository.wipePad(conversation.id) + + // 3. Delete conversation record + conversationRepository.deleteConversation(conversation.id) + + AppResult.Success(Unit) + } catch (e: Exception) { + AppResult.Error(AppError.Storage.WriteFailed("Failed to burn conversation", e)) + } + } + + /** + * Burns multiple conversations. + * + * @param conversations List of conversations to burn + * @return Number of successfully burned conversations + */ + suspend fun burnAll(conversations: List): Int { + var burnedCount = 0 + conversations.forEach { conversation -> + val result = invoke(conversation) + if (result.isSuccess) { + burnedCount++ + } + } + return burnedCount + } +} diff --git a/apps/android/app/src/main/java/com/monadial/ash/domain/usecases/conversation/CheckBurnStatusUseCase.kt b/apps/android/app/src/main/java/com/monadial/ash/domain/usecases/conversation/CheckBurnStatusUseCase.kt new file mode 100644 index 0000000..56b1d0b --- /dev/null +++ b/apps/android/app/src/main/java/com/monadial/ash/domain/usecases/conversation/CheckBurnStatusUseCase.kt @@ -0,0 +1,89 @@ +package com.monadial.ash.domain.usecases.conversation + +import com.monadial.ash.core.common.AppResult +import com.monadial.ash.domain.entities.Conversation +import com.monadial.ash.domain.repositories.ConversationRepository +import com.monadial.ash.domain.services.RelayService +import javax.inject.Inject + +/** + * Result of checking burn status. + */ +data class BurnStatusResult( + val burned: Boolean, + val burnedAt: String? = null, + val conversationUpdated: Boolean = false +) + +/** + * Use case for checking if a conversation has been burned by the peer. + * + * This consolidates the check burn status logic from: + * - ConversationsViewModel.checkBurnStatus() + * - MessagingViewModel.checkBurnStatus() + * + * The check process: + * 1. Queries relay server for burn status + * 2. If burned and not already marked, updates local conversation state + */ +class CheckBurnStatusUseCase @Inject constructor( + private val relayService: RelayService, + private val conversationRepository: ConversationRepository +) { + /** + * Checks if a conversation has been burned by the peer. + * + * @param conversation The conversation to check + * @param updateIfBurned If true, automatically updates local state when peer has burned + * @return BurnStatusResult with burn status and whether local state was updated + */ + suspend operator fun invoke( + conversation: Conversation, + updateIfBurned: Boolean = true + ): AppResult { + val result = relayService.checkBurnStatus( + conversationId = conversation.id, + authToken = conversation.authToken, + relayUrl = conversation.relayUrl + ) + + return when (result) { + is AppResult.Success -> { + val status = result.data + var conversationUpdated = false + + if (status.burned && conversation.peerBurnedAt == null && updateIfBurned) { + // Peer has burned - update local state + val updated = conversation.copy(peerBurnedAt = System.currentTimeMillis()) + conversationRepository.saveConversation(updated) + conversationUpdated = true + } + + AppResult.Success( + BurnStatusResult( + burned = status.burned, + burnedAt = status.burnedAt, + conversationUpdated = conversationUpdated + ) + ) + } + is AppResult.Error -> { + result + } + } + } + + /** + * Checks burn status for multiple conversations. + * + * @param conversations List of conversations to check + * @return Map of conversation ID to burn status result + */ + suspend fun checkAll( + conversations: List + ): Map> { + return conversations.associate { conversation -> + conversation.id to invoke(conversation) + } + } +} diff --git a/apps/android/app/src/main/java/com/monadial/ash/domain/usecases/conversation/GetConversationsUseCase.kt b/apps/android/app/src/main/java/com/monadial/ash/domain/usecases/conversation/GetConversationsUseCase.kt new file mode 100644 index 0000000..00b207d --- /dev/null +++ b/apps/android/app/src/main/java/com/monadial/ash/domain/usecases/conversation/GetConversationsUseCase.kt @@ -0,0 +1,44 @@ +package com.monadial.ash.domain.usecases.conversation + +import com.monadial.ash.core.common.AppResult +import com.monadial.ash.domain.entities.Conversation +import com.monadial.ash.domain.repositories.ConversationRepository +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +/** + * Use case for retrieving conversations. + * + * Provides a clean interface for ViewModels to access conversation data + * without directly depending on repository implementation details. + */ +class GetConversationsUseCase @Inject constructor( + private val conversationRepository: ConversationRepository +) { + /** + * Observable list of all conversations. + */ + val conversations: StateFlow> + get() = conversationRepository.conversations + + /** + * Load all conversations from storage. + */ + suspend operator fun invoke(): AppResult> { + return conversationRepository.loadConversations() + } + + /** + * Get a specific conversation by ID. + */ + suspend fun getById(id: String): AppResult { + return conversationRepository.getConversation(id) + } + + /** + * Refresh conversations from storage. + */ + suspend fun refresh(): AppResult> { + return conversationRepository.loadConversations() + } +} diff --git a/apps/android/app/src/main/java/com/monadial/ash/domain/usecases/conversation/RegisterConversationUseCase.kt b/apps/android/app/src/main/java/com/monadial/ash/domain/usecases/conversation/RegisterConversationUseCase.kt new file mode 100644 index 0000000..46fb447 --- /dev/null +++ b/apps/android/app/src/main/java/com/monadial/ash/domain/usecases/conversation/RegisterConversationUseCase.kt @@ -0,0 +1,59 @@ +package com.monadial.ash.domain.usecases.conversation + +import android.util.Log +import com.monadial.ash.core.common.AppResult +import com.monadial.ash.domain.entities.Conversation +import com.monadial.ash.domain.services.RelayService +import javax.inject.Inject + +private const val TAG = "RegisterConversationUseCase" + +/** + * Use case for registering a conversation with the relay server. + * + * This consolidates the registration logic from: + * - MessagingViewModel.registerConversationWithRelay() + * - InitiatorCeremonyViewModel.registerConversationWithRelay() + * - ReceiverCeremonyViewModel.registerConversationWithRelay() + * + * Registration involves: + * 1. Hashing auth and burn tokens + * 2. Sending registration request to relay + */ +class RegisterConversationUseCase @Inject constructor( + private val relayService: RelayService +) { + /** + * Registers a conversation with the relay server. + * + * @param conversation The conversation to register + * @return Success(true) if registered, Success(false) if registration failed but not critical + */ + suspend operator fun invoke(conversation: Conversation): AppResult { + return try { + val authTokenHash = relayService.hashToken(conversation.authToken) + val burnTokenHash = relayService.hashToken(conversation.burnToken) + + val result = relayService.registerConversation( + conversationId = conversation.id, + authTokenHash = authTokenHash, + burnTokenHash = burnTokenHash, + relayUrl = conversation.relayUrl + ) + + when (result) { + is AppResult.Success -> { + Log.d(TAG, "[${conversation.id.take(8)}] Conversation registered with relay") + AppResult.Success(true) + } + is AppResult.Error -> { + Log.w(TAG, "[${conversation.id.take(8)}] Failed to register: ${result.error.message}") + AppResult.Success(false) + } + } + } catch (e: Exception) { + Log.w(TAG, "[${conversation.id.take(8)}] Failed to register: ${e.message}") + AppResult.Success(false) + } + } +} diff --git a/apps/android/app/src/main/java/com/monadial/ash/domain/usecases/messaging/ReceiveMessageUseCase.kt b/apps/android/app/src/main/java/com/monadial/ash/domain/usecases/messaging/ReceiveMessageUseCase.kt new file mode 100644 index 0000000..5a1e5aa --- /dev/null +++ b/apps/android/app/src/main/java/com/monadial/ash/domain/usecases/messaging/ReceiveMessageUseCase.kt @@ -0,0 +1,185 @@ +package com.monadial.ash.domain.usecases.messaging + +import android.util.Log +import com.monadial.ash.core.common.AppError +import com.monadial.ash.core.common.AppResult +import com.monadial.ash.domain.entities.Conversation +import com.monadial.ash.domain.entities.ConversationRole +import com.monadial.ash.domain.entities.Message +import com.monadial.ash.domain.entities.MessageContent +import com.monadial.ash.domain.repositories.ConversationRepository +import com.monadial.ash.domain.repositories.PadRepository +import com.monadial.ash.domain.services.CryptoService +import javax.inject.Inject + +private const val TAG = "ReceiveMessageUseCase" + +/** + * Input data for receiving a message. + */ +data class ReceivedMessageData( + val id: String, + val ciphertext: ByteArray, + val sequence: Long?, + val receivedAt: String +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as ReceivedMessageData + return id == other.id + } + + override fun hashCode(): Int = id.hashCode() +} + +/** + * Result of processing a received message. + */ +sealed class ReceiveMessageResult { + data class Success( + val message: Message, + val updatedConversation: Conversation + ) : ReceiveMessageResult() + + data class Skipped(val reason: String) : ReceiveMessageResult() + data class Error(val error: AppError) : ReceiveMessageResult() +} + +/** + * Use case for receiving and decrypting messages. + * + * Encapsulates the receive flow: + * 1. Validate sequence number + * 2. Check for own message / already processed + * 3. Get key bytes for decryption + * 4. Decrypt ciphertext using OTP + * 5. Parse message content + * 6. Update peer consumption state + * 7. Update conversation state + * + * This follows Single Responsibility Principle by handling only message receiving. + */ +class ReceiveMessageUseCase @Inject constructor( + private val conversationRepository: ConversationRepository, + private val padRepository: PadRepository, + private val cryptoService: CryptoService +) { + + /** + * Process a received encrypted message. + * + * @param conversation The conversation this message belongs to + * @param received The received message data (ciphertext, sequence, etc.) + * @return Result containing the decrypted message and updated conversation, or skip/error + */ + suspend operator fun invoke( + conversation: Conversation, + received: ReceivedMessageData + ): ReceiveMessageResult { + val logId = conversation.id.take(8) + + // 1. Validate sequence + val senderOffset = received.sequence + if (senderOffset == null) { + Log.w(TAG, "[$logId] Received message without sequence, skipping") + return ReceiveMessageResult.Skipped("No sequence number") + } + + // 2. Check if this is our OWN sent message (must skip to avoid corrupting peerConsumed) + val isOwnMessage = when (conversation.role) { + ConversationRole.INITIATOR -> { + // Initiator sends from [0, sendOffset) - messages in this range are ours + senderOffset < conversation.sendOffset + } + ConversationRole.RESPONDER -> { + // Responder sends from [totalBytes - sendOffset, totalBytes) + senderOffset >= conversation.padTotalSize - conversation.sendOffset + } + } + + if (isOwnMessage) { + Log.d(TAG, "[$logId] Skipping own sent message seq=$senderOffset") + return ReceiveMessageResult.Skipped("Own message echo") + } + + // 3. Check if already processed + if (conversation.hasProcessedIncomingSequence(senderOffset)) { + Log.d(TAG, "[$logId] Skipping already-processed message seq=$senderOffset") + return ReceiveMessageResult.Skipped("Already processed") + } + + Log.d(TAG, "[$logId] Processing: ${received.ciphertext.size} bytes, seq=$senderOffset") + + // 4. Get key bytes for decryption (does NOT update consumption state) + val keyBytesResult = padRepository.getBytesForDecryption( + conversation.id, + senderOffset, + received.ciphertext.size + ) + val keyBytes = when (keyBytesResult) { + is AppResult.Error -> { + Log.e(TAG, "[$logId] Failed to get key bytes: ${keyBytesResult.error.message}") + return ReceiveMessageResult.Error(keyBytesResult.error) + } + is AppResult.Success -> keyBytesResult.data + } + + // 5. Decrypt + val plaintext = try { + cryptoService.decrypt(keyBytes, received.ciphertext) + } catch (e: Exception) { + Log.e(TAG, "[$logId] Decryption failed: ${e.message}") + return ReceiveMessageResult.Error(AppError.Crypto.DecryptionFailed("Decryption failed: ${e.message}", e)) + } + + // 6. Parse content + val content = try { + MessageContent.fromBytes(plaintext) + } catch (e: Exception) { + Log.e(TAG, "[$logId] Failed to parse message content: ${e.message}") + return ReceiveMessageResult.Error(AppError.Validation("Invalid message format")) + } + + val contentType = when (content) { + is MessageContent.Text -> "text" + is MessageContent.Location -> "location" + } + Log.i(TAG, "[$logId] Decrypted $contentType message, seq=$senderOffset") + + // 7. Update peer consumption tracking + // - If I'm initiator (peer is responder): peerConsumed = totalBytes - sequence + // - If I'm responder (peer is initiator): peerConsumed = sequence + length + val peerRole = if (conversation.role == ConversationRole.INITIATOR) { + ConversationRole.RESPONDER + } else { + ConversationRole.INITIATOR + } + + val consumedAmount = if (conversation.role == ConversationRole.INITIATOR) { + conversation.padTotalSize - senderOffset + } else { + senderOffset + received.ciphertext.size + } + + Log.d(TAG, "[$logId] Updating peer consumption: peerRole=$peerRole, consumed=$consumedAmount") + padRepository.updatePeerConsumption(conversation.id, peerRole, consumedAmount) + + // 8. Update conversation state and persist + val updatedConv = conversation.afterReceiving(senderOffset, received.ciphertext.size.toLong()) + conversationRepository.saveConversation(updatedConv) + Log.d(TAG, "[$logId] Conversation state saved. Remaining=${updatedConv.remainingBytes}") + + // 9. Create message entity + val disappearingSeconds = conversation.disappearingMessages.seconds?.toLong() + val message = Message.incoming( + conversationId = conversation.id, + content = content, + sequence = senderOffset, + disappearingSeconds = disappearingSeconds, + blobId = received.id + ) + + return ReceiveMessageResult.Success(message, updatedConv) + } +} diff --git a/apps/android/app/src/main/java/com/monadial/ash/domain/usecases/messaging/SendMessageUseCase.kt b/apps/android/app/src/main/java/com/monadial/ash/domain/usecases/messaging/SendMessageUseCase.kt new file mode 100644 index 0000000..e5d9c5d --- /dev/null +++ b/apps/android/app/src/main/java/com/monadial/ash/domain/usecases/messaging/SendMessageUseCase.kt @@ -0,0 +1,162 @@ +package com.monadial.ash.domain.usecases.messaging + +import android.util.Log +import com.monadial.ash.core.common.AppError +import com.monadial.ash.core.common.AppResult +import com.monadial.ash.domain.entities.Conversation +import com.monadial.ash.domain.entities.ConversationRole +import com.monadial.ash.domain.entities.DeliveryStatus +import com.monadial.ash.domain.entities.Message +import com.monadial.ash.domain.entities.MessageContent +import com.monadial.ash.domain.entities.MessageDirection +import com.monadial.ash.domain.repositories.ConversationRepository +import com.monadial.ash.domain.repositories.PadRepository +import com.monadial.ash.domain.services.CryptoService +import com.monadial.ash.domain.services.RelayService +import javax.inject.Inject + +private const val TAG = "SendMessageUseCase" + +/** + * Result of a successful message send operation. + */ +data class SendMessageResult( + val message: Message, + val blobId: String, + val sequence: Long +) + +/** + * Use case for sending encrypted messages. + * + * Encapsulates the entire send flow: + * 1. Validate pad availability + * 2. Calculate sequence number + * 3. Consume pad bytes for encryption + * 4. Encrypt plaintext using OTP + * 5. Submit to relay server + * 6. Update conversation state + * + * This follows Single Responsibility Principle by handling only message sending. + */ +class SendMessageUseCase @Inject constructor( + private val conversationRepository: ConversationRepository, + private val padRepository: PadRepository, + private val cryptoService: CryptoService, + private val relayService: RelayService +) { + + /** + * Send a message in a conversation. + * + * @param conversation The conversation to send in + * @param content The message content (text or location) + * @return Result containing the sent message with blob ID, or an error + */ + suspend operator fun invoke( + conversation: Conversation, + content: MessageContent + ): AppResult { + val logId = conversation.id.take(8) + val plaintext = MessageContent.toBytes(content) + + Log.d(TAG, "[$logId] Sending message: ${plaintext.size} bytes") + + // 1. Check if we can send + val canSendResult = padRepository.canSend(conversation.id, plaintext.size, conversation.role) + when (canSendResult) { + is AppResult.Error -> return canSendResult + is AppResult.Success -> { + if (!canSendResult.data) { + Log.w(TAG, "[$logId] Pad exhausted - cannot send") + return AppResult.Error(AppError.Pad.Exhausted) + } + } + } + + // 2. Calculate sequence (offset where key material STARTS) + // - Initiator: key starts at consumed_front (nextSendOffset) + // - Responder: key starts at total_size - consumed_back - message_size + val sequenceResult = if (conversation.role == ConversationRole.RESPONDER) { + padRepository.getPadState(conversation.id).let { stateResult -> + when (stateResult) { + is AppResult.Error -> return stateResult + is AppResult.Success -> { + val padState = stateResult.data + AppResult.Success(padState.totalBytes - padState.consumedBack - plaintext.size) + } + } + } + } else { + padRepository.nextSendOffset(conversation.id, conversation.role) + } + + val sequence = when (sequenceResult) { + is AppResult.Error -> return sequenceResult + is AppResult.Success -> sequenceResult.data + } + + Log.d(TAG, "[$logId] Sequence=$sequence, remaining=${conversation.remainingBytes}") + + // 3. Consume pad bytes for encryption (updates state) + val keyBytesResult = padRepository.consumeForSending( + conversation.id, + plaintext.size, + conversation.role + ) + val keyBytes = when (keyBytesResult) { + is AppResult.Error -> return keyBytesResult + is AppResult.Success -> keyBytesResult.data + } + + // 4. Encrypt using OTP + val ciphertext = cryptoService.encrypt(keyBytes, plaintext) + Log.d(TAG, "[$logId] Encrypted: ${plaintext.size} → ${ciphertext.size} bytes") + + // 5. Update conversation state after sending + val updatedConv = conversation.afterSending(plaintext.size.toLong()) + val saveResult = conversationRepository.saveConversation(updatedConv) + if (saveResult is AppResult.Error) { + Log.e(TAG, "[$logId] Failed to save conversation state") + // Continue anyway - message sending is more important + } + + // 6. Send to relay + val sendResult = relayService.submitMessage( + conversationId = conversation.id, + authToken = conversation.authToken, + ciphertext = ciphertext, + sequence = sequence, + ttlSeconds = conversation.messageRetention.seconds, + relayUrl = conversation.relayUrl + ) + + return when (sendResult) { + is AppResult.Error -> { + Log.e(TAG, "[$logId] Send failed: ${sendResult.error.message}") + sendResult + } + is AppResult.Success -> { + val blobId = sendResult.data.blobId + if (blobId == null) { + Log.e(TAG, "[$logId] Send succeeded but no blob ID returned") + return AppResult.Error(AppError.Relay.SubmitFailed("No blob ID returned")) + } + + Log.i(TAG, "[$logId] Message sent: blobId=${blobId.take(8)}") + + val message = Message( + conversationId = conversation.id, + content = content, + direction = MessageDirection.SENT, + status = DeliveryStatus.SENT, + sequence = sequence, + blobId = blobId, + serverExpiresAt = System.currentTimeMillis() + (conversation.messageRetention.seconds * 1000L) + ) + + AppResult.Success(SendMessageResult(message, blobId, sequence)) + } + } + } +} diff --git a/apps/android/app/src/main/java/com/monadial/ash/ui/AshApp.kt b/apps/android/app/src/main/java/com/monadial/ash/ui/AshApp.kt index a8afb02..d6d151d 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/ui/AshApp.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/ui/AshApp.kt @@ -19,31 +19,35 @@ import com.monadial.ash.ui.viewmodels.AppViewModel sealed class Screen(val route: String) { data object Lock : Screen("lock") + data object Conversations : Screen("conversations") + data object Messaging : Screen("messaging/{conversationId}") { fun createRoute(conversationId: String) = "messaging/$conversationId" } + data object ConversationInfo : Screen("conversation-info/{conversationId}") { fun createRoute(conversationId: String) = "conversation-info/$conversationId" } + data object Ceremony : Screen("ceremony") + data object Settings : Screen("settings") } @Composable -fun AshApp( - viewModel: AppViewModel = hiltViewModel() -) { +fun AshApp(viewModel: AppViewModel = hiltViewModel()) { val isLocked by viewModel.isLocked.collectAsState() val isLoading by viewModel.isLoading.collectAsState() val navController = rememberNavController() - val startDestination = when { - isLoading -> Screen.Lock.route - isLocked -> Screen.Lock.route - else -> Screen.Conversations.route - } + val startDestination = + when { + isLoading -> Screen.Lock.route + isLocked -> Screen.Lock.route + else -> Screen.Conversations.route + } NavHost( navController = navController, 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 13b796e..1bc46d3 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 @@ -28,17 +28,19 @@ import androidx.compose.ui.unit.dp * Entropy collection canvas - just the drawing surface * Text/labels are handled by the parent EntropyCollectionContent */ +@Suppress("UnusedParameter") @Composable fun EntropyCollectionView( progress: Float, onPointCollected: (Float, Float) -> Unit, - accentColor: Color = MaterialTheme.colorScheme.primary, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + accentColor: Color = MaterialTheme.colorScheme.primary ) { val touchPoints = remember { mutableStateListOf() } Box( - modifier = modifier + modifier = + modifier .fillMaxWidth() .clip(RoundedCornerShape(16.dp)) .background(MaterialTheme.colorScheme.surfaceVariant) @@ -67,16 +69,18 @@ fun EntropyCollectionView( 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) + 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.7f), - style = Stroke( + style = + Stroke( width = 4.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round 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 b4982b8..17f8997 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 @@ -23,13 +23,10 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @Composable -fun QRCodeView( - bitmap: Bitmap?, - modifier: Modifier = Modifier, - size: Dp = 320.dp -) { +fun QRCodeView(bitmap: Bitmap?, modifier: Modifier = Modifier, size: Dp = 320.dp) { Box( - modifier = modifier + modifier = + modifier .size(size) .shadow(8.dp, RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp)) @@ -42,7 +39,8 @@ fun QRCodeView( Image( bitmap = bitmap.asImageBitmap(), contentDescription = "QR Code", - modifier = Modifier + modifier = + Modifier .padding(4.dp) .size(size - 8.dp), contentScale = ContentScale.Fit, @@ -59,13 +57,10 @@ fun QRCodeView( } @Composable -fun QRCodeFrameCounter( - currentFrame: Int, - totalFrames: Int, - modifier: Modifier = Modifier -) { +fun QRCodeFrameCounter(currentFrame: Int, totalFrames: Int, modifier: Modifier = Modifier) { Box( - modifier = modifier + modifier = + modifier .clip(RoundedCornerShape(8.dp)) .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.9f)) .padding(horizontal = 16.dp, vertical = 8.dp) 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 e50b011..717a9a7 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,3 +1,5 @@ +@file:OptIn(androidx.camera.core.ExperimentalGetImage::class) + package com.monadial.ash.ui.components import android.Manifest @@ -55,9 +57,7 @@ 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 class CallbackHolder(initialCallback: (String) -> Unit) { private val callbackRef = AtomicReference(initialCallback) private val lastScanTime = AtomicLong(0L) private val recentScans = ConcurrentHashMap() @@ -100,10 +100,7 @@ private class CallbackHolder( */ @OptIn(ExperimentalPermissionsApi::class) @Composable -fun QRScannerView( - onQRCodeScanned: (String) -> Unit, - modifier: Modifier = Modifier -) { +fun QRScannerView(onQRCodeScanned: (String) -> Unit, modifier: Modifier = Modifier) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA) @@ -135,7 +132,8 @@ fun QRScannerView( } Box( - modifier = modifier + modifier = + modifier .fillMaxSize() .clip(RoundedCornerShape(16.dp)) .background(Color.Black) @@ -143,9 +141,10 @@ fun QRScannerView( AndroidView( factory = { ctx -> Log.d(TAG, "Creating PreviewView") - val previewView = PreviewView(ctx).apply { - implementationMode = PreviewView.ImplementationMode.PERFORMANCE - } + val previewView = + PreviewView(ctx).apply { + implementationMode = PreviewView.ImplementationMode.PERFORMANCE + } setupCamera( context = ctx, @@ -164,7 +163,8 @@ fun QRScannerView( } } else { Box( - modifier = modifier + modifier = + modifier .fillMaxSize() .background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center @@ -192,59 +192,63 @@ private fun setupCamera( val provider = cameraProviderFuture.get() onCameraProviderReady(provider) - val preview = Preview.Builder().build().also { - it.surfaceProvider = previewView.surfaceProvider - } + val preview = + Preview.Builder().build().also { + it.surfaceProvider = previewView.surfaceProvider + } - val options = BarcodeScannerOptions.Builder() - .setBarcodeFormats(Barcode.FORMAT_QR_CODE) - .build() + 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 + val resolutionSelector = + ResolutionSelector.Builder() + .setResolutionStrategy( + ResolutionStrategy( + Size(1920, 1080), + ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER + ) ) - ) - .build() + .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 - ) + val imageAnalysis = + ImageAnalysis.Builder() + .setResolutionSelector(resolutionSelector) + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { analysis -> + analysis.setAnalyzer(analysisExecutor) { imageProxy -> + 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) + 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() + .addOnFailureListener { e -> + Log.e(TAG, "Barcode scanning failed: ${e.message}") + } + .addOnCompleteListener { + imageProxy.close() + } + } else { + imageProxy.close() + } } } - } try { provider.unbindAll() @@ -262,13 +266,10 @@ private fun setupCamera( } @Composable -fun ScanProgressOverlay( - receivedBlocks: Int, - totalBlocks: Int, - modifier: Modifier = Modifier -) { +fun ScanProgressOverlay(receivedBlocks: Int, totalBlocks: Int, modifier: Modifier = Modifier) { Box( - modifier = modifier + modifier = + modifier .clip(RoundedCornerShape(8.dp)) .background(Color.Black.copy(alpha = 0.7f)) .padding(horizontal = 24.dp, vertical = 16.dp) diff --git a/apps/android/app/src/main/java/com/monadial/ash/ui/components/ceremony/CeremonyStatusContent.kt b/apps/android/app/src/main/java/com/monadial/ash/ui/components/ceremony/CeremonyStatusContent.kt new file mode 100644 index 0000000..42bb7cd --- /dev/null +++ b/apps/android/app/src/main/java/com/monadial/ash/ui/components/ceremony/CeremonyStatusContent.kt @@ -0,0 +1,186 @@ +package com.monadial.ash.ui.components.ceremony + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.monadial.ash.domain.entities.CeremonyError + +/** + * Loading content with optional progress indicator. + */ +@Composable +fun LoadingContent( + title: String, + message: String, + progress: Float? = null, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (progress != null) { + CircularProgressIndicator(progress = { progress }) + Text("${(progress * 100).toInt()}%") + } else { + CircularProgressIndicator() + } + + Text( + text = title, + style = MaterialTheme.typography.titleMedium + ) + + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +/** + * Success content shown when ceremony completes successfully. + */ +@Suppress("UnusedParameter") +@Composable +fun CompletedContent( + conversationId: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(24.dp) + ) { + 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) + ) + } + } + + Text( + text = "Conversation Created!", + style = MaterialTheme.typography.headlineSmall + ) + + Text( + text = "Your secure channel is ready", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Button(onClick = onDismiss) { + Text("Start Messaging") + } + } + } +} + +/** + * Error content shown when ceremony fails. + */ +@Composable +fun FailedContent( + error: CeremonyError, + onRetry: () -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(24.dp) + ) { + 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) + ) + } + } + + Text( + text = "Ceremony Failed", + style = MaterialTheme.typography.headlineSmall + ) + + 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(16.dp)) + + Button(onClick = onRetry) { + Text("Try Again") + } + + TextButton(onClick = onCancel) { + Text("Cancel") + } + } + } +} diff --git a/apps/android/app/src/main/java/com/monadial/ash/ui/components/ceremony/PadSizeSelection.kt b/apps/android/app/src/main/java/com/monadial/ash/ui/components/ceremony/PadSizeSelection.kt new file mode 100644 index 0000000..de8e3ca --- /dev/null +++ b/apps/android/app/src/main/java/com/monadial/ash/ui/components/ceremony/PadSizeSelection.kt @@ -0,0 +1,264 @@ +package com.monadial.ash.ui.components.ceremony + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.QrCode2 +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material.icons.outlined.ChatBubbleOutline +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.monadial.ash.domain.entities.PadSize + +/** + * Content for selecting pad size and optional passphrase. + */ +@Composable +fun PadSizeSelectionContent( + selectedSize: PadSize, + onSizeSelected: (PadSize) -> Unit, + passphraseEnabled: Boolean, + onPassphraseToggle: (Boolean) -> Unit, + passphrase: String, + onPassphraseChange: (String) -> Unit, + onProceed: () -> Unit, + accentColor: Color, + modifier: Modifier = Modifier +) { + val accentContainer = accentColor.copy(alpha = 0.15f) + + Column( + modifier = modifier + .fillMaxSize() + .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 = "Pad Size", + style = MaterialTheme.typography.headlineSmall + ) + + Text( + text = "Larger pads allow more messages but take longer to transfer", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // Pad size options + PadSize.entries.forEach { size -> + PadSizeCard( + size = size, + isSelected = size == selectedSize, + onClick = { onSizeSelected(size) }, + accentColor = accentColor + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Passphrase section + PassphraseSection( + passphraseEnabled = passphraseEnabled, + onPassphraseToggle = onPassphraseToggle, + passphrase = passphrase, + onPassphraseChange = onPassphraseChange, + accentColor = accentColor + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = onProceed, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = accentColor) + ) { + Text("Continue") + } + } +} + +@Composable +private fun PadSizeCard( + size: PadSize, + isSelected: Boolean, + onClick: () -> Unit, + accentColor: Color +) { + val accentContainer = accentColor.copy(alpha = 0.15f) + val containerColor = if (isSelected) accentContainer else MaterialTheme.colorScheme.surfaceVariant + + Card( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = containerColor), + border = if (isSelected) BorderStroke(2.dp, accentColor) else null + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = size.displayName, + 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 + ) + } + } + } + RadioButton( + selected = isSelected, + onClick = onClick, + colors = RadioButtonDefaults.colors( + selectedColor = accentColor, + unselectedColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + } + } +} + +@Composable +private fun PassphraseSection( + passphraseEnabled: Boolean, + onPassphraseToggle: (Boolean) -> Unit, + passphrase: String, + onPassphraseChange: (String) -> Unit, + accentColor: Color +) { + 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 + ) + } + } + } +} diff --git a/apps/android/app/src/main/java/com/monadial/ash/ui/components/common/ColorButton.kt b/apps/android/app/src/main/java/com/monadial/ash/ui/components/common/ColorButton.kt new file mode 100644 index 0000000..ad167d4 --- /dev/null +++ b/apps/android/app/src/main/java/com/monadial/ash/ui/components/common/ColorButton.kt @@ -0,0 +1,55 @@ +package com.monadial.ash.ui.components.common + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * A circular button used for color selection. + * + * @param color The background color of the button + * @param isSelected Whether this color is currently selected + * @param onClick Callback when the button is clicked + * @param modifier Optional modifier + */ +@Composable +fun ColorButton( + color: Color, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Surface( + onClick = onClick, + modifier = modifier.size(44.dp), + shape = CircleShape, + color = color, + border = if (isSelected) { + BorderStroke(3.dp, MaterialTheme.colorScheme.outline) + } else { + null + } + ) { + if (isSelected) { + Box(contentAlignment = Alignment.Center) { + Icon( + Icons.Default.Check, + contentDescription = "Selected", + tint = Color.White, + modifier = Modifier.size(22.dp) + ) + } + } + } +} diff --git a/apps/android/app/src/main/java/com/monadial/ash/ui/components/common/MnemonicWord.kt b/apps/android/app/src/main/java/com/monadial/ash/ui/components/common/MnemonicWord.kt new file mode 100644 index 0000000..c7cd2e4 --- /dev/null +++ b/apps/android/app/src/main/java/com/monadial/ash/ui/components/common/MnemonicWord.kt @@ -0,0 +1,48 @@ +package com.monadial.ash.ui.components.common + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * Displays a numbered mnemonic word for verification. + * + * @param number The word number (1-6) + * @param word The mnemonic word + * @param accentColor The color for the word text + * @param modifier Optional modifier + */ +@Composable +fun MnemonicWord( + number: Int, + word: String, + accentColor: Color = Color(0xFF5856D6), + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + 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 + ) + } +} 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 aa410c7..ca092a9 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 @@ -7,7 +7,6 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith -import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -15,7 +14,6 @@ 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 @@ -73,7 +71,6 @@ 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 @@ -82,16 +79,12 @@ 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 @@ -107,7 +100,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.vector.ImageVector 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 import androidx.hilt.navigation.compose.hiltViewModel @@ -118,6 +110,7 @@ import com.monadial.ash.domain.entities.ConversationColor 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.state.ConnectionTestResult import com.monadial.ash.ui.components.EntropyCollectionView import com.monadial.ash.ui.components.QRCodeView import com.monadial.ash.ui.components.QRScannerView @@ -131,10 +124,7 @@ import com.monadial.ash.ui.viewmodels.ReceiverCeremonyViewModel */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun CeremonyScreen( - onComplete: (String) -> Unit, - onCancel: () -> Unit -) { +fun CeremonyScreen(onComplete: (String) -> Unit, onCancel: () -> Unit) { var selectedRole by remember { mutableStateOf(null) } when (selectedRole) { @@ -170,10 +160,7 @@ enum class CeremonyRole { @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun RoleSelectionScreen( - onRoleSelected: (CeremonyRole) -> Unit, - onCancel: () -> Unit -) { +private fun RoleSelectionScreen(onRoleSelected: (CeremonyRole) -> Unit, onCancel: () -> Unit) { Scaffold( topBar = { TopAppBar( @@ -187,7 +174,8 @@ private fun RoleSelectionScreen( } ) { padding -> Column( - modifier = Modifier + modifier = + Modifier .fillMaxSize() .padding(padding) .padding(24.dp), @@ -235,7 +223,8 @@ private fun RoleSelectionScreen( modifier = Modifier.fillMaxWidth() ) { Row( - modifier = Modifier + modifier = + Modifier .fillMaxWidth() .padding(16.dp), verticalAlignment = Alignment.CenterVertically @@ -287,7 +276,8 @@ private fun RoleSelectionScreen( modifier = Modifier.fillMaxWidth() ) { Row( - modifier = Modifier + modifier = + Modifier .fillMaxWidth() .padding(16.dp), verticalAlignment = Alignment.CenterVertically @@ -347,24 +337,28 @@ private fun InitiatorCeremonyScreen( onComplete: (String) -> Unit, onCancel: () -> Unit ) { - val phase by viewModel.phase.collectAsState() - val selectedPadSize by viewModel.selectedPadSize.collectAsState() - val selectedColor by viewModel.selectedColor.collectAsState() - val conversationName by viewModel.conversationName.collectAsState() - val relayUrl by viewModel.relayUrl.collectAsState() - val serverRetention by viewModel.serverRetention.collectAsState() - val disappearingMessages by viewModel.disappearingMessages.collectAsState() - val consent by viewModel.consent.collectAsState() - val entropyProgress by viewModel.entropyProgress.collectAsState() - val currentQRBitmap by viewModel.currentQRBitmap.collectAsState() - val currentFrameIndex by viewModel.currentFrameIndex.collectAsState() - 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() + // Single consolidated state collection (principal developer pattern) + val uiState by viewModel.uiState.collectAsState() + + // Extract values for readability + val phase = uiState.phase + val selectedPadSize = uiState.selectedPadSize + val selectedColor = uiState.selectedColor + val conversationName = uiState.conversationName + val relayUrl = uiState.relayUrl + val serverRetention = uiState.serverRetention + val disappearingMessages = uiState.disappearingMessages + val consent = uiState.consent + val entropyProgress = uiState.entropyProgress + val currentQRBitmap = uiState.currentQRBitmap + val currentFrameIndex = uiState.currentFrameIndex + val totalFrames = uiState.totalFrames + val connectionTestResult = uiState.connectionTestResult + val isTestingConnection = uiState.isTestingConnection + val passphraseEnabled = uiState.passphraseEnabled + val passphrase = uiState.passphrase + val isPaused = uiState.isPaused + val fps = uiState.fps val accentColor = Color(selectedColor.toColorLong()) @@ -386,7 +380,8 @@ private fun InitiatorCeremonyScreen( AnimatedContent( targetState = phase, transitionSpec = { fadeIn() togetherWith fadeOut() }, - modifier = Modifier + modifier = + Modifier .fillMaxSize() .padding(padding), label = "ceremony_phase", @@ -523,13 +518,17 @@ private fun ReceiverCeremonyScreen( onComplete: (String) -> Unit, onCancel: () -> Unit ) { - 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() + // Single consolidated state collection (principal developer pattern) + val uiState by viewModel.uiState.collectAsState() + + // Extract values for readability + val phase = uiState.phase + val conversationName = uiState.conversationName + val selectedColor = uiState.selectedColor + val receivedBlocks = uiState.receivedBlocks + val totalBlocks = uiState.totalBlocks + val passphraseEnabled = uiState.passphraseEnabled + val passphrase = uiState.passphrase Scaffold( topBar = { @@ -549,7 +548,8 @@ private fun ReceiverCeremonyScreen( AnimatedContent( targetState = phase, transitionSpec = { fadeIn() togetherWith fadeOut() }, - modifier = Modifier + modifier = + Modifier .fillMaxSize() .padding(padding), label = "receiver_ceremony_phase", @@ -629,7 +629,8 @@ private fun PadSizeSelectionContent( val accentContainer = accentColor.copy(alpha = 0.15f) Column( - modifier = Modifier + modifier = + Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) .padding(24.dp), @@ -709,7 +710,8 @@ private fun PadSizeSelectionContent( Switch( checked = passphraseEnabled, onCheckedChange = onPassphraseToggle, - colors = SwitchDefaults.colors( + colors = + SwitchDefaults.colors( checkedThumbColor = Color.White, checkedTrackColor = accentColor, checkedBorderColor = accentColor @@ -736,7 +738,8 @@ private fun PadSizeSelectionContent( Button( onClick = onProceed, modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors( + colors = + ButtonDefaults.buttonColors( containerColor = accentColor ) ) { @@ -746,29 +749,29 @@ private fun PadSizeSelectionContent( } @Composable -private fun PadSizeCard( - size: PadSize, - isSelected: Boolean, - onClick: () -> Unit, - accentColor: Color -) { +private fun PadSizeCard(size: PadSize, isSelected: Boolean, onClick: () -> Unit, accentColor: Color) { val accentContainer = accentColor.copy(alpha = 0.15f) - val containerColor = if (isSelected) { - accentContainer - } else { - MaterialTheme.colorScheme.surfaceVariant - } + val containerColor = + if (isSelected) { + accentContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + } Card( onClick = onClick, modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = containerColor), - border = if (isSelected) { + border = + if (isSelected) { androidx.compose.foundation.BorderStroke(2.dp, accentColor) - } else null + } else { + null + } ) { Row( - modifier = Modifier + modifier = + Modifier .fillMaxWidth() .padding(16.dp), verticalAlignment = Alignment.CenterVertically @@ -816,7 +819,8 @@ private fun PadSizeCard( RadioButton( selected = isSelected, onClick = onClick, - colors = RadioButtonDefaults.colors( + colors = + RadioButtonDefaults.colors( selectedColor = accentColor, unselectedColor = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -830,6 +834,7 @@ private fun PadSizeCard( // ============================================================================ @OptIn(ExperimentalLayoutApi::class) +@Suppress("UnusedParameter") @Composable private fun OptionsConfigurationContent( conversationName: String, @@ -844,7 +849,7 @@ private fun OptionsConfigurationContent( onDisappearingChange: (DisappearingMessages) -> Unit, onTestConnection: () -> Unit, isTestingConnection: Boolean, - connectionTestResult: InitiatorCeremonyViewModel.ConnectionTestResult?, + connectionTestResult: ConnectionTestResult?, onProceed: () -> Unit, accentColor: Color ) { @@ -852,7 +857,8 @@ private fun OptionsConfigurationContent( var showDisappearingMenu by remember { mutableStateOf(false) } Column( - modifier = Modifier + modifier = + Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) .padding(24.dp), @@ -893,7 +899,8 @@ private fun OptionsConfigurationContent( // Server Retention Row( - modifier = Modifier + modifier = + Modifier .fillMaxWidth() .clickable { showRetentionMenu = true } .padding(vertical = 8.dp), @@ -935,7 +942,8 @@ private fun OptionsConfigurationContent( // Disappearing Messages Row( - modifier = Modifier + modifier = + Modifier .fillMaxWidth() .clickable { showDisappearingMenu = true } .padding(vertical = 8.dp), @@ -1010,7 +1018,8 @@ private fun OptionsConfigurationContent( FilledTonalButton( onClick = onTestConnection, enabled = !isTestingConnection, - colors = ButtonDefaults.filledTonalButtonColors( + colors = + ButtonDefaults.filledTonalButtonColors( containerColor = accentColor.copy(alpha = 0.15f), contentColor = accentColor ) @@ -1028,9 +1037,9 @@ private fun OptionsConfigurationContent( connectionTestResult?.let { result -> when (result) { - is InitiatorCeremonyViewModel.ConnectionTestResult.Success -> + is ConnectionTestResult.Success -> Text("Connected", color = MaterialTheme.colorScheme.primary) - is InitiatorCeremonyViewModel.ConnectionTestResult.Failure -> + is ConnectionTestResult.Failure -> Text("Failed", color = MaterialTheme.colorScheme.error) } } @@ -1076,7 +1085,8 @@ private fun OptionsConfigurationContent( Button( onClick = onProceed, modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors( + colors = + ButtonDefaults.buttonColors( containerColor = accentColor ) ) { @@ -1086,19 +1096,18 @@ private fun OptionsConfigurationContent( } @Composable -private fun ColorButton( - color: Color, - isSelected: Boolean, - onClick: () -> Unit -) { +private fun ColorButton(color: Color, isSelected: Boolean, onClick: () -> Unit) { Surface( onClick = onClick, modifier = Modifier.size(44.dp), shape = CircleShape, color = color, - border = if (isSelected) { + border = + if (isSelected) { androidx.compose.foundation.BorderStroke(3.dp, MaterialTheme.colorScheme.outline) - } else null + } else { + null + } ) { if (isSelected) { Box(contentAlignment = Alignment.Center) { @@ -1130,7 +1139,8 @@ private fun ConsentContent( val accentContainer = accentColor.copy(alpha = 0.15f) Column( - modifier = Modifier + modifier = + Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) .padding(24.dp), @@ -1170,7 +1180,8 @@ private fun ConsentContent( // Progress bar LinearProgressIndicator( progress = { consent.confirmedCount.toFloat() / consent.totalCount }, - modifier = Modifier + modifier = + Modifier .fillMaxWidth() .height(4.dp) .clip(RoundedCornerShape(2.dp)), @@ -1283,7 +1294,8 @@ private fun ConsentContent( onClick = onConfirm, modifier = Modifier.fillMaxWidth(), enabled = consent.allConfirmed, - colors = ButtonDefaults.buttonColors( + colors = + ButtonDefaults.buttonColors( containerColor = accentColor ) ) { @@ -1302,11 +1314,7 @@ private fun ConsentContent( } @Composable -private fun ConsentSection( - title: String, - icon: ImageVector, - content: @Composable () -> Unit -) { +private fun ConsentSection(title: String, icon: ImageVector, content: @Composable () -> Unit) { OutlinedCard(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(16.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -1339,7 +1347,8 @@ private fun ConsentCheckItem( iconTint: Color? = null ) { Row( - modifier = Modifier + modifier = + Modifier .fillMaxWidth() .clickable { onCheckedChange(!checked) } .padding(vertical = 4.dp), @@ -1348,7 +1357,8 @@ private fun ConsentCheckItem( Checkbox( checked = checked, onCheckedChange = onCheckedChange, - colors = CheckboxDefaults.colors( + colors = + CheckboxDefaults.colors( checkedColor = accentColor, checkmarkColor = Color.White ) @@ -1382,7 +1392,8 @@ private fun ConsentCheckItem( @Composable private fun EthicsGuidelinesContent(onDismiss: () -> Unit) { Column( - modifier = Modifier + modifier = + Modifier .fillMaxWidth() .verticalScroll(rememberScrollState()) .padding(24.dp) @@ -1416,7 +1427,8 @@ private fun EthicsGuidelinesContent(onDismiss: () -> Unit) { @Composable private fun EthicsItem(number: Int, title: String, description: String) { Row( - modifier = Modifier + modifier = + Modifier .fillMaxWidth() .padding(vertical = 8.dp) ) { @@ -1450,15 +1462,12 @@ private fun EthicsItem(number: Int, title: String, description: String) { // ============================================================================ @Composable -private fun EntropyCollectionContent( - progress: Float, - onPointCollected: (Float, Float) -> Unit, - accentColor: Color -) { +private fun EntropyCollectionContent(progress: Float, onPointCollected: (Float, Float) -> Unit, accentColor: Color) { val accentContainer = accentColor.copy(alpha = 0.15f) Column( - modifier = Modifier + modifier = + Modifier .fillMaxSize() .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally @@ -1481,7 +1490,8 @@ private fun EntropyCollectionContent( progress = progress, onPointCollected = onPointCollected, accentColor = accentColor, - modifier = Modifier + modifier = + Modifier .fillMaxWidth() .weight(1f) ) @@ -1491,7 +1501,8 @@ private fun EntropyCollectionContent( // Compact progress indicator LinearProgressIndicator( progress = { progress }, - modifier = Modifier + modifier = + Modifier .fillMaxWidth() .height(8.dp) .clip(RoundedCornerShape(4.dp)), @@ -1514,11 +1525,7 @@ private fun EntropyCollectionContent( // ============================================================================ @Composable -private fun LoadingContent( - title: String, - message: String, - progress: Float? = null -) { +private fun LoadingContent(title: String, message: String, progress: Float? = null) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center @@ -1576,15 +1583,17 @@ private fun TransferringContent( 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 - } + 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 - } + window?.attributes = + window?.attributes?.apply { + screenBrightness = originalBrightness + } } } @@ -1595,7 +1604,8 @@ private fun TransferringContent( ) Column( - modifier = Modifier + modifier = + Modifier .fillMaxSize() .padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally @@ -1627,7 +1637,8 @@ private fun TransferringContent( Spacer(modifier = Modifier.height(4.dp)) LinearProgressIndicator( progress = { progressAnimation }, - modifier = Modifier + modifier = + Modifier .fillMaxWidth() .height(4.dp) .clip(RoundedCornerShape(2.dp)), @@ -1653,7 +1664,8 @@ private fun TransferringContent( FilledIconButton( onClick = onTogglePause, modifier = Modifier.size(56.dp), - colors = IconButtonDefaults.filledIconButtonColors( + colors = + IconButtonDefaults.filledIconButtonColors( containerColor = accentColor ) ) { @@ -1714,7 +1726,8 @@ private fun TransferringContent( Button( onClick = onDone, modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors( + colors = + ButtonDefaults.buttonColors( containerColor = accentColor ) ) { @@ -1730,11 +1743,7 @@ private fun TransferringContent( // ============================================================================ @Composable -private fun ScanningContent( - receivedBlocks: Int, - totalBlocks: Int, - onFrameScanned: (String) -> Unit -) { +private fun ScanningContent(receivedBlocks: Int, totalBlocks: Int, onFrameScanned: (String) -> Unit) { Box(modifier = Modifier.fillMaxSize()) { QRScannerView( onQRCodeScanned = onFrameScanned, @@ -1744,7 +1753,8 @@ private fun ScanningContent( ScanProgressOverlay( receivedBlocks = receivedBlocks, totalBlocks = totalBlocks, - modifier = Modifier + modifier = + Modifier .align(Alignment.BottomCenter) .padding(24.dp) ) @@ -1769,7 +1779,8 @@ private fun ReceiverSetupContent( val accentColor = Color(selectedColor.toColorLong()) Column( - modifier = Modifier + modifier = + Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) .padding(24.dp), @@ -1858,7 +1869,8 @@ private fun ReceiverSetupContent( Switch( checked = passphraseEnabled, onCheckedChange = onPassphraseToggle, - colors = SwitchDefaults.colors( + colors = + SwitchDefaults.colors( checkedThumbColor = Color.White, checkedTrackColor = accentColor, checkedBorderColor = accentColor @@ -1920,7 +1932,8 @@ private fun ReceiverSetupContent( Button( onClick = onStartScanning, modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors( + colors = + ButtonDefaults.buttonColors( containerColor = accentColor ) ) { @@ -1976,7 +1989,8 @@ private fun VerificationContent( val accentContainer = accentColor.copy(alpha = 0.15f) Column( - modifier = Modifier + modifier = + Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) .padding(24.dp), @@ -2015,7 +2029,8 @@ private fun VerificationContent( // Mnemonic words in a grid Card( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( + colors = + CardDefaults.cardColors( containerColor = accentContainer ) ) { @@ -2052,7 +2067,8 @@ private fun VerificationContent( Button( onClick = onConfirm, modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors( + colors = + ButtonDefaults.buttonColors( containerColor = accentColor ) ) { @@ -2066,7 +2082,8 @@ private fun VerificationContent( OutlinedButton( onClick = onReject, modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.outlinedButtonColors( + colors = + ButtonDefaults.outlinedButtonColors( contentColor = MaterialTheme.colorScheme.error ) ) { @@ -2080,7 +2097,8 @@ private fun VerificationContent( @Composable private fun MnemonicWord(number: Int, word: String, accentColor: Color = Color(0xFF5856D6)) { Row( - modifier = Modifier + modifier = + Modifier .fillMaxWidth() .padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically @@ -2103,11 +2121,9 @@ private fun MnemonicWord(number: Int, word: String, accentColor: Color = Color(0 // Completed Content // ============================================================================ +@Suppress("UnusedParameter") @Composable -private fun CompletedContent( - conversationId: String, - onDismiss: () -> Unit -) { +private fun CompletedContent(conversationId: String, onDismiss: () -> Unit) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center @@ -2157,11 +2173,7 @@ private fun CompletedContent( // ============================================================================ @Composable -private fun FailedContent( - error: CeremonyError, - onRetry: () -> Unit, - onCancel: () -> Unit -) { +private fun FailedContent(error: CeremonyError, onRetry: () -> Unit, onCancel: () -> Unit) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center @@ -2192,7 +2204,8 @@ private fun FailedContent( ) Text( - text = when (error) { + 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" @@ -2227,20 +2240,18 @@ private fun FailedContent( * Important: Scanning and Transferring use the same key in receiver flow * to prevent camera recreation when progress updates. */ -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 - } +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" + 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/ConversationInfoScreen.kt b/apps/android/app/src/main/java/com/monadial/ash/ui/screens/ConversationInfoScreen.kt index a0e09b9..476c2ca 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/ui/screens/ConversationInfoScreen.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/ui/screens/ConversationInfoScreen.kt @@ -34,7 +34,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold @@ -64,6 +63,7 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +@Suppress("UnusedParameter") @OptIn(ExperimentalMaterial3Api::class) @Composable fun ConversationInfoScreen( @@ -100,7 +100,8 @@ fun ConversationInfoScreen( ) { padding -> if (isLoading) { Box( - modifier = Modifier + modifier = + Modifier .fillMaxSize() .padding(padding), contentAlignment = Alignment.Center @@ -112,7 +113,8 @@ fun ConversationInfoScreen( val accentColor = Color(conv.color.toColorLong()) Column( - modifier = Modifier + modifier = + Modifier .fillMaxSize() .padding(padding) .verticalScroll(rememberScrollState()) @@ -172,7 +174,7 @@ fun ConversationInfoScreen( text = { Text( "This will permanently destroy the encryption pad and all messages. " + - "Your peer will be notified. This action cannot be undone." + "Your peer will be notified. This action cannot be undone." ) }, confirmButton = { @@ -181,7 +183,8 @@ fun ConversationInfoScreen( showBurnDialog = false viewModel.burnConversation() }, - colors = ButtonDefaults.buttonColors( + colors = + ButtonDefaults.buttonColors( containerColor = Color(0xFFFF3B30) ) ) { @@ -231,17 +234,14 @@ fun ConversationInfoScreen( } @Composable -private fun ConversationHeader( - conversation: Conversation, - accentColor: Color, - onRenameClick: () -> Unit -) { +private fun ConversationHeader(conversation: Conversation, accentColor: Color, onRenameClick: () -> Unit) { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { Box( - modifier = Modifier + modifier = + Modifier .size(80.dp) .clip(CircleShape) .background(accentColor), @@ -273,7 +273,8 @@ private fun ConversationHeader( private fun MnemonicCard(mnemonic: List) { Card( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( + colors = + CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) ) ) { @@ -313,7 +314,8 @@ private fun MnemonicCard(mnemonic: List) { private fun PadUsageCard(conversation: Conversation, accentColor: Color) { Card( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( + colors = + CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ) ) { @@ -395,13 +397,10 @@ private fun PadUsageCard(conversation: Conversation, accentColor: Color) { } @Composable -private fun DualUsageBar( - myUsage: Float, - peerUsage: Float, - accentColor: Color -) { +private fun DualUsageBar(myUsage: Float, peerUsage: Float, accentColor: Color) { Box( - modifier = Modifier + modifier = + Modifier .fillMaxWidth() .height(12.dp) .clip(RoundedCornerShape(6.dp)) @@ -409,7 +408,8 @@ private fun DualUsageBar( ) { // My usage from left Box( - modifier = Modifier + modifier = + Modifier .fillMaxWidth(myUsage / 100f) .height(12.dp) .background(accentColor) @@ -417,7 +417,8 @@ private fun DualUsageBar( ) // Peer usage from right Box( - modifier = Modifier + modifier = + Modifier .fillMaxWidth(peerUsage / 100f) .height(12.dp) .background(accentColor.copy(alpha = 0.5f)) @@ -430,7 +431,8 @@ private fun DualUsageBar( private fun UsageLegendItem(color: Color, label: String, percentage: Double) { Row(verticalAlignment = Alignment.CenterVertically) { Box( - modifier = Modifier + modifier = + Modifier .size(12.dp) .clip(CircleShape) .background(color) @@ -448,7 +450,8 @@ private fun UsageLegendItem(color: Color, label: String, percentage: Double) { private fun DetailsCard(conversation: Conversation) { Card( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( + colors = + CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ) ) { @@ -495,7 +498,8 @@ private fun DetailRow(label: String, value: String) { private fun MessageSettingsCard(conversation: Conversation) { Card( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( + colors = + CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ) ) { @@ -567,7 +571,8 @@ private fun BurnButton(isBurning: Boolean, onClick: () -> Unit) { onClick = onClick, enabled = !isBurning, modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors( + colors = + ButtonDefaults.buttonColors( containerColor = Color(0xFFFF3B30) ), shape = RoundedCornerShape(12.dp) 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 86b2646..d540c4f 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 @@ -34,7 +34,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface @@ -50,7 +49,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -62,7 +60,6 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.monadial.ash.domain.entities.Conversation import com.monadial.ash.ui.viewmodels.ConversationsViewModel -import kotlinx.coroutines.launch import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -102,7 +99,8 @@ fun ConversationsScreen( PullToRefreshBox( isRefreshing = isRefreshing, onRefresh = { viewModel.refresh() }, - modifier = Modifier + modifier = + Modifier .fillMaxSize() .padding(padding) ) { @@ -150,7 +148,7 @@ fun ConversationsScreen( text = { Text( "This will permanently destroy the encryption pad and all messages with \"${conv.displayName}\". " + - "Your peer will be notified. This cannot be undone." + "Your peer will be notified. This cannot be undone." ) }, confirmButton = { @@ -159,7 +157,8 @@ fun ConversationsScreen( viewModel.burnConversation(conv) conversationToBurn = null }, - colors = ButtonDefaults.buttonColors( + colors = + ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.error ) ) { @@ -177,22 +176,18 @@ fun ConversationsScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun SwipeableConversationCard( - conversation: Conversation, - onClick: () -> Unit, - onBurn: () -> Unit -) { - val scope = rememberCoroutineScope() - val dismissState = rememberSwipeToDismissBoxState( - confirmValueChange = { value -> - if (value == SwipeToDismissBoxValue.EndToStart) { - onBurn() - false // Don't actually dismiss, show confirmation dialog - } else { - false +private fun SwipeableConversationCard(conversation: Conversation, onClick: () -> Unit, onBurn: () -> Unit) { + val dismissState = + rememberSwipeToDismissBoxState( + confirmValueChange = { value -> + if (value == SwipeToDismissBoxValue.EndToStart) { + onBurn() + false // Don't actually dismiss, show confirmation dialog + } else { + false + } } - } - ) + ) val errorColor = MaterialTheme.colorScheme.error @@ -207,7 +202,8 @@ private fun SwipeableConversationCard( label = "background" ) Box( - modifier = Modifier + modifier = + Modifier .fillMaxSize() .clip(RoundedCornerShape(12.dp)) .background(color) @@ -231,10 +227,7 @@ private fun SwipeableConversationCard( } @Composable -private fun EmptyConversationsView( - modifier: Modifier = Modifier, - onNewConversation: () -> Unit -) { +private fun EmptyConversationsView(modifier: Modifier = Modifier, onNewConversation: () -> Unit) { Column( modifier = modifier.padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, @@ -242,7 +235,8 @@ private fun EmptyConversationsView( ) { // Icon circle Box( - modifier = Modifier + modifier = + Modifier .size(100.dp) .background( color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), @@ -268,7 +262,8 @@ private fun EmptyConversationsView( Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Start a secure conversation by meeting with someone in person and performing a key exchange ceremony.", + 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, textAlign = androidx.compose.ui.text.style.TextAlign.Center @@ -278,7 +273,8 @@ private fun EmptyConversationsView( Button( onClick = onNewConversation, - colors = ButtonDefaults.buttonColors( + colors = + ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary ) ) { @@ -295,10 +291,7 @@ private fun EmptyConversationsView( @OptIn(ExperimentalLayoutApi::class) @Composable -private fun MnemonicTagsRow( - mnemonic: List, - accentColor: Color -) { +private fun MnemonicTagsRow(mnemonic: List, accentColor: Color) { FlowRow( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp) @@ -321,23 +314,23 @@ private fun MnemonicTagsRow( } @Composable -private fun ConversationCard( - conversation: Conversation, - onClick: () -> Unit -) { +private fun ConversationCard(conversation: Conversation, onClick: () -> Unit) { val accentColor = Color(conversation.color.toColorLong()) Card( - modifier = Modifier + modifier = + Modifier .fillMaxWidth() .clickable(onClick = onClick), - colors = CardDefaults.cardColors( + colors = + CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ) ) { Column { Row( - modifier = Modifier + modifier = + Modifier .fillMaxWidth() .padding(16.dp), verticalAlignment = Alignment.CenterVertically @@ -415,20 +408,18 @@ private fun ConversationCard( } @Composable -private fun DualUsageBar( - myUsage: Float, - peerUsage: Float, - accentColor: Color -) { +private fun DualUsageBar(myUsage: Float, peerUsage: Float, accentColor: Color) { Box( - modifier = Modifier + modifier = + Modifier .fillMaxWidth() .height(4.dp) .background(MaterialTheme.colorScheme.surfaceVariant) ) { // My usage from left Box( - modifier = Modifier + modifier = + Modifier .fillMaxWidth(myUsage / 100f) .height(4.dp) .background(accentColor) @@ -436,7 +427,8 @@ private fun DualUsageBar( ) // Peer usage from right Box( - modifier = Modifier + modifier = + Modifier .fillMaxWidth(peerUsage / 100f) .height(4.dp) .background(accentColor.copy(alpha = 0.5f)) @@ -445,7 +437,6 @@ private fun DualUsageBar( } } - private fun formatTimestamp(timestamp: Long): String { val now = System.currentTimeMillis() val diff = now - timestamp diff --git a/apps/android/app/src/main/java/com/monadial/ash/ui/screens/LockScreen.kt b/apps/android/app/src/main/java/com/monadial/ash/ui/screens/LockScreen.kt index b64cf47..23113f7 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/ui/screens/LockScreen.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/ui/screens/LockScreen.kt @@ -27,10 +27,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import com.monadial.ash.ui.viewmodels.LockViewModel @Composable -fun LockScreen( - onUnlocked: () -> Unit, - viewModel: LockViewModel = hiltViewModel() -) { +fun LockScreen(onUnlocked: () -> Unit, viewModel: LockViewModel = hiltViewModel()) { val context = LocalContext.current val isUnlocked by viewModel.isUnlocked.collectAsState() val isBiometricAvailable by viewModel.isBiometricAvailable.collectAsState() @@ -42,7 +39,8 @@ fun LockScreen( } Column( - modifier = Modifier + modifier = + Modifier .fillMaxSize() .padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, 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 4337cba..0153b4a 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,7 +1,6 @@ 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 @@ -68,10 +67,8 @@ import com.monadial.ash.domain.entities.DeliveryStatus import com.monadial.ash.domain.entities.Message import com.monadial.ash.domain.entities.MessageDirection import com.monadial.ash.ui.viewmodels.MessagingViewModel -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale +@Suppress("UnusedParameter") @OptIn(ExperimentalMaterial3Api::class) @Composable fun MessagingScreen( @@ -90,8 +87,9 @@ fun MessagingScreen( val snackbarHostState = remember { SnackbarHostState() } val listState = rememberLazyListState() - val accentColor = conversation?.color?.let { Color(it.toColorLong()) } - ?: MaterialTheme.colorScheme.primary + val accentColor = + conversation?.color?.let { Color(it.toColorLong()) } + ?: MaterialTheme.colorScheme.primary // Scroll to bottom when new message arrives LaunchedEffect(messages.size) { @@ -139,7 +137,8 @@ fun MessagingScreen( Icon(Icons.Default.Info, contentDescription = "Info") } }, - colors = TopAppBarDefaults.topAppBarColors( + colors = + TopAppBarDefaults.topAppBarColors( containerColor = accentColor.copy(alpha = 0.1f) ) ) @@ -155,18 +154,20 @@ fun MessagingScreen( } ) { padding -> Column( - modifier = Modifier + modifier = + Modifier .fillMaxSize() .padding(padding) .imePadding() ) { // Pad usage bar if (conversation != null) { - val progressColor = when { - viewModel.padUsagePercentage > 90 -> MaterialTheme.colorScheme.error - viewModel.padUsagePercentage > 70 -> MaterialTheme.colorScheme.tertiary - else -> accentColor - } + 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(), @@ -176,7 +177,8 @@ fun MessagingScreen( // Messages list LazyColumn( - modifier = Modifier + modifier = + Modifier .weight(1f) .fillMaxWidth(), state = listState, @@ -186,7 +188,8 @@ fun MessagingScreen( if (isLoading) { item { Box( - modifier = Modifier + modifier = + Modifier .fillMaxWidth() .padding(32.dp), contentAlignment = Alignment.Center @@ -229,11 +232,7 @@ fun MessagingScreen( } @Composable -private fun MessageBubble( - message: Message, - accentColor: Color, - onRetry: () -> Unit -) { +private fun MessageBubble(message: Message, accentColor: Color, onRetry: () -> Unit) { val isSent = message.direction == MessageDirection.SENT val alignment = if (isSent) Alignment.CenterEnd else Alignment.CenterStart val backgroundColor = if (isSent) accentColor else MaterialTheme.colorScheme.surfaceVariant @@ -247,7 +246,8 @@ private fun MessageBubble( horizontalAlignment = if (isSent) Alignment.End else Alignment.Start ) { Surface( - shape = RoundedCornerShape( + shape = + RoundedCornerShape( topStart = 16.dp, topEnd = 16.dp, bottomStart = if (isSent) 16.dp else 4.dp, @@ -300,43 +300,45 @@ private fun MessageBubble( } @Composable -private fun MessageStatusIcon( - status: DeliveryStatus, - tint: Color -) { +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), - strokeWidth = 1.dp, - color = tint - ) - DeliveryStatus.SENT -> Icon( - Icons.Default.CheckCircle, - contentDescription = "Sent", - modifier = Modifier.size(14.dp), - tint = tint - ) - DeliveryStatus.DELIVERED -> Icon( - Icons.Default.CheckCircle, - contentDescription = "Delivered", - modifier = Modifier.size(14.dp), - tint = successColor - ) - is DeliveryStatus.FAILED -> Icon( - Icons.Default.Error, - contentDescription = "Failed", - modifier = Modifier.size(14.dp), - tint = errorColor - ) - DeliveryStatus.NONE -> Icon( - Icons.Default.Schedule, - contentDescription = "Pending", - modifier = Modifier.size(14.dp), - tint = tint - ) + DeliveryStatus.SENDING -> + CircularProgressIndicator( + modifier = Modifier.size(12.dp), + strokeWidth = 1.dp, + color = tint + ) + DeliveryStatus.SENT -> + Icon( + Icons.Default.CheckCircle, + contentDescription = "Sent", + modifier = Modifier.size(14.dp), + tint = tint + ) + DeliveryStatus.DELIVERED -> + Icon( + Icons.Default.CheckCircle, + contentDescription = "Delivered", + modifier = Modifier.size(14.dp), + tint = successColor + ) + is DeliveryStatus.FAILED -> + Icon( + Icons.Default.Error, + contentDescription = "Failed", + modifier = Modifier.size(14.dp), + tint = errorColor + ) + DeliveryStatus.NONE -> + Icon( + Icons.Default.Schedule, + contentDescription = "Pending", + modifier = Modifier.size(14.dp), + tint = tint + ) } } @@ -356,7 +358,8 @@ private fun MessageInput( color = MaterialTheme.colorScheme.surface ) { Row( - modifier = Modifier + modifier = + Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically @@ -387,7 +390,8 @@ private fun MessageInput( onValueChange = onTextChange, modifier = Modifier.weight(1f), placeholder = { Text("Message") }, - colors = TextFieldDefaults.colors( + colors = + TextFieldDefaults.colors( focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, focusedIndicatorColor = Color.Transparent, @@ -404,14 +408,16 @@ private fun MessageInput( IconButton( onClick = onSend, enabled = text.isNotBlank() && !isSending, - modifier = Modifier + modifier = + Modifier .size(48.dp) .clip(CircleShape) .background( - if (text.isNotBlank() && !isSending) + if (text.isNotBlank() && !isSending) { accentColor - else + } else { MaterialTheme.colorScheme.surfaceVariant + } ) ) { if (isSending) { @@ -424,8 +430,12 @@ private fun MessageInput( Icon( Icons.AutoMirrored.Filled.Send, contentDescription = "Send", - tint = if (text.isNotBlank()) Color.White - else MaterialTheme.colorScheme.onSurfaceVariant + tint = + if (text.isNotBlank()) { + Color.White + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } ) } } @@ -440,7 +450,8 @@ private fun EmptyMessagesPlaceholder( accentColor: Color = MaterialTheme.colorScheme.primary ) { Column( - modifier = Modifier + modifier = + Modifier .fillMaxWidth() .padding(48.dp), horizontalAlignment = Alignment.CenterHorizontally, @@ -448,7 +459,8 @@ private fun EmptyMessagesPlaceholder( ) { // Lock icon Box( - modifier = Modifier + modifier = + Modifier .size(80.dp) .background( color = accentColor.copy(alpha = 0.1f), @@ -517,10 +529,7 @@ private fun EmptyMessagesPlaceholder( } @Composable -private fun MnemonicTag( - word: String, - accentColor: Color -) { +private fun MnemonicTag(word: String, accentColor: Color) { Surface( shape = RoundedCornerShape(16.dp), color = accentColor.copy(alpha = 0.1f) @@ -535,15 +544,8 @@ private fun MnemonicTag( } } -private fun formatTime(timestamp: Long): String { - val sdf = SimpleDateFormat("HH:mm", Locale.getDefault()) - return sdf.format(Date(timestamp)) -} - -private fun formatBytes(bytes: Long): String { - return when { - bytes >= 1024 * 1024 -> "%.1f MB".format(bytes / (1024.0 * 1024.0)) - bytes >= 1024 -> "%.1f KB".format(bytes / 1024.0) - else -> "$bytes B" - } +private fun formatBytes(bytes: Long): String = when { + bytes >= 1024 * 1024 -> "%.1f MB".format(bytes / (1024.0 * 1024.0)) + bytes >= 1024 -> "%.1f KB".format(bytes / 1024.0) + else -> "$bytes B" } 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 f3b1e21..5848e38 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 @@ -15,14 +15,11 @@ 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 @@ -57,10 +54,7 @@ import com.monadial.ash.ui.viewmodels.SettingsViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SettingsScreen( - onBack: () -> Unit, - viewModel: SettingsViewModel = hiltViewModel() -) { +fun SettingsScreen(onBack: () -> Unit, viewModel: SettingsViewModel = hiltViewModel()) { val isBiometricEnabled by viewModel.isBiometricEnabled.collectAsState() val lockOnBackground by viewModel.lockOnBackground.collectAsState() val editedRelayUrl by viewModel.editedRelayUrl.collectAsState() @@ -84,7 +78,8 @@ fun SettingsScreen( } ) { padding -> Column( - modifier = Modifier + modifier = + Modifier .fillMaxSize() .padding(padding) .verticalScroll(rememberScrollState()) @@ -98,7 +93,8 @@ fun SettingsScreen( Card( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( + colors = + CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ) ) { @@ -130,7 +126,8 @@ fun SettingsScreen( Card( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( + colors = + CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ) ) { @@ -217,15 +214,25 @@ fun SettingsScreen( Row( verticalAlignment = Alignment.CenterVertically ) { + val statusColor = if (result.success) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.error + } Icon( - imageVector = if (result.success) Icons.Default.Wifi else Icons.Default.Warning, + imageVector = if (result.success) { + Icons.Default.Wifi + } else { + Icons.Default.Warning + }, contentDescription = null, modifier = Modifier.size(16.dp), - tint = if (result.success) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error + tint = statusColor ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = if (result.success) { + text = + if (result.success) { val latency = result.latencyMs?.let { "${it}ms" } ?: "" val version = result.version ?: "OK" "Connected ($version) $latency" @@ -233,7 +240,7 @@ fun SettingsScreen( "Failed: ${result.error ?: "Unknown error"}" }, style = MaterialTheme.typography.bodySmall, - color = if (result.success) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error + color = statusColor ) } } @@ -250,7 +257,8 @@ fun SettingsScreen( Card( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( + colors = + CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ) ) { @@ -285,7 +293,8 @@ fun SettingsScreen( Card( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( + colors = + CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.errorContainer ) ) { @@ -308,7 +317,8 @@ fun SettingsScreen( onClick = { showPanicBurnDialog = true }, enabled = !isBurningAll, modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors( + colors = + ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.error ) ) { @@ -352,7 +362,7 @@ fun SettingsScreen( text = { Text( "This will permanently destroy ALL encryption pads and messages across ALL conversations. " + - "Your peers will be notified. This action CANNOT be undone." + "Your peers will be notified. This action CANNOT be undone." ) }, confirmButton = { @@ -361,7 +371,8 @@ fun SettingsScreen( showPanicBurnDialog = false viewModel.burnAllConversations() }, - colors = ButtonDefaults.buttonColors( + colors = + ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.error ) ) { @@ -412,7 +423,8 @@ private fun SettingRow( enabled: Boolean = true ) { Row( - modifier = Modifier + modifier = + Modifier .fillMaxWidth() .padding(16.dp), verticalAlignment = Alignment.CenterVertically @@ -421,14 +433,22 @@ private fun SettingRow( Text( text = title, style = MaterialTheme.typography.bodyLarge, - color = if (enabled) MaterialTheme.colorScheme.onSurface - else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + color = + if (enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + } ) Text( text = description, style = MaterialTheme.typography.bodySmall, - color = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant - else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + color = + if (enabled) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + } ) } Switch( diff --git a/apps/android/app/src/main/java/com/monadial/ash/ui/state/CeremonyUiState.kt b/apps/android/app/src/main/java/com/monadial/ash/ui/state/CeremonyUiState.kt new file mode 100644 index 0000000..9ceef0d --- /dev/null +++ b/apps/android/app/src/main/java/com/monadial/ash/ui/state/CeremonyUiState.kt @@ -0,0 +1,142 @@ +package com.monadial.ash.ui.state + +import android.graphics.Bitmap +import com.monadial.ash.domain.entities.CeremonyPhase +import com.monadial.ash.domain.entities.ConsentState +import com.monadial.ash.domain.entities.ConversationColor +import com.monadial.ash.domain.entities.DisappearingMessages +import com.monadial.ash.domain.entities.MessageRetention +import com.monadial.ash.domain.entities.PadSize + +/** + * Sealed class representing connection test results. + */ +sealed class ConnectionTestResult { + data class Success(val version: String) : ConnectionTestResult() + data class Failure(val error: String) : ConnectionTestResult() +} + +/** + * UI state for the initiator ceremony screen. + * Consolidates all state into a single immutable data class. + */ +data class InitiatorCeremonyUiState( + // Phase tracking + val phase: CeremonyPhase = CeremonyPhase.SelectingPadSize, + + // Pad configuration + val selectedPadSize: PadSize = PadSize.MEDIUM, + val selectedColor: ConversationColor = ConversationColor.INDIGO, + val conversationName: String = "", + val relayUrl: String = "", + + // Message settings + val serverRetention: MessageRetention = MessageRetention.ONE_DAY, + val disappearingMessages: DisappearingMessages = DisappearingMessages.OFF, + + // Consent + val consent: ConsentState = ConsentState(), + + // Entropy collection + val entropyProgress: Float = 0f, + + // QR display + val currentQRBitmap: Bitmap? = null, + val currentFrameIndex: Int = 0, + val totalFrames: Int = 0, + + // Connection testing + val connectionTestResult: ConnectionTestResult? = null, + val isTestingConnection: Boolean = false, + + // Passphrase protection + val passphraseEnabled: Boolean = false, + val passphrase: String = "", + + // Playback controls + val isPaused: Boolean = false, + val fps: Int = 7 +) { + val canProceedToOptions: Boolean + get() = true // Pad size is always selected + + val canProceedToConsent: Boolean + get() = relayUrl.isNotBlank() + + val canConfirmConsent: Boolean + get() = consent.allConfirmed +} + +/** + * UI state for the receiver ceremony screen. + */ +data class ReceiverCeremonyUiState( + // Phase tracking + val phase: CeremonyPhase = CeremonyPhase.ConfiguringReceiver, + + // Conversation setup + val conversationName: String = "", + val selectedColor: ConversationColor = ConversationColor.INDIGO, + + // Scanning progress + val receivedBlocks: Int = 0, + val totalBlocks: Int = 0, + val progress: Float = 0f, + + // Passphrase protection + val passphraseEnabled: Boolean = false, + val passphrase: String = "" +) { + val canStartScanning: Boolean + get() = !passphraseEnabled || passphrase.isNotBlank() + + val progressPercentage: Int + get() = (progress * 100).toInt() +} + +/** + * UI state for the messaging screen. + */ +data class MessagingUiState( + val isLoading: Boolean = false, + val isSending: Boolean = false, + val isGettingLocation: Boolean = false, + val inputText: String = "", + val peerBurned: Boolean = false, + val error: String? = null, + + // Computed from conversation + val padUsagePercentage: Float = 0f, + val remainingBytes: Long = 0L +) + +/** + * UI state for the conversations list screen. + */ +data class ConversationsUiState( + val isRefreshing: Boolean = false, + val error: String? = null +) + +/** + * UI state for the settings screen. + */ +data class SettingsUiState( + // Saved relay URL + val relayUrl: String = "", + // Edited relay URL (for UI editing) + val editedRelayUrl: String = "", + // Biometric settings + val isBiometricEnabled: Boolean = false, + val lockOnBackground: Boolean = true, + // Connection testing + val isTestingConnection: Boolean = false, + val connectionTestResult: com.monadial.ash.core.services.ConnectionTestResult? = null, + // Burn all + val isBurningAll: Boolean = false, + // Error + val error: String? = null +) { + val hasUnsavedChanges: Boolean + get() = relayUrl != editedRelayUrl +} 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 203765e..1aea27c 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 @@ -2,6 +2,7 @@ package com.monadial.ash.ui.theme import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Shapes import androidx.compose.material3.Typography @@ -14,9 +15,8 @@ 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 +import androidx.compose.ui.unit.sp /** * ASH Theme - Material Design 3 compliant color scheme @@ -30,22 +30,17 @@ import androidx.compose.ui.unit.dp * See: https://m3.material.io/styles/color/roles */ -// ASH brand colors - Indigo theme private val AshIndigo = Color(0xFF5856D6) -private val AshIndigoLight = Color(0xFFE8E7FF) // Light container color -private val AshIndigoDark = Color(0xFF4240B0) +private val AshIndigoLight = Color(0xFFE8E7FF) -// 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) @@ -54,234 +49,228 @@ private val AshOnErrorDark = Color(0xFF690005) * Dark color scheme following Material 3 guidelines * Dark themes use lighter tones of colors */ -private val DarkColorScheme = darkColorScheme( - // 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), -) +private val DarkColorScheme = + darkColorScheme( + primary = Color(0xFFBFBDFF), + onPrimary = Color(0xFF2A2785), + primaryContainer = Color(0xFF413E9C), + onPrimaryContainer = Color(0xFFE2DFFF), + secondary = Color(0xFFC6C3DC), + onSecondary = Color(0xFF2F2D42), + secondaryContainer = Color(0xFF454359), + onSecondaryContainer = Color(0xFFE3DFF9), + tertiary = Color(0xFF80CBC4), + onTertiary = Color(0xFF00382F), + tertiaryContainer = Color(0xFF005046), + onTertiaryContainer = Color(0xFFA1F0E7), + error = AshErrorDark, + onError = AshOnErrorDark, + errorContainer = AshErrorContainerDark, + onErrorContainer = Color(0xFFFFDAD6), + background = Color(0xFF1C1B1F), + onBackground = Color(0xFFE6E1E5), + surface = Color(0xFF1C1B1F), + onSurface = Color(0xFFE6E1E5), + surfaceVariant = Color(0xFF49454F), + onSurfaceVariant = Color(0xFFCAC4D0), + outline = Color(0xFF938F99), + outlineVariant = Color(0xFF49454F), + inverseSurface = Color(0xFFE6E1E5), + inverseOnSurface = Color(0xFF313033), + inversePrimary = AshIndigo, + 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(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), - onBackground = Color(0xFF1C1B1F), - surface = Color(0xFFFFFBFE), - onSurface = Color(0xFF1C1B1F), - 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, -) +private val LightColorScheme = + lightColorScheme( + primary = AshIndigo, + onPrimary = Color.White, + primaryContainer = AshIndigoLight, + onPrimaryContainer = Color(0xFF1A1764), + secondary = Color(0xFF605D71), + onSecondary = Color.White, + secondaryContainer = Color(0xFFE6E0F9), + onSecondaryContainer = Color(0xFF1D1A2C), + tertiary = AshTeal, + onTertiary = Color.White, + tertiaryContainer = AshTealLight, + onTertiaryContainer = AshTealDark, + error = AshError, + onError = Color.White, + errorContainer = AshErrorLight, + onErrorContainer = AshOnErrorLight, + background = Color(0xFFFFFBFE), + onBackground = Color(0xFF1C1B1F), + surface = Color(0xFFFFFBFE), + onSurface = Color(0xFF1C1B1F), + surfaceVariant = Color(0xFFE7E0EC), + onSurfaceVariant = Color(0xFF49454F), + outline = Color(0xFF79747E), + outlineVariant = Color(0xFFCAC4D0), + inverseSurface = Color(0xFF313033), + inverseOnSurface = Color(0xFFF4EFF4), + inversePrimary = Color(0xFFBFBDFF), + 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 +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 -) +private val AshShapes = + Shapes( + extraSmall = RoundedCornerShape(4.dp), + small = RoundedCornerShape(8.dp), + medium = RoundedCornerShape(12.dp), + large = RoundedCornerShape(16.dp), + extraLarge = RoundedCornerShape(28.dp) + ) +/** + * ASH app theme composable. + * Dynamic color is disabled by default to maintain brand consistency. + */ @Composable fun AshTheme( darkTheme: Boolean = isSystemInDarkTheme(), - dynamicColor: Boolean = false, // Disabled to maintain brand consistency + dynamicColor: Boolean = false, content: @Composable () -> Unit ) { - val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + val colorScheme = + when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> { + DarkColorScheme + } + else -> { + LightColorScheme + } } - darkTheme -> DarkColorScheme - else -> LightColorScheme - } MaterialTheme( colorScheme = colorScheme, diff --git a/apps/android/app/src/main/java/com/monadial/ash/ui/viewmodels/AppViewModel.kt b/apps/android/app/src/main/java/com/monadial/ash/ui/viewmodels/AppViewModel.kt index de0c1ad..c9bae97 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/ui/viewmodels/AppViewModel.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/ui/viewmodels/AppViewModel.kt @@ -4,17 +4,14 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.monadial.ash.core.services.SettingsService import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel -class AppViewModel @Inject constructor( - private val settingsService: SettingsService -) : ViewModel() { - +class AppViewModel @Inject constructor(private val settingsService: SettingsService) : ViewModel() { private val _isLocked = MutableStateFlow(true) val isLocked: StateFlow = _isLocked.asStateFlow() diff --git a/apps/android/app/src/main/java/com/monadial/ash/ui/viewmodels/ConversationInfoViewModel.kt b/apps/android/app/src/main/java/com/monadial/ash/ui/viewmodels/ConversationInfoViewModel.kt index 6eb4a8b..a06ea85 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/ui/viewmodels/ConversationInfoViewModel.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/ui/viewmodels/ConversationInfoViewModel.kt @@ -7,11 +7,11 @@ import com.monadial.ash.core.services.ConversationStorageService import com.monadial.ash.core.services.RelayService import com.monadial.ash.domain.entities.Conversation import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class ConversationInfoViewModel @Inject constructor( @@ -19,7 +19,6 @@ class ConversationInfoViewModel @Inject constructor( private val conversationStorage: ConversationStorageService, private val relayService: RelayService ) : ViewModel() { - private val conversationId: String = savedStateHandle["conversationId"]!! private val _conversation = MutableStateFlow(null) diff --git a/apps/android/app/src/main/java/com/monadial/ash/ui/viewmodels/ConversationsViewModel.kt b/apps/android/app/src/main/java/com/monadial/ash/ui/viewmodels/ConversationsViewModel.kt index a07d399..69c1e5e 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/ui/viewmodels/ConversationsViewModel.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/ui/viewmodels/ConversationsViewModel.kt @@ -2,30 +2,55 @@ package com.monadial.ash.ui.viewmodels import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.monadial.ash.core.services.ConversationStorageService -import com.monadial.ash.core.services.RelayService +import com.monadial.ash.core.common.AppResult import com.monadial.ash.domain.entities.Conversation +import com.monadial.ash.domain.repositories.ConversationRepository +import com.monadial.ash.domain.repositories.PadRepository +import com.monadial.ash.domain.usecases.conversation.BurnConversationUseCase +import com.monadial.ash.domain.usecases.conversation.CheckBurnStatusUseCase +import com.monadial.ash.domain.usecases.conversation.GetConversationsUseCase import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import javax.inject.Inject +/** + * ViewModel for the conversations list screen. + * + * Follows Clean Architecture by: + * - Using Use Cases for business logic (burn, check burn status) + * - Using Repositories for data access + * - Only handling UI state and user interactions + */ @HiltViewModel class ConversationsViewModel @Inject constructor( - private val conversationStorage: ConversationStorageService, - private val relayService: RelayService + private val getConversationsUseCase: GetConversationsUseCase, + private val burnConversationUseCase: BurnConversationUseCase, + private val checkBurnStatusUseCase: CheckBurnStatusUseCase, + private val conversationRepository: ConversationRepository, + private val padRepository: PadRepository ) : ViewModel() { - val conversations: StateFlow> = conversationStorage.conversations + val conversations: StateFlow> = getConversationsUseCase.conversations private val _isRefreshing = MutableStateFlow(false) val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + init { + loadConversations() + } + + private fun loadConversations() { viewModelScope.launch { - conversationStorage.loadConversations() + when (val result = getConversationsUseCase()) { + is AppResult.Success -> _error.value = null + is AppResult.Error -> _error.value = result.error.message + } } } @@ -33,11 +58,13 @@ class ConversationsViewModel @Inject constructor( viewModelScope.launch { _isRefreshing.value = true try { - conversationStorage.loadConversations() + // Reload conversations + getConversationsUseCase.refresh() // Check burn status for each conversation - conversations.value.forEach { conv -> - checkBurnStatus(conv) + val currentConversations = conversations.value + if (currentConversations.isNotEmpty()) { + checkBurnStatusUseCase.checkAll(currentConversations) } } finally { _isRefreshing.value = false @@ -45,46 +72,29 @@ class ConversationsViewModel @Inject constructor( } } - private suspend fun checkBurnStatus(conversation: Conversation) { - val result = relayService.checkBurnStatus( - conversationId = conversation.id, - authToken = conversation.authToken, - relayUrl = conversation.relayUrl - ) - result.onSuccess { status -> - if (status.burned && conversation.peerBurnedAt == null) { - // Peer has burned - update local state - val updated = conversation.copy(peerBurnedAt = System.currentTimeMillis()) - conversationStorage.saveConversation(updated) - } - } - } - fun burnConversation(conversation: Conversation) { viewModelScope.launch { - // 1. Notify relay (fire-and-forget) - try { - relayService.burnConversation( - conversationId = conversation.id, - burnToken = conversation.burnToken, - relayUrl = conversation.relayUrl - ) - } catch (_: Exception) { - // Continue even if relay notification fails + when (val result = burnConversationUseCase(conversation)) { + is AppResult.Success -> { + // Successfully burned - list will auto-update via StateFlow + } + is AppResult.Error -> { + _error.value = "Failed to burn conversation: ${result.error.message}" + } } - - // 2. Delete pad bytes (secure wipe) - conversationStorage.deletePadBytes(conversation.id) - - // 3. Delete conversation record - conversationStorage.deleteConversation(conversation.id) } } fun deleteConversation(conversationId: String) { viewModelScope.launch { - conversationStorage.deleteConversation(conversationId) - conversationStorage.deletePadBytes(conversationId) + // Wipe pad bytes first (secure deletion) + padRepository.wipePad(conversationId) + // Then delete conversation record + conversationRepository.deleteConversation(conversationId) } } + + fun clearError() { + _error.value = null + } } 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 e10b34c..f7e05db 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 @@ -4,12 +4,7 @@ import android.graphics.Bitmap import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.monadial.ash.core.services.AshCoreService -import com.monadial.ash.core.services.ConversationStorageService -import com.monadial.ash.core.services.PadManager -import com.monadial.ash.core.services.QRCodeService -import com.monadial.ash.core.services.RelayService -import com.monadial.ash.core.services.SettingsService +import com.monadial.ash.domain.services.QRCodeService import com.monadial.ash.domain.entities.CeremonyError import com.monadial.ash.domain.entities.CeremonyPhase import com.monadial.ash.domain.entities.ConsentState @@ -19,219 +14,188 @@ import com.monadial.ash.domain.entities.ConversationRole 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.domain.repositories.ConversationRepository +import com.monadial.ash.domain.repositories.PadRepository +import com.monadial.ash.domain.repositories.SettingsRepository +import com.monadial.ash.domain.services.CryptoService +import com.monadial.ash.domain.services.RelayService +import com.monadial.ash.domain.usecases.conversation.RegisterConversationUseCase +import com.monadial.ash.ui.state.ConnectionTestResult +import com.monadial.ash.ui.state.InitiatorCeremonyUiState import dagger.hilt.android.lifecycle.HiltViewModel +import java.security.SecureRandom +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import uniffi.ash.CeremonyMetadata import uniffi.ash.FountainFrameGenerator -import java.security.SecureRandom -import javax.inject.Inject +/** + * ViewModel for the initiator (sender) ceremony flow. + * + * Follows Clean Architecture and MVVM patterns: + * - Single UiState for all screen state (reduces compose recomposition) + * - Repositories for data access + * - Domain Services for crypto operations + * - Use Cases for business logic + */ @HiltViewModel class InitiatorCeremonyViewModel @Inject constructor( - private val settingsService: SettingsService, + private val settingsRepository: SettingsRepository, private val qrCodeService: QRCodeService, - private val conversationStorage: ConversationStorageService, + private val conversationRepository: ConversationRepository, private val relayService: RelayService, - private val ashCoreService: AshCoreService, - private val padManager: PadManager + private val cryptoService: CryptoService, + private val padRepository: PadRepository, + private val registerConversationUseCase: RegisterConversationUseCase ) : ViewModel() { - // Note: relayService is already injected, used for connection testing and conversation registration companion object { private const val TAG = "InitiatorCeremonyVM" - // 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 + private const val ENTROPY_TARGET_BYTES = 750 + private const val DEFAULT_FPS = 7 } - // State - private val _phase = MutableStateFlow(CeremonyPhase.SelectingPadSize) - val phase: StateFlow = _phase.asStateFlow() - - private val _selectedPadSize = MutableStateFlow(PadSize.MEDIUM) - val selectedPadSize: StateFlow = _selectedPadSize.asStateFlow() - - private val _selectedColor = MutableStateFlow(ConversationColor.INDIGO) - val selectedColor: StateFlow = _selectedColor.asStateFlow() - - private val _conversationName = MutableStateFlow("") - val conversationName: StateFlow = _conversationName.asStateFlow() - - private val _relayUrl = MutableStateFlow("") - val relayUrl: StateFlow = _relayUrl.asStateFlow() - - private val _serverRetention = MutableStateFlow(MessageRetention.ONE_DAY) - val serverRetention: StateFlow = _serverRetention.asStateFlow() - - private val _disappearingMessages = MutableStateFlow(DisappearingMessages.OFF) - val disappearingMessages: StateFlow = _disappearingMessages.asStateFlow() - - private val _consent = MutableStateFlow(ConsentState()) - val consent: StateFlow = _consent.asStateFlow() - - private val _entropyProgress = MutableStateFlow(0f) - val entropyProgress: StateFlow = _entropyProgress.asStateFlow() + // Single consolidated UI state + private val _uiState = MutableStateFlow(InitiatorCeremonyUiState()) + val uiState: StateFlow = _uiState.asStateFlow() - private val _currentQRBitmap = MutableStateFlow(null) - val currentQRBitmap: StateFlow = _currentQRBitmap.asStateFlow() - - private val _currentFrameIndex = MutableStateFlow(0) - val currentFrameIndex: StateFlow = _currentFrameIndex.asStateFlow() - - private val _totalFrames = MutableStateFlow(0) - val totalFrames: StateFlow = _totalFrames.asStateFlow() - - private val _connectionTestResult = MutableStateFlow(null) - val connectionTestResult: StateFlow = _connectionTestResult.asStateFlow() - - 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 + // Internal state (not exposed to UI) private val collectedEntropy = mutableListOf() private var generatedPadBytes: ByteArray? = null private var preGeneratedQRImages: List = emptyList() private var displayJob: Job? = null private var mnemonic: List = emptyList() private var fountainGenerator: FountainFrameGenerator? = null - private var isGeneratingPad: Boolean = false // Guard against multiple generatePad() calls + private var isGeneratingPad: Boolean = false - sealed class ConnectionTestResult { - data class Success(val version: String) : ConnectionTestResult() - data class Failure(val error: String) : ConnectionTestResult() + init { + loadInitialSettings() } - init { + private fun loadInitialSettings() { viewModelScope.launch { - _relayUrl.value = settingsService.getRelayUrl() + val relayUrl = settingsRepository.relayServerUrl.first() + _uiState.update { it.copy(relayUrl = relayUrl) } } } // MARK: - Pad Size Selection fun selectPadSize(size: PadSize) { - _selectedPadSize.value = size + _uiState.update { it.copy(selectedPadSize = size) } } fun setPassphraseEnabled(enabled: Boolean) { - _passphraseEnabled.value = enabled - if (!enabled) { - _passphrase.value = "" + _uiState.update { + it.copy( + passphraseEnabled = enabled, + passphrase = if (!enabled) "" else it.passphrase + ) } } fun setPassphrase(value: String) { - _passphrase.value = value + _uiState.update { it.copy(passphrase = value) } } fun proceedToOptions() { - _phase.value = CeremonyPhase.ConfiguringOptions + _uiState.update { it.copy(phase = CeremonyPhase.ConfiguringOptions) } } // MARK: - Options Configuration fun setConversationName(name: String) { - _conversationName.value = name + _uiState.update { it.copy(conversationName = name) } } fun setRelayUrl(url: String) { - _relayUrl.value = url - _connectionTestResult.value = null + _uiState.update { + it.copy( + relayUrl = url, + connectionTestResult = null + ) + } } fun setSelectedColor(color: ConversationColor) { - _selectedColor.value = color + _uiState.update { it.copy(selectedColor = color) } } fun setServerRetention(retention: MessageRetention) { - _serverRetention.value = retention + _uiState.update { it.copy(serverRetention = retention) } } fun setDisappearingMessages(setting: DisappearingMessages) { - _disappearingMessages.value = setting + _uiState.update { it.copy(disappearingMessages = setting) } } fun testRelayConnection() { viewModelScope.launch { - _isTestingConnection.value = true - _connectionTestResult.value = null + _uiState.update { it.copy(isTestingConnection = true, connectionTestResult = null) } try { - val result = relayService.testConnection(_relayUrl.value) - _connectionTestResult.value = if (result.success) { + val result = relayService.testConnection(_uiState.value.relayUrl) + val testResult = if (result.success) { ConnectionTestResult.Success(result.version ?: "OK") } else { ConnectionTestResult.Failure(result.error ?: "Connection failed") } + _uiState.update { it.copy(connectionTestResult = testResult) } } catch (e: Exception) { - _connectionTestResult.value = ConnectionTestResult.Failure(e.message ?: "Unknown error") + _uiState.update { + it.copy(connectionTestResult = ConnectionTestResult.Failure(e.message ?: "Unknown error")) + } } finally { - _isTestingConnection.value = false + _uiState.update { it.copy(isTestingConnection = false) } } } } fun proceedToConsent() { - _phase.value = CeremonyPhase.ConfirmingConsent + _uiState.update { it.copy(phase = CeremonyPhase.ConfirmingConsent) } } // MARK: - Consent fun updateConsent(consent: ConsentState) { - _consent.value = consent + _uiState.update { it.copy(consent = consent) } } fun confirmConsent() { - if (_consent.value.allConfirmed) { - _phase.value = CeremonyPhase.CollectingEntropy + if (_uiState.value.canConfirmConsent) { + _uiState.update { it.copy(phase = CeremonyPhase.CollectingEntropy) } } } // MARK: - Entropy Collection fun addEntropy(x: Float, y: Float) { - // Don't collect more entropy once we've started generating the pad 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()) - // Require 750 bytes of entropy (~150 touch points with 5 bytes each) - _entropyProgress.value = minOf(1f, collectedEntropy.size / 750f) + val progress = minOf(1f, collectedEntropy.size / ENTROPY_TARGET_BYTES.toFloat()) + _uiState.update { it.copy(entropyProgress = progress) } - if (_entropyProgress.value >= 1f && !isGeneratingPad) { + if (progress >= 1f && !isGeneratingPad) { isGeneratingPad = true generatePad() } @@ -240,150 +204,136 @@ class InitiatorCeremonyViewModel @Inject constructor( // MARK: - Pad Generation private fun generatePad() { - // Take a snapshot of entropy immediately to avoid ConcurrentModificationException val entropySnapshot = collectedEntropy.toList() - val padSize = _selectedPadSize.value.bytes.toInt() + val padSize = _uiState.value.selectedPadSize.bytes.toInt() viewModelScope.launch { - _phase.value = CeremonyPhase.GeneratingPad + _uiState.update { it.copy(phase = CeremonyPhase.GeneratingPad) } try { val padBytes = withContext(Dispatchers.Default) { - generatePadBytes( - entropy = entropySnapshot.toByteArray(), - sizeBytes = padSize - ) + generatePadBytes(entropySnapshot.toByteArray(), padSize) } generatedPadBytes = padBytes Log.d(TAG, "Generated pad: ${padBytes.size} bytes") + logPadDebugInfo(padBytes) - // Log first and last 16 bytes for debugging cross-platform - val firstBytes = padBytes.take(16).joinToString("") { String.format("%02X", it) } - val lastBytes = padBytes.takeLast(16).joinToString("") { String.format("%02X", it) } - Log.d(TAG, "Pad first 16 bytes: $firstBytes") - Log.d(TAG, "Pad last 16 bytes: $lastBytes") - - // Generate mnemonic from pad using FFI - mnemonic = ashCoreService.generateMnemonic(padBytes) + mnemonic = cryptoService.generateMnemonic(padBytes) Log.d(TAG, "Generated mnemonic: ${mnemonic.joinToString(" ")}") - // Derive tokens for debugging - try { - val tokens = ashCoreService.deriveAllTokens(padBytes) - Log.d(TAG, "Derived conversation ID: ${tokens.conversationId}") - Log.d(TAG, "Derived auth token: ${tokens.authToken.take(16)}...") - } catch (e: Exception) { - Log.w(TAG, "Could not derive tokens for debug: ${e.message}") - } - - // Generate QR codes using FFI fountain codes preGenerateQRCodes(padBytes) } catch (e: Exception) { Log.e(TAG, "Failed to generate pad: ${e.message}", e) - _phase.value = CeremonyPhase.Failed(CeremonyError.QR_GENERATION_FAILED) + _uiState.update { it.copy(phase = CeremonyPhase.Failed(CeremonyError.QR_GENERATION_FAILED)) } } } } private fun generatePadBytes(entropy: ByteArray, sizeBytes: Int): ByteArray { - // Mix user entropy with secure random for additional security val secureRandom = SecureRandom() val systemEntropy = ByteArray(32) secureRandom.nextBytes(systemEntropy) - // Combine entropies val combinedEntropy = entropy + systemEntropy - - // Generate pad bytes - use simple secure random with combined entropy as seed - // The Rust Pad.fromEntropy expects exactly padSize bytes of entropy - // So we generate padSize bytes using seeded SecureRandom val seededRandom = SecureRandom(combinedEntropy) - val pad = ByteArray(sizeBytes) - seededRandom.nextBytes(pad) - return pad + return ByteArray(sizeBytes).also { seededRandom.nextBytes(it) } + } + + private fun logPadDebugInfo(padBytes: ByteArray) { + val firstBytes = padBytes.take(16).joinToString("") { String.format("%02X", it) } + val lastBytes = padBytes.takeLast(16).joinToString("") { String.format("%02X", it) } + Log.d(TAG, "Pad first 16 bytes: $firstBytes") + Log.d(TAG, "Pad last 16 bytes: $lastBytes") + + try { + val tokens = cryptoService.deriveAllTokens(padBytes) + Log.d(TAG, "Derived conversation ID: ${tokens.conversationId}") + Log.d(TAG, "Derived auth token: ${tokens.authToken.take(16)}...") + } catch (e: Exception) { + Log.w(TAG, "Could not derive tokens for debug: ${e.message}") + } } private suspend fun preGenerateQRCodes(padBytes: ByteArray) { + val state = _uiState.value + try { - // Build ceremony metadata using FFI struct val metadata = CeremonyMetadata( version = 1u, - ttlSeconds = _serverRetention.value.seconds.toULong(), - disappearingMessagesSeconds = (_disappearingMessages.value.seconds ?: 0).toUInt(), - notificationFlags = buildNotificationFlags(), - relayUrl = _relayUrl.value + ttlSeconds = state.serverRetention.seconds.toULong(), + disappearingMessagesSeconds = (state.disappearingMessages.seconds ?: 0).toUInt(), + notificationFlags = buildNotificationFlags(state.selectedColor), + relayUrl = state.relayUrl ) - // Use passphrase if enabled, otherwise null - val passphraseToUse = if (_passphraseEnabled.value) _passphrase.value.ifEmpty { null } else null + val passphraseToUse = if (state.passphraseEnabled) { + state.passphrase.ifEmpty { null } + } else null - Log.d(TAG, "Creating fountain generator: blockSize=$FOUNTAIN_BLOCK_SIZE, passphraseEnabled=${_passphraseEnabled.value}") + Log.d(TAG, "Creating fountain generator: blockSize=$FOUNTAIN_BLOCK_SIZE, passphraseEnabled=${state.passphraseEnabled}") - // Create fountain generator using FFI val generator = withContext(Dispatchers.Default) { - ashCoreService.createFountainGenerator( - metadata = metadata, - padBytes = padBytes, - blockSize = FOUNTAIN_BLOCK_SIZE, - passphrase = passphraseToUse - ) + cryptoService.createFountainGenerator(metadata, padBytes, FOUNTAIN_BLOCK_SIZE, passphraseToUse) } fountainGenerator = generator val sourceCount = generator.sourceCount().toInt() - _totalFrames.value = sourceCount + _uiState.update { it.copy(totalFrames = sourceCount) } + + Log.d(TAG, "Fountain generator created: sourceCount=$sourceCount") - Log.d(TAG, "Fountain generator created: sourceCount=$sourceCount, blockSize=${generator.blockSize()}, totalSize=${generator.totalSize()}") + val images = generateQRImages(generator, sourceCount) - val images = mutableListOf() + if (images.size == sourceCount) { + Log.d(TAG, "Successfully generated ${images.size} QR codes") + preGeneratedQRImages = images + _uiState.update { + it.copy(phase = CeremonyPhase.Transferring(currentFrame = 0, totalFrames = sourceCount)) + } + startDisplayCycling() + } + } catch (e: Exception) { + Log.e(TAG, "Failed to generate QR codes: ${e.message}", e) + _uiState.update { it.copy(phase = CeremonyPhase.Failed(CeremonyError.QR_GENERATION_FAILED)) } + } + } - withContext(Dispatchers.Default) { - for (index in 0 until sourceCount) { - _phase.value = CeremonyPhase.GeneratingQRCodes( - progress = (index + 1).toFloat() / sourceCount, - total = sourceCount + private suspend fun generateQRImages(generator: FountainFrameGenerator, sourceCount: Int): List { + val images = mutableListOf() + + withContext(Dispatchers.Default) { + for (index in 0 until sourceCount) { + _uiState.update { + it.copy( + phase = CeremonyPhase.GeneratingQRCodes( + progress = (index + 1).toFloat() / sourceCount, + total = sourceCount + ) ) + } - // Generate frame using FFI - val frameBytes = with(ashCoreService) { - generator.generateFrameBytes(index.toUInt()) - } + val frameBytes = cryptoService.generateFrameBytes(generator, index.toUInt()) + val bitmap = qrCodeService.generate(frameBytes, QR_CODE_SIZE) - Log.d(TAG, "Frame $index: ${frameBytes.size} bytes") - - val bitmap = qrCodeService.generate(frameBytes, QR_CODE_SIZE) - if (bitmap != null) { - images.add(bitmap) - } else { - Log.e(TAG, "Failed to generate QR code for frame $index") - withContext(Dispatchers.Main) { - _phase.value = CeremonyPhase.Failed(CeremonyError.QR_GENERATION_FAILED) - } - return@withContext + if (bitmap != null) { + images.add(bitmap) + } else { + Log.e(TAG, "Failed to generate QR code for frame $index") + withContext(Dispatchers.Main) { + _uiState.update { it.copy(phase = CeremonyPhase.Failed(CeremonyError.QR_GENERATION_FAILED)) } } + return@withContext } } - - Log.d(TAG, "Successfully generated ${images.size} QR codes") - preGeneratedQRImages = images - _phase.value = CeremonyPhase.Transferring(currentFrame = 0, totalFrames = sourceCount) - startDisplayCycling() - } catch (e: Exception) { - Log.e(TAG, "Failed to generate QR codes: ${e.message}", e) - _phase.value = CeremonyPhase.Failed(CeremonyError.QR_GENERATION_FAILED) } + + return images } - private fun buildNotificationFlags(): UShort { - // Notification flags bitfield: - // Bit 0: NOTIFY_NEW_MESSAGE (0x0001) - // Bit 1: NOTIFY_MESSAGE_EXPIRING (0x0002) - // Bit 2: NOTIFY_MESSAGE_EXPIRED (0x0004) - // Bit 8: NOTIFY_DELIVERY_FAILED (0x0100) - // Bits 12-15: Color encoding - val colorIndex = _selectedColor.value.ordinal - val flags = (colorIndex shl 12) or 0x0103 // Default: new message + expiring + delivery failed + private fun buildNotificationFlags(color: ConversationColor): UShort { + val colorIndex = color.ordinal + val flags = (colorIndex shl 12) or 0x0103 return flags.toUShort() } @@ -391,31 +341,44 @@ class InitiatorCeremonyViewModel @Inject constructor( private fun startDisplayCycling() { displayJob?.cancel() - _currentFrameIndex.value = 0 - _isPaused.value = false - _currentQRBitmap.value = preGeneratedQRImages.firstOrNull() + _uiState.update { + it.copy( + currentFrameIndex = 0, + isPaused = false, + currentQRBitmap = preGeneratedQRImages.firstOrNull() + ) + } displayJob = viewModelScope.launch { while (isActive && preGeneratedQRImages.isNotEmpty()) { - val delayMs = 1000L / _fps.value + val delayMs = 1000L / _uiState.value.fps 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 - ) - } + if (!_uiState.value.isPaused) { + advanceFrame() } } } } + private fun advanceFrame() { + val nextIndex = (_uiState.value.currentFrameIndex + 1) % preGeneratedQRImages.size + updateFrame(nextIndex) + } + + private fun updateFrame(index: Int) { + val totalFrames = _uiState.value.totalFrames + _uiState.update { + it.copy( + currentFrameIndex = index, + currentQRBitmap = preGeneratedQRImages.getOrNull(index), + phase = if (it.phase is CeremonyPhase.Transferring) { + CeremonyPhase.Transferring(currentFrame = index % totalFrames, totalFrames = totalFrames) + } else it.phase + ) + } + } + fun stopDisplayCycling() { displayJob?.cancel() displayJob = null @@ -424,179 +387,121 @@ class InitiatorCeremonyViewModel @Inject constructor( // MARK: - Playback Controls fun togglePause() { - _isPaused.value = !_isPaused.value + _uiState.update { it.copy(isPaused = !it.isPaused) } } fun setFps(newFps: Int) { - _fps.value = newFps.coerceIn(1, 10) + _uiState.update { it.copy(fps = 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) + val current = _uiState.value.currentFrameIndex + val prevIndex = if (current > 0) current - 1 else preGeneratedQRImages.size - 1 + updateFrame(prevIndex) } fun nextFrame() { if (preGeneratedQRImages.isEmpty()) return - val nextIndex = (_currentFrameIndex.value + 1) % preGeneratedQRImages.size - _currentFrameIndex.value = nextIndex - _currentQRBitmap.value = preGeneratedQRImages[nextIndex] - updateTransferringPhase(nextIndex) + val nextIndex = (_uiState.value.currentFrameIndex + 1) % preGeneratedQRImages.size + updateFrame(nextIndex) } fun firstFrame() { if (preGeneratedQRImages.isEmpty()) return - _currentFrameIndex.value = 0 - _currentQRBitmap.value = preGeneratedQRImages.first() - updateTransferringPhase(0) + updateFrame(0) } fun lastFrame() { if (preGeneratedQRImages.isEmpty()) return - val lastIndex = preGeneratedQRImages.size - 1 - _currentFrameIndex.value = lastIndex - _currentQRBitmap.value = preGeneratedQRImages[lastIndex] - updateTransferringPhase(lastIndex) + updateFrame(preGeneratedQRImages.size - 1) } 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 - ) - } + _uiState.update { it.copy(isPaused = false) } + updateFrame(0) } // MARK: - Verification fun finishSending() { stopDisplayCycling() - _phase.value = CeremonyPhase.Verifying(mnemonic = mnemonic) + _uiState.update { it.copy(phase = CeremonyPhase.Verifying(mnemonic = mnemonic)) } } fun confirmVerification(): Conversation? { val padBytes = generatedPadBytes ?: return null + val state = _uiState.value try { - // Derive all tokens using FFI - val tokens = ashCoreService.deriveAllTokens(padBytes) + val tokens = cryptoService.deriveAllTokens(padBytes) val conversation = Conversation( id = tokens.conversationId, - name = _conversationName.value.ifBlank { null }, - relayUrl = _relayUrl.value, + name = state.conversationName.ifBlank { null }, + relayUrl = state.relayUrl, authToken = tokens.authToken, burnToken = tokens.burnToken, role = ConversationRole.INITIATOR, - color = _selectedColor.value, + color = state.selectedColor, createdAt = System.currentTimeMillis(), padTotalSize = padBytes.size.toLong(), mnemonic = mnemonic, - messageRetention = _serverRetention.value, - disappearingMessages = _disappearingMessages.value + messageRetention = state.serverRetention, + disappearingMessages = state.disappearingMessages ) viewModelScope.launch { - conversationStorage.saveConversation(conversation) - padManager.storePad(padBytes, conversation.id) - - // Register conversation with relay (fire-and-forget) - registerConversationWithRelay(conversation) + conversationRepository.saveConversation(conversation) + padRepository.storePad(conversation.id, padBytes) + registerConversationUseCase(conversation) } - _phase.value = CeremonyPhase.Completed(conversation) + _uiState.update { it.copy(phase = CeremonyPhase.Completed(conversation)) } return conversation } catch (e: Exception) { Log.e(TAG, "Failed to confirm verification: ${e.message}", e) - _phase.value = CeremonyPhase.Failed(CeremonyError.QR_GENERATION_FAILED) + _uiState.update { it.copy(phase = CeremonyPhase.Failed(CeremonyError.QR_GENERATION_FAILED)) } return null } } - private suspend fun registerConversationWithRelay(conversation: Conversation) { - try { - val authTokenHash = relayService.hashToken(conversation.authToken) - val burnTokenHash = relayService.hashToken(conversation.burnToken) - val result = relayService.registerConversation( - conversationId = conversation.id, - authTokenHash = authTokenHash, - burnTokenHash = burnTokenHash, - relayUrl = conversation.relayUrl - ) - if (result.isSuccess) { - Log.d(TAG, "Conversation registered with relay") - } else { - Log.w(TAG, "Failed to register conversation with relay: ${result.exceptionOrNull()?.message}") - } - } catch (e: Exception) { - Log.w(TAG, "Failed to register conversation with relay: ${e.message}") - } - } - fun rejectVerification() { - _phase.value = CeremonyPhase.Failed(CeremonyError.CHECKSUM_MISMATCH) + _uiState.update { it.copy(phase = CeremonyPhase.Failed(CeremonyError.CHECKSUM_MISMATCH)) } } // MARK: - Reset & Cancel fun reset() { stopDisplayCycling() - fountainGenerator?.close() - fountainGenerator = null + cleanupResources() + + viewModelScope.launch { + val relayUrl = settingsRepository.relayServerUrl.first() + _uiState.value = InitiatorCeremonyUiState(relayUrl = relayUrl) + } - _phase.value = CeremonyPhase.SelectingPadSize - _selectedPadSize.value = PadSize.MEDIUM - _selectedColor.value = ConversationColor.INDIGO - _conversationName.value = "" - _serverRetention.value = MessageRetention.ONE_DAY - _disappearingMessages.value = DisappearingMessages.OFF - _consent.value = ConsentState() - _entropyProgress.value = 0f - _currentQRBitmap.value = null - _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() mnemonic = emptyList() isGeneratingPad = false - - viewModelScope.launch { - _relayUrl.value = settingsService.getRelayUrl() - } } fun cancel() { stopDisplayCycling() + cleanupResources() + _uiState.update { it.copy(phase = CeremonyPhase.Failed(CeremonyError.CANCELLED)) } + } + + private fun cleanupResources() { fountainGenerator?.close() fountainGenerator = null - _phase.value = CeremonyPhase.Failed(CeremonyError.CANCELLED) } override fun onCleared() { super.onCleared() stopDisplayCycling() - fountainGenerator?.close() - fountainGenerator = null + cleanupResources() } } diff --git a/apps/android/app/src/main/java/com/monadial/ash/ui/viewmodels/LockViewModel.kt b/apps/android/app/src/main/java/com/monadial/ash/ui/viewmodels/LockViewModel.kt index 58c7247..d253677 100644 --- a/apps/android/app/src/main/java/com/monadial/ash/ui/viewmodels/LockViewModel.kt +++ b/apps/android/app/src/main/java/com/monadial/ash/ui/viewmodels/LockViewModel.kt @@ -6,18 +6,17 @@ import androidx.lifecycle.viewModelScope import com.monadial.ash.core.services.BiometricService import com.monadial.ash.core.services.SettingsService import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class LockViewModel @Inject constructor( private val biometricService: BiometricService, private val settingsService: SettingsService ) : ViewModel() { - private val _isUnlocked = MutableStateFlow(false) val isUnlocked: StateFlow = _isUnlocked.asStateFlow() @@ -38,11 +37,12 @@ class LockViewModel @Inject constructor( fun authenticate(activity: FragmentActivity) { viewModelScope.launch { - val success = biometricService.authenticate( - activity = activity, - title = "Unlock ASH", - subtitle = "Verify your identity to access secure messages" - ) + val success = + biometricService.authenticate( + activity = activity, + title = "Unlock ASH", + subtitle = "Verify your identity to access secure messages" + ) if (success) { _isUnlocked.value = true } 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 0b1d5c1..73e5cbe 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 @@ -4,40 +4,54 @@ import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.monadial.ash.core.services.AshCoreService -import com.monadial.ash.core.services.ConversationStorageService -import com.monadial.ash.core.services.LocationService -import com.monadial.ash.core.services.PadManager -import com.monadial.ash.core.services.ReceivedMessage -import com.monadial.ash.core.services.RelayService +import com.monadial.ash.core.common.AppResult +import com.monadial.ash.domain.services.LocationError +import com.monadial.ash.domain.services.LocationService import com.monadial.ash.core.services.SSEEvent -import com.monadial.ash.core.services.SSEService -import uniffi.ash.Role import com.monadial.ash.domain.entities.Conversation -import com.monadial.ash.domain.entities.ConversationRole import com.monadial.ash.domain.entities.DeliveryStatus import com.monadial.ash.domain.entities.Message import com.monadial.ash.domain.entities.MessageContent import com.monadial.ash.domain.entities.MessageDirection +import com.monadial.ash.domain.repositories.ConversationRepository +import com.monadial.ash.domain.services.RealtimeService +import com.monadial.ash.domain.services.RelayService +import com.monadial.ash.domain.usecases.conversation.CheckBurnStatusUseCase +import com.monadial.ash.domain.usecases.conversation.RegisterConversationUseCase +import com.monadial.ash.domain.usecases.messaging.ReceivedMessageData +import com.monadial.ash.domain.usecases.messaging.ReceiveMessageResult +import com.monadial.ash.domain.usecases.messaging.ReceiveMessageUseCase +import com.monadial.ash.domain.usecases.messaging.SendMessageUseCase import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import javax.inject.Inject private const val TAG = "MessagingViewModel" +/** + * ViewModel for the messaging screen. + * + * Follows Clean Architecture by: + * - Using Use Cases for business logic (send/receive messages, burn, registration) + * - Using Repositories for data access (ConversationRepository) + * - Using Domain Services for relay/realtime communication + * - Only handling UI state and user interactions + */ @HiltViewModel class MessagingViewModel @Inject constructor( savedStateHandle: SavedStateHandle, - private val conversationStorage: ConversationStorageService, + private val conversationRepository: ConversationRepository, private val relayService: RelayService, - private val sseService: SSEService, + private val realtimeService: RealtimeService, private val locationService: LocationService, - private val ashCoreService: AshCoreService, - private val padManager: PadManager + private val sendMessageUseCase: SendMessageUseCase, + private val receiveMessageUseCase: ReceiveMessageUseCase, + private val registerConversationUseCase: RegisterConversationUseCase, + private val checkBurnStatusUseCase: CheckBurnStatusUseCase ) : ViewModel() { private val conversationId: String = savedStateHandle["conversationId"]!! @@ -70,12 +84,11 @@ 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) + // Track sent messages to filter out own message echoes from SSE 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 { @@ -86,249 +99,165 @@ class MessagingViewModel @Inject constructor( viewModelScope.launch { _isLoading.value = true try { - val conv = conversationStorage.getConversation(conversationId) - _conversation.value = conv - - if (conv != null) { - // Check if peer has burned - if (conv.peerBurnedAt != null) { - _peerBurned.value = true - } else { - // Register conversation with relay before SSE (fire-and-forget) - registerConversationWithRelay(conv) - // Try SSE first, fall back to polling - startSSE(conv) - startPollingMessages() - checkBurnStatus(conv) + when (val result = conversationRepository.getConversation(conversationId)) { + is AppResult.Success -> { + val conv = result.data + _conversation.value = conv + + if (conv.peerBurnedAt != null) { + _peerBurned.value = true + } else { + registerAndConnect(conv) + } + } + is AppResult.Error -> { + _error.value = "Failed to load conversation: ${result.error.message}" } } - } catch (e: Exception) { - _error.value = "Failed to load conversation: ${e.message}" } finally { _isLoading.value = false } } } - private suspend fun registerConversationWithRelay(conv: Conversation): Boolean { - return try { - val authTokenHash = relayService.hashToken(conv.authToken) - val burnTokenHash = relayService.hashToken(conv.burnToken) - val result = relayService.registerConversation( - conversationId = conv.id, - authTokenHash = authTokenHash, - burnTokenHash = burnTokenHash, - relayUrl = conv.relayUrl - ) - result.isSuccess - } catch (e: Exception) { - false - } + private suspend fun registerAndConnect(conv: Conversation) { + // Register conversation with relay (fire-and-forget) + registerConversationUseCase(conv) + + // Start SSE and polling + startSSE(conv) + startPollingMessages() + checkBurnStatus(conv) } private fun startSSE(conv: Conversation) { sseJob?.cancel() sseJob = viewModelScope.launch { - sseService.connect( + realtimeService.connect( relayUrl = conv.relayUrl, conversationId = conversationId, authToken = conv.authToken ) - 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, - ciphertext = event.ciphertext, - sequence = event.sequence, - 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 - if (registerConversationWithRelay(conv)) { - // Retry SSE connection after successful registration - startSSE(conv) - } - } - } - is SSEEvent.Error -> { - Log.e(TAG, "[$logId] SSE error: ${event.message}") - } - else -> { /* Ignore ping, disconnected */ } - } + realtimeService.events.collect { event -> + handleSSEEvent(event, conv) } } } - private fun startPollingMessages() { - pollingJob?.cancel() - pollingJob = viewModelScope.launch { - val conv = _conversation.value ?: return@launch - - relayService.pollMessages( - relayUrl = conv.relayUrl, - conversationId = conversationId, - authToken = conv.authToken, - cursor = conv.relayCursor - ).collect { result -> - if (result.success) { - result.messages.forEach { handleReceivedMessage(it) } + private suspend fun handleSSEEvent(event: SSEEvent, conv: Conversation) { + when (event) { + is SSEEvent.Connected -> { + Log.i(TAG, "[$logId] SSE connected") + } + is SSEEvent.MessageReceived -> { + handleSSEMessage(event) + } + is SSEEvent.DeliveryConfirmed -> { + Log.i(TAG, "[$logId] Delivery confirmed for ${event.blobIds.size} messages") + event.blobIds.forEach { handleDeliveryConfirmation(it) } + } + is SSEEvent.BurnSignal -> { + Log.w(TAG, "[$logId] Peer burned conversation") + handlePeerBurn() + } + is SSEEvent.NotFound -> { + Log.w(TAG, "[$logId] Conversation not found on relay") + if (!hasAttemptedRegistration) { + hasAttemptedRegistration = true + val result = registerConversationUseCase(conv) + if (result is AppResult.Success && result.data) { + startSSE(conv) + } } } + is SSEEvent.Error -> { + Log.e(TAG, "[$logId] SSE error: ${event.message}") + } + else -> { /* Ignore ping, disconnected */ } } } - private suspend fun handleReceivedMessage(received: ReceivedMessage) { - val conv = _conversation.value ?: return + private suspend fun handleSSEMessage(event: SSEEvent.MessageReceived) { + Log.d(TAG, "[$logId] SSE raw: ${event.ciphertext.size} bytes, seq=${event.sequence}, blobId=${event.id.take(8)}") - // Check for duplicates using blob ID - if (_messages.value.any { it.blobId == received.id }) { - Log.d(TAG, "[$logId] Skipping duplicate (already in messages list)") + // Filter own messages + if (sentBlobIds.contains(event.id)) { + Log.d(TAG, "[$logId] Skipping own message (blobId match)") return } - - // sequence is the sender's consumption offset, not absolute pad position - val senderOffset = received.sequence ?: run { - Log.w(TAG, "[$logId] Received message without sequence, skipping") + if (event.sequence != null && sentSequences.contains(event.sequence)) { + Log.d(TAG, "[$logId] Skipping own message (sequence match: ${event.sequence})") + sentBlobIds.add(event.id) 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})") + // Filter duplicates + if (processedBlobIds.contains(event.id)) { + Log.d(TAG, "[$logId] Skipping duplicate message") 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") + val conv = _conversation.value ?: return + if (_messages.value.any { it.blobId == event.id }) { + Log.d(TAG, "[$logId] Skipping duplicate (already in messages list)") return } - Log.d(TAG, "[$logId] Processing received message: ${received.ciphertext.size} bytes, seq=$senderOffset") - - try { - // 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 { - Role.INITIATOR - } + Log.d(TAG, "[$logId] SSE processing: ${event.ciphertext.size} bytes, seq=${event.sequence}") - Log.d(TAG, "[$logId] Decrypting: peerRole=$peerRole, absoluteOffset=$absoluteOffset") - - // Get key bytes from pad at the absolute position - val keyBytes = padManager.getBytesForDecryption( - offset = absoluteOffset, - length = received.ciphertext.size, - conversationId = conversationId + processReceivedMessage( + conv, + ReceivedMessageData( + id = event.id, + ciphertext = event.ciphertext, + sequence = event.sequence, + receivedAt = event.receivedAt ) + ) + processedBlobIds.add(event.id) + } - // Decrypt using FFI - 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() + private fun startPollingMessages() { + pollingJob?.cancel() + pollingJob = viewModelScope.launch { + val conv = _conversation.value ?: return@launch - val message = Message.incoming( + relayService.pollMessages( conversationId = conversationId, - content = content, - sequence = senderOffset, - disappearingSeconds = disappearingSeconds, - blobId = received.id - ) - - _messages.value = _messages.value + message + authToken = conv.authToken, + cursor = conv.relayCursor, + relayUrl = conv.relayUrl + ).collect { result -> + if (result.success) { + result.messages.forEach { received -> + processReceivedMessage( + conv, + ReceivedMessageData( + id = received.id, + ciphertext = received.ciphertext, + sequence = received.sequence, + receivedAt = received.receivedAt + ) + ) + } + } + } + } + } - // 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 + private suspend fun processReceivedMessage(conv: Conversation, received: ReceivedMessageData) { + when (val result = receiveMessageUseCase(conv, received)) { + is ReceiveMessageResult.Success -> { + _messages.value = _messages.value + result.message + _conversation.value = result.updatedConversation + sendAck(received.id) + } + is ReceiveMessageResult.Skipped -> { + Log.d(TAG, "[$logId] Message skipped: ${result.reason}") + } + is ReceiveMessageResult.Error -> { + Log.e(TAG, "[$logId] Message processing failed: ${result.error.message}") } - 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) { - Log.e(TAG, "[$logId] Failed to process message: ${e.message}", e) } } @@ -346,21 +275,20 @@ class MessagingViewModel @Inject constructor( _peerBurned.value = true viewModelScope.launch { val conv = _conversation.value ?: return@launch - val updated = conv.copy(peerBurnedAt = System.currentTimeMillis()) - conversationStorage.saveConversation(updated) - _conversation.value = updated + conversationRepository.markPeerBurned(conv.id, System.currentTimeMillis()) + _conversation.value = conv.copy(peerBurnedAt = System.currentTimeMillis()) } } private suspend fun checkBurnStatus(conv: Conversation) { - val result = relayService.checkBurnStatus( - conversationId = conv.id, - authToken = conv.authToken, - relayUrl = conv.relayUrl - ) - result.onSuccess { status -> - if (status.burned) { - handlePeerBurn() + when (val result = checkBurnStatusUseCase(conv)) { + is AppResult.Success -> { + if (result.data.burned) { + handlePeerBurn() + } + } + is AppResult.Error -> { + Log.w(TAG, "[$logId] Failed to check burn status: ${result.error.message}") } } } @@ -398,10 +326,8 @@ class MessagingViewModel @Inject constructor( sendMessageContent(content) }.onFailure { e -> _error.value = when (e) { - is com.monadial.ash.core.services.LocationError.PermissionDenied -> - "Location permission required" - is com.monadial.ash.core.services.LocationError.LocationUnavailable -> - "Location unavailable" + is LocationError.PermissionDenied -> "Location permission required" + is LocationError.Unavailable -> "Location unavailable" else -> "Failed to get location: ${e.message}" } } @@ -417,90 +343,51 @@ class MessagingViewModel @Inject constructor( viewModelScope.launch { _isSending.value = true try { - val plaintext = MessageContent.toBytes(content) - val myRole = if (conv.role == ConversationRole.INITIATOR) Role.INITIATOR else Role.RESPONDER - - // 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( + // Create optimistic message for UI + val optimisticMessage = Message( conversationId = conversationId, content = content, direction = MessageDirection.SENT, status = DeliveryStatus.SENDING, - sequence = sequence, + sequence = 0L, // Will be updated serverExpiresAt = System.currentTimeMillis() + (conv.messageRetention.seconds * 1000L) ) + _messages.value = _messages.value + optimisticMessage + + when (val result = sendMessageUseCase(conv, content)) { + is AppResult.Success -> { + val sendResult = result.data + // Track for SSE filtering + sentSequences.add(sendResult.sequence) + sentBlobIds.add(sendResult.blobId) + + // Update optimistic message with real data + _messages.value = _messages.value.map { msg -> + if (msg.id == optimisticMessage.id) { + sendResult.message + } else { + msg + } + } - // Add to local list immediately - _messages.value = _messages.value + message - - // Send to relay (matching iOS: POST /v1/messages) - val sendResult = relayService.submitMessage( - conversationId = conversationId, - authToken = conv.authToken, - ciphertext = ciphertext, - 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 conversation state + val updatedConv = conv.afterSending(MessageContent.toBytes(content).size.toLong()) + _conversation.value = updatedConv - // 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)}") } - Log.i(TAG, "[$logId] Message sent: blobId=${sendResult.blobId.take(8)}") - } else { - // Mark as failed - _messages.value = _messages.value.map { - if (it.id == message.id) { - it.withDeliveryStatus(DeliveryStatus.FAILED(sendResult.error)) - } else it + is AppResult.Error -> { + // Mark as failed + _messages.value = _messages.value.map { msg -> + if (msg.id == optimisticMessage.id) { + msg.withDeliveryStatus(DeliveryStatus.FAILED(result.error.message)) + } else { + msg + } + } + _error.value = result.error.message + Log.e(TAG, "[$logId] Send failed: ${result.error.message}") } - _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}" @@ -542,6 +429,6 @@ class MessagingViewModel @Inject constructor( super.onCleared() sseJob?.cancel() pollingJob?.cancel() - sseService.disconnect() + realtimeService.disconnect() } } 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 f5e50c6..c27765b 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 @@ -3,12 +3,7 @@ package com.monadial.ash.ui.viewmodels import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.monadial.ash.core.services.AshCoreService -import com.monadial.ash.core.services.ConversationStorageService -import com.monadial.ash.core.services.PadManager -import com.monadial.ash.core.services.QRCodeService -import com.monadial.ash.core.services.RelayService -import com.monadial.ash.core.services.SettingsService +import com.monadial.ash.domain.services.QRCodeService import com.monadial.ash.domain.entities.CeremonyError import com.monadial.ash.domain.entities.CeremonyPhase import com.monadial.ash.domain.entities.Conversation @@ -16,104 +11,105 @@ import com.monadial.ash.domain.entities.ConversationColor import com.monadial.ash.domain.entities.ConversationRole import com.monadial.ash.domain.entities.DisappearingMessages import com.monadial.ash.domain.entities.MessageRetention +import com.monadial.ash.domain.repositories.ConversationRepository +import com.monadial.ash.domain.repositories.PadRepository +import com.monadial.ash.domain.services.CryptoService +import com.monadial.ash.domain.usecases.conversation.RegisterConversationUseCase +import com.monadial.ash.ui.state.ReceiverCeremonyUiState import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import uniffi.ash.CeremonyMetadata import uniffi.ash.FountainCeremonyResult import uniffi.ash.FountainFrameReceiver -import javax.inject.Inject +/** + * ViewModel for the receiver (scanner) ceremony flow. + * + * Follows Clean Architecture and MVVM patterns: + * - Single UiState for all screen state + * - Repositories for data access + * - Domain Services for crypto operations + * - Use Cases for business logic + */ @HiltViewModel class ReceiverCeremonyViewModel @Inject constructor( - private val settingsService: SettingsService, private val qrCodeService: QRCodeService, - private val conversationStorage: ConversationStorageService, - private val ashCoreService: AshCoreService, - private val relayService: RelayService, - private val padManager: PadManager + private val conversationRepository: ConversationRepository, + private val cryptoService: CryptoService, + private val padRepository: PadRepository, + private val registerConversationUseCase: RegisterConversationUseCase ) : ViewModel() { companion object { private const val TAG = "ReceiverCeremonyVM" } - // State - private val _phase = MutableStateFlow(CeremonyPhase.ConfiguringReceiver) - val phase: StateFlow = _phase.asStateFlow() - - private val _conversationName = MutableStateFlow("") - val conversationName: StateFlow = _conversationName.asStateFlow() - - private val _receivedBlocks = MutableStateFlow(0) - val receivedBlocks: StateFlow = _receivedBlocks.asStateFlow() - - private val _totalBlocks = MutableStateFlow(0) - val totalBlocks: StateFlow = _totalBlocks.asStateFlow() - - private val _progress = MutableStateFlow(0f) - val progress: StateFlow = _progress.asStateFlow() - - // Passphrase protection - private val _passphraseEnabled = MutableStateFlow(false) - val passphraseEnabled: StateFlow = _passphraseEnabled.asStateFlow() + // Single consolidated UI state + private val _uiState = MutableStateFlow(ReceiverCeremonyUiState()) + val uiState: StateFlow = _uiState.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 + // Internal state (not exposed to UI) private var fountainReceiver: FountainFrameReceiver? = null private var ceremonyResult: FountainCeremonyResult? = null private var reconstructedPadBytes: ByteArray? = null private var mnemonic: List = emptyList() - // 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 = passphraseToUse) - - _phase.value = CeremonyPhase.Scanning - _receivedBlocks.value = 0 - _totalBlocks.value = 0 - _progress.value = 0f - ceremonyResult = null - reconstructedPadBytes = null - mnemonic = emptyList() - - Log.d(TAG, "Started scanning with new fountain receiver, passphraseEnabled=${_passphraseEnabled.value}") - } + // MARK: - Configuration fun setConversationName(name: String) { - _conversationName.value = name + _uiState.update { it.copy(conversationName = name) } } fun setPassphraseEnabled(enabled: Boolean) { - _passphraseEnabled.value = enabled - if (!enabled) { - _passphrase.value = "" + _uiState.update { + it.copy( + passphraseEnabled = enabled, + passphrase = if (!enabled) "" else it.passphrase + ) } } fun setPassphrase(value: String) { - _passphrase.value = value + _uiState.update { it.copy(passphrase = value) } } fun setSelectedColor(color: ConversationColor) { - _selectedColor.value = color + _uiState.update { it.copy(selectedColor = color) } } - // MARK: - Frame Processing + // MARK: - Scanning + + fun startScanning() { + val state = _uiState.value + val passphraseToUse = if (state.passphraseEnabled) { + state.passphrase.ifEmpty { null } + } else null + + // Create a new fountain receiver + fountainReceiver?.close() + fountainReceiver = cryptoService.createFountainReceiver(passphrase = passphraseToUse) + + // Reset internal state + ceremonyResult = null + reconstructedPadBytes = null + mnemonic = emptyList() + + // Update UI state + _uiState.update { + it.copy( + phase = CeremonyPhase.Scanning, + receivedBlocks = 0, + totalBlocks = 0, + progress = 0f + ) + } + + Log.d(TAG, "Started scanning with new fountain receiver, passphraseEnabled=${state.passphraseEnabled}") + } fun processScannedFrame(base64String: String) { val receiver = fountainReceiver ?: return @@ -122,35 +118,31 @@ class ReceiverCeremonyViewModel @Inject constructor( if (frameBytes.isEmpty()) return try { - // Add frame to receiver using FFI - val isComplete = with(ashCoreService) { - receiver.addFrameBytes(frameBytes) - } + val isComplete = cryptoService.addFrameBytes(receiver, frameBytes) - // 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 = uniqueBlocks - _totalBlocks.value = sourceCount - _progress.value = progress + _uiState.update { + it.copy( + receivedBlocks = uniqueBlocks, + totalBlocks = sourceCount, + progress = progress, + phase = CeremonyPhase.Transferring( + currentFrame = uniqueBlocks, + totalFrames = sourceCount + ) + ) + } Log.d(TAG, "Frame processed: unique=$uniqueBlocks, sourceCount=$sourceCount, progress=${(progress * 100).toInt()}%") - // Update phase - _phase.value = CeremonyPhase.Transferring( - currentFrame = uniqueBlocks, - totalFrames = sourceCount - ) - - // Check if complete if (isComplete || receiver.isComplete()) { reconstructAndVerify() } } catch (e: Exception) { Log.e(TAG, "Failed to process frame: ${e.message}", e) - // Ignore invalid frames, continue scanning } } @@ -158,85 +150,79 @@ class ReceiverCeremonyViewModel @Inject constructor( private fun reconstructAndVerify() { val receiver = fountainReceiver ?: run { - _phase.value = CeremonyPhase.Failed(CeremonyError.PAD_RECONSTRUCTION_FAILED) + _uiState.update { it.copy(phase = CeremonyPhase.Failed(CeremonyError.PAD_RECONSTRUCTION_FAILED)) } return } try { - // Get the decoded result from FFI receiver val result = receiver.getResult() if (result == null) { Log.e(TAG, "Receiver reported complete but getResult() returned null") - _phase.value = CeremonyPhase.Failed(CeremonyError.PAD_RECONSTRUCTION_FAILED) + _uiState.update { it.copy(phase = CeremonyPhase.Failed(CeremonyError.PAD_RECONSTRUCTION_FAILED)) } return } ceremonyResult = result - - // Use pad directly from FFI result (List) for mnemonic/tokens - // to avoid any byte conversion issues val padUBytes = result.pad 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 + // Extract color from notification flags val colorIndex = ((result.metadata.notificationFlags.toInt()) shr 12) and 0x0F val decodedColor = ConversationColor.entries.getOrElse(colorIndex) { ConversationColor.INDIGO } - _selectedColor.value = decodedColor + _uiState.update { it.copy(selectedColor = 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("") - Log.d(TAG, "Pad first 16 bytes: $firstBytes") - Log.d(TAG, "Pad last 16 bytes: $lastBytes") + logPadDebugInfo(padUBytes) - // Generate mnemonic from pad using FFI directly with UByte list + // Generate mnemonic using FFI (takes List) mnemonic = uniffi.ash.generateMnemonic(padUBytes) Log.d(TAG, "Generated mnemonic: ${mnemonic.joinToString(" ")}") - // Also derive tokens for debugging - use FFI directly + // Derive tokens for debugging val tokens = uniffi.ash.deriveAllTokens(padUBytes) Log.d(TAG, "Derived conversation ID: ${tokens.conversationId}") - Log.d(TAG, "Derived auth token: ${tokens.authToken.take(16)}...") - // Store as ByteArray for later use reconstructedPadBytes = padUBytes.map { it.toByte() }.toByteArray() - _phase.value = CeremonyPhase.Verifying(mnemonic = mnemonic) + _uiState.update { it.copy(phase = CeremonyPhase.Verifying(mnemonic = mnemonic)) } } catch (e: Exception) { Log.e(TAG, "Failed to reconstruct ceremony: ${e.message}", e) - _phase.value = CeremonyPhase.Failed(CeremonyError.PAD_RECONSTRUCTION_FAILED) + _uiState.update { it.copy(phase = CeremonyPhase.Failed(CeremonyError.PAD_RECONSTRUCTION_FAILED)) } } } + private fun logPadDebugInfo(padUBytes: List) { + val firstBytes = padUBytes.take(16).joinToString("") { String.format("%02X", it.toInt()) } + val lastBytes = padUBytes.takeLast(16).joinToString("") { String.format("%02X", it.toInt()) } + Log.d(TAG, "Pad first 16 bytes: $firstBytes") + Log.d(TAG, "Pad last 16 bytes: $lastBytes") + } + // MARK: - Verification fun confirmVerification(): Conversation? { val result = ceremonyResult ?: return null val padUBytes = result.pad val metadata = result.metadata + val state = _uiState.value try { - // Derive all tokens using FFI directly with UByte list + // Derive tokens using FFI directly (takes List) val tokens = uniffi.ash.deriveAllTokens(padUBytes) - // 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()) val disappearingMessages = DisappearingMessages.fromSeconds(metadata.disappearingMessagesSeconds.toInt()) val conversation = Conversation( id = tokens.conversationId, - name = _conversationName.value.ifBlank { null }, + name = state.conversationName.ifBlank { null }, relayUrl = metadata.relayUrl, authToken = tokens.authToken, burnToken = tokens.burnToken, role = ConversationRole.RESPONDER, - color = color, + color = state.selectedColor, createdAt = System.currentTimeMillis(), padTotalSize = padUBytes.size.toLong(), mnemonic = mnemonic, @@ -244,77 +230,49 @@ class ReceiverCeremonyViewModel @Inject constructor( disappearingMessages = disappearingMessages ) - // Convert to ByteArray for storage val padBytes = padUBytes.map { it.toByte() }.toByteArray() viewModelScope.launch { - conversationStorage.saveConversation(conversation) - padManager.storePad(padBytes, conversation.id) - - // Register conversation with relay (fire-and-forget) - registerConversationWithRelay(conversation) + conversationRepository.saveConversation(conversation) + padRepository.storePad(conversation.id, padBytes) + registerConversationUseCase(conversation) } - _phase.value = CeremonyPhase.Completed(conversation) + _uiState.update { it.copy(phase = CeremonyPhase.Completed(conversation)) } return conversation } catch (e: Exception) { Log.e(TAG, "Failed to confirm verification: ${e.message}", e) - _phase.value = CeremonyPhase.Failed(CeremonyError.PAD_RECONSTRUCTION_FAILED) + _uiState.update { it.copy(phase = CeremonyPhase.Failed(CeremonyError.PAD_RECONSTRUCTION_FAILED)) } return null } } - private suspend fun registerConversationWithRelay(conversation: Conversation) { - try { - val authTokenHash = relayService.hashToken(conversation.authToken) - val burnTokenHash = relayService.hashToken(conversation.burnToken) - val result = relayService.registerConversation( - conversationId = conversation.id, - authTokenHash = authTokenHash, - burnTokenHash = burnTokenHash, - relayUrl = conversation.relayUrl - ) - if (result.isSuccess) { - Log.d(TAG, "Conversation registered with relay") - } else { - Log.w(TAG, "Failed to register conversation with relay: ${result.exceptionOrNull()?.message}") - } - } catch (e: Exception) { - Log.w(TAG, "Failed to register conversation with relay: ${e.message}") - } - } - fun rejectVerification() { - _phase.value = CeremonyPhase.Failed(CeremonyError.CHECKSUM_MISMATCH) + _uiState.update { it.copy(phase = CeremonyPhase.Failed(CeremonyError.CHECKSUM_MISMATCH)) } } // MARK: - Reset & Cancel fun reset() { - fountainReceiver?.close() - fountainReceiver = null + cleanupResources() - _phase.value = CeremonyPhase.ConfiguringReceiver - _conversationName.value = "" - _receivedBlocks.value = 0 - _totalBlocks.value = 0 - _progress.value = 0f - _passphraseEnabled.value = false - _passphrase.value = "" - _selectedColor.value = ConversationColor.INDIGO + _uiState.value = ReceiverCeremonyUiState() ceremonyResult = null reconstructedPadBytes = null mnemonic = emptyList() } fun cancel() { + cleanupResources() + _uiState.update { it.copy(phase = CeremonyPhase.Failed(CeremonyError.CANCELLED)) } + } + + private fun cleanupResources() { fountainReceiver?.close() fountainReceiver = null - _phase.value = CeremonyPhase.Failed(CeremonyError.CANCELLED) } override fun onCleared() { super.onCleared() - fountainReceiver?.close() - fountainReceiver = null + cleanupResources() } } 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 8b83de7..1153c14 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,28 +2,37 @@ package com.monadial.ash.ui.viewmodels import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.monadial.ash.BuildConfig +import com.monadial.ash.core.common.AppResult import com.monadial.ash.core.services.ConnectionTestResult -import com.monadial.ash.core.services.ConversationStorageService -import com.monadial.ash.core.services.RelayService -import com.monadial.ash.core.services.SettingsService +import com.monadial.ash.domain.repositories.SettingsRepository +import com.monadial.ash.domain.services.RelayService +import com.monadial.ash.domain.usecases.conversation.BurnConversationUseCase +import com.monadial.ash.domain.usecases.conversation.GetConversationsUseCase import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject 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 +/** + * ViewModel for the settings screen. + * + * Follows Clean Architecture by: + * - Using SettingsRepository for settings persistence + * - Using BurnConversationUseCase for burning all conversations + * - Using RelayService for connection testing + */ @HiltViewModel class SettingsViewModel @Inject constructor( - private val settingsService: SettingsService, - private val conversationStorage: ConversationStorageService, - private val relayService: RelayService + private val settingsRepository: SettingsRepository, + private val relayService: RelayService, + private val getConversationsUseCase: GetConversationsUseCase, + private val burnConversationUseCase: BurnConversationUseCase ) : ViewModel() { - val isBiometricEnabled: StateFlow = settingsService.isBiometricEnabled + val isBiometricEnabled: StateFlow = settingsRepository.isBiometricEnabled private val _lockOnBackground = MutableStateFlow(true) val lockOnBackground: StateFlow = _lockOnBackground.asStateFlow() @@ -49,14 +58,21 @@ class SettingsViewModel @Inject constructor( private val _isBurningAll = MutableStateFlow(false) val isBurningAll: StateFlow = _isBurningAll.asStateFlow() + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + init { + observeSettings() + } + + private fun observeSettings() { viewModelScope.launch { - settingsService.lockOnBackground.collect { + settingsRepository.lockOnBackground.collect { _lockOnBackground.value = it } } viewModelScope.launch { - settingsService.relayServerUrl.collect { url -> + settingsRepository.relayServerUrl.collect { url -> _relayUrl.value = url // Only update edited URL if there are no unsaved changes if (!_hasUnsavedChanges.value) { @@ -82,12 +98,15 @@ class SettingsViewModel @Inject constructor( fun saveRelayUrl() { viewModelScope.launch { - settingsService.setRelayServerUrl(_editedRelayUrl.value) + when (val result = settingsRepository.setRelayUrl(_editedRelayUrl.value)) { + is AppResult.Success -> _error.value = null + is AppResult.Error -> _error.value = result.error.message + } } } fun resetRelayUrl() { - _editedRelayUrl.value = BuildConfig.DEFAULT_RELAY_URL + _editedRelayUrl.value = settingsRepository.getDefaultRelayUrl() _connectionTestResult.value = null } @@ -98,13 +117,13 @@ class SettingsViewModel @Inject constructor( fun setBiometricEnabled(enabled: Boolean) { viewModelScope.launch { - settingsService.setBiometricEnabled(enabled) + settingsRepository.setBiometricEnabled(enabled) } } fun setLockOnBackground(enabled: Boolean) { viewModelScope.launch { - settingsService.setLockOnBackground(enabled) + settingsRepository.setLockOnBackground(enabled) } } @@ -132,33 +151,22 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { _isBurningAll.value = true try { - val conversations = conversationStorage.conversations.value - - // Burn each conversation - for (conv in conversations) { - // Notify relay (fire-and-forget) - try { - relayService.burnConversation( - conversationId = conv.id, - burnToken = conv.burnToken, - relayUrl = conv.relayUrl - ) - } catch (_: Exception) { - // Continue even if relay notification fails - } - - // Delete pad bytes - conversationStorage.deletePadBytes(conv.id) - - // Delete conversation - conversationStorage.deleteConversation(conv.id) + val conversations = getConversationsUseCase.conversations.value + val burnedCount = burnConversationUseCase.burnAll(conversations) + + if (burnedCount < conversations.size) { + _error.value = "Failed to burn some conversations" } - // Reload conversations to update state - conversationStorage.loadConversations() + // Reload to update UI + getConversationsUseCase.refresh() } finally { _isBurningAll.value = false } } } + + fun clearError() { + _error.value = null + } } diff --git a/apps/android/app/src/test/java/com/monadial/ash/TestFixtures.kt b/apps/android/app/src/test/java/com/monadial/ash/TestFixtures.kt new file mode 100644 index 0000000..56b8a06 --- /dev/null +++ b/apps/android/app/src/test/java/com/monadial/ash/TestFixtures.kt @@ -0,0 +1,51 @@ +package com.monadial.ash + +import com.monadial.ash.domain.entities.Conversation +import com.monadial.ash.domain.entities.ConversationColor +import com.monadial.ash.domain.entities.ConversationRole +import com.monadial.ash.domain.entities.MessageRetention + +/** + * Test fixtures for unit tests. + */ +object TestFixtures { + + fun createConversation( + id: String = "test-conversation-id", + name: String? = "Test Conversation", + relayUrl: String = "https://relay.test.com", + authToken: String = "test-auth-token", + burnToken: String = "test-burn-token", + role: ConversationRole = ConversationRole.INITIATOR, + color: ConversationColor = ConversationColor.INDIGO, + createdAt: Long = System.currentTimeMillis(), + padTotalSize: Long = 65536, + padConsumedFront: Long = 0, + padConsumedBack: Long = 0, + peerBurnedAt: Long? = null, + messageRetention: MessageRetention = MessageRetention.ONE_HOUR + ): Conversation = Conversation( + id = id, + name = name, + relayUrl = relayUrl, + authToken = authToken, + burnToken = burnToken, + role = role, + color = color, + createdAt = createdAt, + padTotalSize = padTotalSize, + padConsumedFront = padConsumedFront, + padConsumedBack = padConsumedBack, + peerBurnedAt = peerBurnedAt, + messageRetention = messageRetention + ) + + fun createMultipleConversations(count: Int): List { + return (1..count).map { index -> + createConversation( + id = "conversation-$index", + name = "Conversation $index" + ) + } + } +} diff --git a/apps/android/app/src/test/java/com/monadial/ash/data/repositories/ConversationRepositoryImplTest.kt b/apps/android/app/src/test/java/com/monadial/ash/data/repositories/ConversationRepositoryImplTest.kt new file mode 100644 index 0000000..521ff04 --- /dev/null +++ b/apps/android/app/src/test/java/com/monadial/ash/data/repositories/ConversationRepositoryImplTest.kt @@ -0,0 +1,322 @@ +package com.monadial.ash.data.repositories + +import com.google.common.truth.Truth.assertThat +import com.monadial.ash.TestFixtures +import com.monadial.ash.core.common.AppError +import com.monadial.ash.core.services.ConversationStorageService +import com.monadial.ash.domain.entities.Conversation +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +@DisplayName("ConversationRepositoryImpl") +class ConversationRepositoryImplTest { + + private lateinit var storageService: ConversationStorageService + private lateinit var repository: ConversationRepositoryImpl + + private val conversationsFlow = MutableStateFlow>(emptyList()) + + @BeforeEach + fun setup() { + storageService = mockk(relaxed = true) + every { storageService.conversations } returns conversationsFlow + repository = ConversationRepositoryImpl(storageService) + } + + @Nested + @DisplayName("conversations") + inner class ConversationsPropertyTests { + + @Test + @DisplayName("should delegate to storage service") + fun delegatesToStorageService() { + // Given + val conversations = TestFixtures.createMultipleConversations(3) + conversationsFlow.value = conversations + + // Then + assertThat(repository.conversations.value).isEqualTo(conversations) + } + } + + @Nested + @DisplayName("loadConversations()") + inner class LoadConversationsTests { + + @Test + @DisplayName("should return success with conversations list") + fun returnsSuccessWithConversations() = runTest { + // Given + val conversations = TestFixtures.createMultipleConversations(3) + conversationsFlow.value = conversations + coEvery { storageService.loadConversations() } just runs + + // When + val result = repository.loadConversations() + + // Then + assertThat(result.isSuccess).isTrue() + assertThat(result.getOrNull()).hasSize(3) + } + + @Test + @DisplayName("should return error when storage throws exception") + fun returnsErrorOnException() = runTest { + // Given + coEvery { storageService.loadConversations() } throws RuntimeException("Storage error") + + // When + val result = repository.loadConversations() + + // Then + assertThat(result.isError).isTrue() + val error = result.errorOrNull() + assertThat(error).isInstanceOf(AppError.Storage.ReadFailed::class.java) + } + } + + @Nested + @DisplayName("getConversation()") + inner class GetConversationTests { + + @Test + @DisplayName("should return success when conversation exists") + fun returnsSuccessWhenExists() = runTest { + // Given + val conversation = TestFixtures.createConversation(id = "test-id") + coEvery { storageService.getConversation("test-id") } returns conversation + + // When + val result = repository.getConversation("test-id") + + // Then + assertThat(result.isSuccess).isTrue() + assertThat(result.getOrNull()).isEqualTo(conversation) + } + + @Test + @DisplayName("should return NotFound error when conversation doesn't exist") + fun returnsNotFoundWhenMissing() = runTest { + // Given + coEvery { storageService.getConversation("nonexistent") } returns null + + // When + val result = repository.getConversation("nonexistent") + + // Then + assertThat(result.isError).isTrue() + val error = result.errorOrNull() + assertThat(error).isInstanceOf(AppError.Storage.NotFound::class.java) + } + + @Test + @DisplayName("should return error when storage throws exception") + fun returnsErrorOnException() = runTest { + // Given + coEvery { storageService.getConversation(any()) } throws RuntimeException("Error") + + // When + val result = repository.getConversation("test-id") + + // Then + assertThat(result.isError).isTrue() + val error = result.errorOrNull() + assertThat(error).isInstanceOf(AppError.Storage.ReadFailed::class.java) + } + } + + @Nested + @DisplayName("saveConversation()") + inner class SaveConversationTests { + + @Test + @DisplayName("should save conversation successfully") + fun savesSuccessfully() = runTest { + // Given + val conversation = TestFixtures.createConversation() + coEvery { storageService.saveConversation(conversation) } just runs + + // When + val result = repository.saveConversation(conversation) + + // Then + assertThat(result.isSuccess).isTrue() + coVerify { storageService.saveConversation(conversation) } + } + + @Test + @DisplayName("should return error when storage throws exception") + fun returnsErrorOnException() = runTest { + // Given + val conversation = TestFixtures.createConversation() + coEvery { storageService.saveConversation(any()) } throws RuntimeException("Save failed") + + // When + val result = repository.saveConversation(conversation) + + // Then + assertThat(result.isError).isTrue() + val error = result.errorOrNull() + assertThat(error).isInstanceOf(AppError.Storage.WriteFailed::class.java) + } + } + + @Nested + @DisplayName("deleteConversation()") + inner class DeleteConversationTests { + + @Test + @DisplayName("should delete conversation successfully") + fun deletesSuccessfully() = runTest { + // Given + coEvery { storageService.deleteConversation("test-id") } just runs + + // When + val result = repository.deleteConversation("test-id") + + // Then + assertThat(result.isSuccess).isTrue() + coVerify { storageService.deleteConversation("test-id") } + } + + @Test + @DisplayName("should return error when storage throws exception") + fun returnsErrorOnException() = runTest { + // Given + coEvery { storageService.deleteConversation(any()) } throws RuntimeException("Delete failed") + + // When + val result = repository.deleteConversation("test-id") + + // Then + assertThat(result.isError).isTrue() + val error = result.errorOrNull() + assertThat(error).isInstanceOf(AppError.Storage.WriteFailed::class.java) + } + } + + @Nested + @DisplayName("updateConversation()") + inner class UpdateConversationTests { + + @Test + @DisplayName("should update and return updated conversation") + fun updatesSuccessfully() = runTest { + // Given + val original = TestFixtures.createConversation(id = "test-id", name = "Original") + coEvery { storageService.getConversation("test-id") } returns original + coEvery { storageService.saveConversation(any()) } just runs + + // When + val result = repository.updateConversation("test-id") { it.renamed("Updated") } + + // Then + assertThat(result.isSuccess).isTrue() + val updated = result.getOrNull()!! + assertThat(updated.name).isEqualTo("Updated") + } + + @Test + @DisplayName("should return NotFound when conversation doesn't exist") + fun returnsNotFoundWhenMissing() = runTest { + // Given + coEvery { storageService.getConversation("nonexistent") } returns null + + // When + val result = repository.updateConversation("nonexistent") { it } + + // Then + assertThat(result.isError).isTrue() + val error = result.errorOrNull() + assertThat(error).isInstanceOf(AppError.Storage.NotFound::class.java) + } + } + + @Nested + @DisplayName("updateLastMessage()") + inner class UpdateLastMessageTests { + + @Test + @DisplayName("should update last message preview and timestamp") + fun updatesLastMessage() = runTest { + // Given + val conversation = TestFixtures.createConversation(id = "test-id") + coEvery { storageService.getConversation("test-id") } returns conversation + coEvery { storageService.saveConversation(any()) } just runs + + // When + val result = repository.updateLastMessage("test-id", "New preview", 12345L) + + // Then + assertThat(result.isSuccess).isTrue() + coVerify { + storageService.saveConversation(match { + it.lastMessagePreview == "New preview" && it.lastMessageAt == 12345L + }) + } + } + } + + @Nested + @DisplayName("markPeerBurned()") + inner class MarkPeerBurnedTests { + + @Test + @DisplayName("should mark conversation as burned by peer") + fun marksBurnedSuccessfully() = runTest { + // Given + val conversation = TestFixtures.createConversation(id = "test-id", peerBurnedAt = null) + coEvery { storageService.getConversation("test-id") } returns conversation + coEvery { storageService.saveConversation(any()) } just runs + val burnTimestamp = System.currentTimeMillis() + + // When + val result = repository.markPeerBurned("test-id", burnTimestamp) + + // Then + assertThat(result.isSuccess).isTrue() + coVerify { + storageService.saveConversation(match { it.peerBurnedAt == burnTimestamp }) + } + } + } + + @Nested + @DisplayName("updatePadConsumption()") + inner class UpdatePadConsumptionTests { + + @Test + @DisplayName("should update pad consumption values") + fun updatesPadConsumption() = runTest { + // Given + val conversation = TestFixtures.createConversation( + id = "test-id", + padConsumedFront = 0, + padConsumedBack = 0 + ) + coEvery { storageService.getConversation("test-id") } returns conversation + coEvery { storageService.saveConversation(any()) } just runs + + // When + val result = repository.updatePadConsumption("test-id", 1000L, 500L) + + // Then + assertThat(result.isSuccess).isTrue() + coVerify { + storageService.saveConversation(match { + it.padConsumedFront == 1000L && it.padConsumedBack == 500L + }) + } + } + } +} diff --git a/apps/android/app/src/test/java/com/monadial/ash/domain/usecases/conversation/BurnConversationUseCaseTest.kt b/apps/android/app/src/test/java/com/monadial/ash/domain/usecases/conversation/BurnConversationUseCaseTest.kt new file mode 100644 index 0000000..75bf0a6 --- /dev/null +++ b/apps/android/app/src/test/java/com/monadial/ash/domain/usecases/conversation/BurnConversationUseCaseTest.kt @@ -0,0 +1,173 @@ +package com.monadial.ash.domain.usecases.conversation + +import com.google.common.truth.Truth.assertThat +import com.monadial.ash.TestFixtures +import com.monadial.ash.core.common.AppError +import com.monadial.ash.core.common.AppResult +import com.monadial.ash.domain.entities.Conversation +import com.monadial.ash.domain.repositories.ConversationRepository +import com.monadial.ash.domain.repositories.PadRepository +import com.monadial.ash.domain.services.RelayService +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +@DisplayName("BurnConversationUseCase") +class BurnConversationUseCaseTest { + + private lateinit var relayService: RelayService + private lateinit var conversationRepository: ConversationRepository + private lateinit var padRepository: PadRepository + private lateinit var useCase: BurnConversationUseCase + + @BeforeEach + fun setup() { + relayService = mockk(relaxed = true) + conversationRepository = mockk(relaxed = true) + padRepository = mockk(relaxed = true) + useCase = BurnConversationUseCase(relayService, conversationRepository, padRepository) + } + + @Nested + @DisplayName("invoke()") + inner class InvokeTests { + + @Test + @DisplayName("should burn conversation successfully when all operations succeed") + fun burnSuccessfully() = runTest { + // Given + val conversation = TestFixtures.createConversation() + coEvery { relayService.burnConversation(any(), any(), any()) } returns AppResult.Success(Unit) + coEvery { padRepository.wipePad(any()) } returns AppResult.Success(Unit) + coEvery { conversationRepository.deleteConversation(any()) } returns AppResult.Success(Unit) + + // When + val result = useCase(conversation) + + // Then + assertThat(result.isSuccess).isTrue() + coVerify { relayService.burnConversation(conversation.id, conversation.burnToken, conversation.relayUrl) } + coVerify { padRepository.wipePad(conversation.id) } + coVerify { conversationRepository.deleteConversation(conversation.id) } + } + + @Test + @DisplayName("should continue burn process even if relay notification fails") + fun continueOnRelayFailure() = runTest { + // Given + val conversation = TestFixtures.createConversation() + coEvery { relayService.burnConversation(any(), any(), any()) } throws Exception("Network error") + coEvery { padRepository.wipePad(any()) } returns AppResult.Success(Unit) + coEvery { conversationRepository.deleteConversation(any()) } returns AppResult.Success(Unit) + + // When + val result = useCase(conversation) + + // Then + assertThat(result.isSuccess).isTrue() + coVerify { padRepository.wipePad(conversation.id) } + coVerify { conversationRepository.deleteConversation(conversation.id) } + } + + @Test + @DisplayName("should return error when pad wipe fails") + fun errorOnPadWipeFailure() = runTest { + // Given + val conversation = TestFixtures.createConversation() + coEvery { relayService.burnConversation(any(), any(), any()) } returns AppResult.Success(Unit) + coEvery { padRepository.wipePad(any()) } throws Exception("Storage error") + + // When + val result = useCase(conversation) + + // Then + assertThat(result.isError).isTrue() + val error = result.errorOrNull() + assertThat(error).isInstanceOf(AppError.Storage.WriteFailed::class.java) + } + + @Test + @DisplayName("should return error when conversation delete fails") + fun errorOnDeleteFailure() = runTest { + // Given + val conversation = TestFixtures.createConversation() + coEvery { relayService.burnConversation(any(), any(), any()) } returns AppResult.Success(Unit) + coEvery { padRepository.wipePad(any()) } returns AppResult.Success(Unit) + coEvery { conversationRepository.deleteConversation(any()) } throws Exception("Delete failed") + + // When + val result = useCase(conversation) + + // Then + assertThat(result.isError).isTrue() + } + } + + @Nested + @DisplayName("burnAll()") + inner class BurnAllTests { + + @Test + @DisplayName("should return count of successfully burned conversations") + fun burnAllSuccessfully() = runTest { + // Given + val conversations = TestFixtures.createMultipleConversations(3) + coEvery { relayService.burnConversation(any(), any(), any()) } returns AppResult.Success(Unit) + coEvery { padRepository.wipePad(any()) } returns AppResult.Success(Unit) + coEvery { conversationRepository.deleteConversation(any()) } returns AppResult.Success(Unit) + + // When + val burnedCount = useCase.burnAll(conversations) + + // Then + assertThat(burnedCount).isEqualTo(3) + } + + @Test + @DisplayName("should count partial success when some burns fail") + fun partialBurnSuccess() = runTest { + // Given + val conversations = TestFixtures.createMultipleConversations(3) + coEvery { relayService.burnConversation(any(), any(), any()) } returns AppResult.Success(Unit) + coEvery { padRepository.wipePad("conversation-1") } returns AppResult.Success(Unit) + coEvery { padRepository.wipePad("conversation-2") } throws Exception("Failed") + coEvery { padRepository.wipePad("conversation-3") } returns AppResult.Success(Unit) + coEvery { conversationRepository.deleteConversation(any()) } returns AppResult.Success(Unit) + + // When + val burnedCount = useCase.burnAll(conversations) + + // Then + assertThat(burnedCount).isEqualTo(2) + } + + @Test + @DisplayName("should return zero when all burns fail") + fun allBurnsFail() = runTest { + // Given + val conversations = TestFixtures.createMultipleConversations(3) + coEvery { padRepository.wipePad(any()) } throws Exception("Storage error") + + // When + val burnedCount = useCase.burnAll(conversations) + + // Then + assertThat(burnedCount).isEqualTo(0) + } + + @Test + @DisplayName("should return zero for empty list") + fun emptyList() = runTest { + // When + val burnedCount = useCase.burnAll(emptyList()) + + // Then + assertThat(burnedCount).isEqualTo(0) + } + } +} diff --git a/apps/android/app/src/test/java/com/monadial/ash/domain/usecases/conversation/CheckBurnStatusUseCaseTest.kt b/apps/android/app/src/test/java/com/monadial/ash/domain/usecases/conversation/CheckBurnStatusUseCaseTest.kt new file mode 100644 index 0000000..a20fedf --- /dev/null +++ b/apps/android/app/src/test/java/com/monadial/ash/domain/usecases/conversation/CheckBurnStatusUseCaseTest.kt @@ -0,0 +1,180 @@ +package com.monadial.ash.domain.usecases.conversation + +import com.google.common.truth.Truth.assertThat +import com.monadial.ash.TestFixtures +import com.monadial.ash.core.common.AppError +import com.monadial.ash.core.common.AppResult +import com.monadial.ash.core.services.BurnStatusResponse +import com.monadial.ash.domain.entities.Conversation +import com.monadial.ash.domain.repositories.ConversationRepository +import com.monadial.ash.domain.services.RelayService +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +@DisplayName("CheckBurnStatusUseCase") +class CheckBurnStatusUseCaseTest { + + private lateinit var relayService: RelayService + private lateinit var conversationRepository: ConversationRepository + private lateinit var useCase: CheckBurnStatusUseCase + + @BeforeEach + fun setup() { + relayService = mockk(relaxed = true) + conversationRepository = mockk(relaxed = true) + useCase = CheckBurnStatusUseCase(relayService, conversationRepository) + } + + @Nested + @DisplayName("invoke()") + inner class InvokeTests { + + @Test + @DisplayName("should return not burned when conversation is active") + fun notBurned() = runTest { + // Given + val conversation = TestFixtures.createConversation() + coEvery { + relayService.checkBurnStatus(any(), any(), any()) + } returns AppResult.Success(BurnStatusResponse(burned = false, burnedAt = null)) + + // When + val result = useCase(conversation) + + // Then + assertThat(result.isSuccess).isTrue() + val status = result.getOrNull()!! + assertThat(status.burned).isFalse() + assertThat(status.conversationUpdated).isFalse() + } + + @Test + @DisplayName("should return burned and update local state when peer has burned") + fun burnedAndUpdated() = runTest { + // Given + val conversation = TestFixtures.createConversation(peerBurnedAt = null) + coEvery { + relayService.checkBurnStatus(any(), any(), any()) + } returns AppResult.Success(BurnStatusResponse(burned = true, burnedAt = "2024-01-01T00:00:00Z")) + coEvery { conversationRepository.saveConversation(any()) } returns AppResult.Success(Unit) + + // When + val result = useCase(conversation) + + // Then + assertThat(result.isSuccess).isTrue() + val status = result.getOrNull()!! + assertThat(status.burned).isTrue() + assertThat(status.burnedAt).isEqualTo("2024-01-01T00:00:00Z") + assertThat(status.conversationUpdated).isTrue() + coVerify { conversationRepository.saveConversation(match { it.peerBurnedAt != null }) } + } + + @Test + @DisplayName("should not update when already marked as burned locally") + fun alreadyMarkedBurned() = runTest { + // Given + val conversation = TestFixtures.createConversation(peerBurnedAt = System.currentTimeMillis()) + coEvery { + relayService.checkBurnStatus(any(), any(), any()) + } returns AppResult.Success(BurnStatusResponse(burned = true, burnedAt = "2024-01-01T00:00:00Z")) + + // When + val result = useCase(conversation) + + // Then + assertThat(result.isSuccess).isTrue() + val status = result.getOrNull()!! + assertThat(status.burned).isTrue() + assertThat(status.conversationUpdated).isFalse() + coVerify(exactly = 0) { conversationRepository.saveConversation(any()) } + } + + @Test + @DisplayName("should not update when updateIfBurned is false") + fun skipUpdateWhenFlagFalse() = runTest { + // Given + val conversation = TestFixtures.createConversation(peerBurnedAt = null) + coEvery { + relayService.checkBurnStatus(any(), any(), any()) + } returns AppResult.Success(BurnStatusResponse(burned = true, burnedAt = "2024-01-01T00:00:00Z")) + + // When + val result = useCase(conversation, updateIfBurned = false) + + // Then + assertThat(result.isSuccess).isTrue() + val status = result.getOrNull()!! + assertThat(status.burned).isTrue() + assertThat(status.conversationUpdated).isFalse() + coVerify(exactly = 0) { conversationRepository.saveConversation(any()) } + } + + @Test + @DisplayName("should return error when relay check fails") + fun relayError() = runTest { + // Given + val conversation = TestFixtures.createConversation() + coEvery { + relayService.checkBurnStatus(any(), any(), any()) + } returns AppResult.Error(AppError.Network.ConnectionFailed("Network error")) + + // When + val result = useCase(conversation) + + // Then + assertThat(result.isError).isTrue() + val error = result.errorOrNull() + assertThat(error).isInstanceOf(AppError.Network.ConnectionFailed::class.java) + } + } + + @Nested + @DisplayName("checkAll()") + inner class CheckAllTests { + + @Test + @DisplayName("should check all conversations and return map of results") + fun checkAllConversations() = runTest { + // Given + val conversations = TestFixtures.createMultipleConversations(3) + coEvery { + relayService.checkBurnStatus("conversation-1", any(), any()) + } returns AppResult.Success(BurnStatusResponse(burned = false)) + coEvery { + relayService.checkBurnStatus("conversation-2", any(), any()) + } returns AppResult.Success(BurnStatusResponse(burned = true, burnedAt = "2024-01-01")) + coEvery { + relayService.checkBurnStatus("conversation-3", any(), any()) + } returns AppResult.Error(AppError.Network.ConnectionFailed("Error")) + coEvery { conversationRepository.saveConversation(any()) } returns AppResult.Success(Unit) + + // When + val results = useCase.checkAll(conversations) + + // Then + assertThat(results).hasSize(3) + assertThat(results["conversation-1"]?.isSuccess).isTrue() + assertThat(results["conversation-1"]?.getOrNull()?.burned).isFalse() + assertThat(results["conversation-2"]?.isSuccess).isTrue() + assertThat(results["conversation-2"]?.getOrNull()?.burned).isTrue() + assertThat(results["conversation-3"]?.isError).isTrue() + } + + @Test + @DisplayName("should return empty map for empty list") + fun emptyList() = runTest { + // When + val results = useCase.checkAll(emptyList()) + + // Then + assertThat(results).isEmpty() + } + } +} diff --git a/apps/android/app/src/test/java/com/monadial/ash/domain/usecases/messaging/SendMessageUseCaseTest.kt b/apps/android/app/src/test/java/com/monadial/ash/domain/usecases/messaging/SendMessageUseCaseTest.kt new file mode 100644 index 0000000..913cb2d --- /dev/null +++ b/apps/android/app/src/test/java/com/monadial/ash/domain/usecases/messaging/SendMessageUseCaseTest.kt @@ -0,0 +1,266 @@ +package com.monadial.ash.domain.usecases.messaging + +import com.google.common.truth.Truth.assertThat +import com.monadial.ash.TestFixtures +import com.monadial.ash.core.common.AppError +import com.monadial.ash.core.common.AppResult +import com.monadial.ash.core.services.PadState +import com.monadial.ash.core.services.SendResult +import com.monadial.ash.domain.entities.ConversationRole +import com.monadial.ash.domain.entities.DeliveryStatus +import com.monadial.ash.domain.entities.MessageContent +import com.monadial.ash.domain.entities.MessageDirection +import com.monadial.ash.domain.repositories.ConversationRepository +import com.monadial.ash.domain.repositories.PadRepository +import com.monadial.ash.domain.services.CryptoService +import com.monadial.ash.domain.services.RelayService +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +@DisplayName("SendMessageUseCase") +class SendMessageUseCaseTest { + + private lateinit var conversationRepository: ConversationRepository + private lateinit var padRepository: PadRepository + private lateinit var cryptoService: CryptoService + private lateinit var relayService: RelayService + private lateinit var useCase: SendMessageUseCase + + @BeforeEach + fun setup() { + conversationRepository = mockk(relaxed = true) + padRepository = mockk(relaxed = true) + cryptoService = mockk(relaxed = true) + relayService = mockk(relaxed = true) + useCase = SendMessageUseCase(conversationRepository, padRepository, cryptoService, relayService) + } + + @Nested + @DisplayName("invoke() with text messages") + inner class TextMessageTests { + + @Test + @DisplayName("should send text message successfully as initiator") + fun sendTextAsInitiator() = runTest { + // Given + val conversation = TestFixtures.createConversation( + role = ConversationRole.INITIATOR, + padConsumedFront = 100 + ) + val content = MessageContent.Text("Hello, World!") + val expectedBlobId = "blob-123" + + coEvery { padRepository.canSend(any(), any(), any()) } returns AppResult.Success(true) + coEvery { padRepository.nextSendOffset(any(), any()) } returns AppResult.Success(100L) + coEvery { padRepository.consumeForSending(any(), any(), any()) } returns AppResult.Success( + ByteArray(13) { 0xFF.toByte() } + ) + every { cryptoService.encrypt(any(), any()) } returns ByteArray(13) { 0xAA.toByte() } + coEvery { conversationRepository.saveConversation(any()) } returns AppResult.Success(Unit) + coEvery { relayService.submitMessage(any(), any(), any(), any(), any(), any(), any(), any()) } returns + AppResult.Success(SendResult(success = true, blobId = expectedBlobId)) + + // When + val result = useCase(conversation, content) + + // Then + assertThat(result.isSuccess).isTrue() + val sendResult = result.getOrNull()!! + assertThat(sendResult.blobId).isEqualTo(expectedBlobId) + assertThat(sendResult.message.direction).isEqualTo(MessageDirection.SENT) + assertThat(sendResult.message.status).isEqualTo(DeliveryStatus.SENT) + assertThat(sendResult.message.content).isEqualTo(content) + } + + @Test + @DisplayName("should send text message successfully as responder") + fun sendTextAsResponder() = runTest { + // Given + val conversation = TestFixtures.createConversation( + role = ConversationRole.RESPONDER, + padTotalSize = 65536, + padConsumedBack = 100 + ) + val content = MessageContent.Text("Response message") + val expectedBlobId = "blob-456" + + coEvery { padRepository.canSend(any(), any(), any()) } returns AppResult.Success(true) + coEvery { padRepository.getPadState(any()) } returns AppResult.Success( + PadState( + totalBytes = 65536, + consumedFront = 0, + consumedBack = 100, + remaining = 65436, + isExhausted = false + ) + ) + coEvery { padRepository.consumeForSending(any(), any(), any()) } returns AppResult.Success( + ByteArray(16) { 0xFF.toByte() } + ) + every { cryptoService.encrypt(any(), any()) } returns ByteArray(16) { 0xBB.toByte() } + coEvery { conversationRepository.saveConversation(any()) } returns AppResult.Success(Unit) + coEvery { relayService.submitMessage(any(), any(), any(), any(), any(), any(), any(), any()) } returns + AppResult.Success(SendResult(success = true, blobId = expectedBlobId)) + + // When + val result = useCase(conversation, content) + + // Then + assertThat(result.isSuccess).isTrue() + val sendResult = result.getOrNull()!! + assertThat(sendResult.blobId).isEqualTo(expectedBlobId) + } + + @Test + @DisplayName("should return error when pad is exhausted") + fun padExhausted() = runTest { + // Given + val conversation = TestFixtures.createConversation() + val content = MessageContent.Text("Test message") + + coEvery { padRepository.canSend(any(), any(), any()) } returns AppResult.Success(false) + + // When + val result = useCase(conversation, content) + + // Then + assertThat(result.isError).isTrue() + val error = result.errorOrNull() + assertThat(error).isInstanceOf(AppError.Pad.Exhausted::class.java) + } + + @Test + @DisplayName("should return error when canSend check fails") + fun canSendCheckFails() = runTest { + // Given + val conversation = TestFixtures.createConversation() + val content = MessageContent.Text("Test message") + + coEvery { padRepository.canSend(any(), any(), any()) } returns + AppResult.Error(AppError.Storage.ReadFailed("Pad not found")) + + // When + val result = useCase(conversation, content) + + // Then + assertThat(result.isError).isTrue() + } + + @Test + @DisplayName("should return error when relay submission fails") + fun relaySubmitFails() = runTest { + // Given + val conversation = TestFixtures.createConversation(role = ConversationRole.INITIATOR) + val content = MessageContent.Text("Test message") + + coEvery { padRepository.canSend(any(), any(), any()) } returns AppResult.Success(true) + coEvery { padRepository.nextSendOffset(any(), any()) } returns AppResult.Success(0L) + coEvery { padRepository.consumeForSending(any(), any(), any()) } returns AppResult.Success(ByteArray(12)) + every { cryptoService.encrypt(any(), any()) } returns ByteArray(12) + coEvery { conversationRepository.saveConversation(any()) } returns AppResult.Success(Unit) + coEvery { relayService.submitMessage(any(), any(), any(), any(), any(), any(), any(), any()) } returns + AppResult.Error(AppError.Network.ConnectionFailed("Network error")) + + // When + val result = useCase(conversation, content) + + // Then + assertThat(result.isError).isTrue() + val error = result.errorOrNull() + assertThat(error).isInstanceOf(AppError.Network.ConnectionFailed::class.java) + } + + @Test + @DisplayName("should return error when relay returns success but no blobId") + fun relayReturnsNoBlobId() = runTest { + // Given + val conversation = TestFixtures.createConversation(role = ConversationRole.INITIATOR) + val content = MessageContent.Text("Test message") + + coEvery { padRepository.canSend(any(), any(), any()) } returns AppResult.Success(true) + coEvery { padRepository.nextSendOffset(any(), any()) } returns AppResult.Success(0L) + coEvery { padRepository.consumeForSending(any(), any(), any()) } returns AppResult.Success(ByteArray(12)) + every { cryptoService.encrypt(any(), any()) } returns ByteArray(12) + coEvery { conversationRepository.saveConversation(any()) } returns AppResult.Success(Unit) + coEvery { relayService.submitMessage(any(), any(), any(), any(), any(), any(), any(), any()) } returns + AppResult.Success(SendResult(success = true, blobId = null)) + + // When + val result = useCase(conversation, content) + + // Then + assertThat(result.isError).isTrue() + val error = result.errorOrNull() + assertThat(error).isInstanceOf(AppError.Relay.SubmitFailed::class.java) + } + } + + @Nested + @DisplayName("invoke() with location messages") + inner class LocationMessageTests { + + @Test + @DisplayName("should send location message successfully") + fun sendLocation() = runTest { + // Given + val conversation = TestFixtures.createConversation(role = ConversationRole.INITIATOR) + val content = MessageContent.Location(latitude = 52.3676, longitude = 4.9041) + val expectedBlobId = "blob-location" + + coEvery { padRepository.canSend(any(), any(), any()) } returns AppResult.Success(true) + coEvery { padRepository.nextSendOffset(any(), any()) } returns AppResult.Success(0L) + coEvery { padRepository.consumeForSending(any(), any(), any()) } returns + AppResult.Success(ByteArray(24) { 0xFF.toByte() }) + every { cryptoService.encrypt(any(), any()) } returns ByteArray(24) { 0xCC.toByte() } + coEvery { conversationRepository.saveConversation(any()) } returns AppResult.Success(Unit) + coEvery { relayService.submitMessage(any(), any(), any(), any(), any(), any(), any(), any()) } returns + AppResult.Success(SendResult(success = true, blobId = expectedBlobId)) + + // When + val result = useCase(conversation, content) + + // Then + assertThat(result.isSuccess).isTrue() + val sendResult = result.getOrNull()!! + assertThat(sendResult.blobId).isEqualTo(expectedBlobId) + assertThat(sendResult.message.content).isEqualTo(content) + } + } + + @Nested + @DisplayName("encryption verification") + inner class EncryptionTests { + + @Test + @DisplayName("should use consumed pad bytes for encryption") + fun usesConsumedPadBytes() = runTest { + // Given + val conversation = TestFixtures.createConversation(role = ConversationRole.INITIATOR) + val content = MessageContent.Text("Test") + val keyBytes = byteArrayOf(0x01, 0x02, 0x03, 0x04) + val ciphertext = byteArrayOf(0xAA.toByte(), 0xBB.toByte(), 0xCC.toByte(), 0xDD.toByte()) + + coEvery { padRepository.canSend(any(), any(), any()) } returns AppResult.Success(true) + coEvery { padRepository.nextSendOffset(any(), any()) } returns AppResult.Success(0L) + coEvery { padRepository.consumeForSending(any(), any(), any()) } returns AppResult.Success(keyBytes) + every { cryptoService.encrypt(keyBytes, any()) } returns ciphertext + coEvery { conversationRepository.saveConversation(any()) } returns AppResult.Success(Unit) + coEvery { relayService.submitMessage(any(), any(), ciphertext, any(), any(), any(), any(), any()) } returns + AppResult.Success(SendResult(success = true, blobId = "blob-123")) + + // When + val result = useCase(conversation, content) + + // Then + assertThat(result.isSuccess).isTrue() + coVerify { relayService.submitMessage(any(), any(), ciphertext, any(), any(), any(), any(), any()) } + } + } +} diff --git a/apps/android/app/src/test/java/com/monadial/ash/ui/viewmodels/ConversationsViewModelTest.kt b/apps/android/app/src/test/java/com/monadial/ash/ui/viewmodels/ConversationsViewModelTest.kt new file mode 100644 index 0000000..3c7c89d --- /dev/null +++ b/apps/android/app/src/test/java/com/monadial/ash/ui/viewmodels/ConversationsViewModelTest.kt @@ -0,0 +1,267 @@ +package com.monadial.ash.ui.viewmodels + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.monadial.ash.TestFixtures +import com.monadial.ash.core.common.AppError +import com.monadial.ash.core.common.AppResult +import com.monadial.ash.domain.entities.Conversation +import com.monadial.ash.domain.repositories.ConversationRepository +import com.monadial.ash.domain.repositories.PadRepository +import com.monadial.ash.domain.usecases.conversation.BurnConversationUseCase +import com.monadial.ash.domain.usecases.conversation.CheckBurnStatusUseCase +import com.monadial.ash.domain.usecases.conversation.GetConversationsUseCase +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +@DisplayName("ConversationsViewModel") +class ConversationsViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + + private lateinit var getConversationsUseCase: GetConversationsUseCase + private lateinit var burnConversationUseCase: BurnConversationUseCase + private lateinit var checkBurnStatusUseCase: CheckBurnStatusUseCase + private lateinit var conversationRepository: ConversationRepository + private lateinit var padRepository: PadRepository + + private val conversationsFlow = MutableStateFlow>(emptyList()) + + @BeforeEach + fun setup() { + Dispatchers.setMain(testDispatcher) + + getConversationsUseCase = mockk(relaxed = true) + burnConversationUseCase = mockk(relaxed = true) + checkBurnStatusUseCase = mockk(relaxed = true) + conversationRepository = mockk(relaxed = true) + padRepository = mockk(relaxed = true) + + every { getConversationsUseCase.conversations } returns conversationsFlow + coEvery { getConversationsUseCase() } returns AppResult.Success(emptyList()) + } + + @AfterEach + fun tearDown() { + Dispatchers.resetMain() + } + + private fun createViewModel() = ConversationsViewModel( + getConversationsUseCase = getConversationsUseCase, + burnConversationUseCase = burnConversationUseCase, + checkBurnStatusUseCase = checkBurnStatusUseCase, + conversationRepository = conversationRepository, + padRepository = padRepository + ) + + @Nested + @DisplayName("initialization") + inner class InitializationTests { + + @Test + @DisplayName("should load conversations on initialization") + fun loadsConversationsOnInit() = runTest { + // Given + val conversations = TestFixtures.createMultipleConversations(3) + conversationsFlow.value = conversations + coEvery { getConversationsUseCase() } returns AppResult.Success(conversations) + + // When + val viewModel = createViewModel() + advanceUntilIdle() + + // Then + assertThat(viewModel.conversations.value).hasSize(3) + coVerify { getConversationsUseCase() } + } + + @Test + @DisplayName("should set error when loading fails") + fun setsErrorOnLoadFailure() = runTest { + // Given + coEvery { getConversationsUseCase() } returns + AppResult.Error(AppError.Storage.ReadFailed("Database error")) + + // When + val viewModel = createViewModel() + advanceUntilIdle() + + // Then + assertThat(viewModel.error.value).contains("Database error") + } + } + + @Nested + @DisplayName("refresh()") + inner class RefreshTests { + + @Test + @DisplayName("should set isRefreshing during refresh") + fun setsIsRefreshingDuringRefresh() = runTest { + // Given + val viewModel = createViewModel() + advanceUntilIdle() + coEvery { getConversationsUseCase.refresh() } returns AppResult.Success(emptyList()) + + // When + viewModel.isRefreshing.test { + assertThat(awaitItem()).isFalse() // Initial state + + viewModel.refresh() + assertThat(awaitItem()).isTrue() // Refreshing started + + advanceUntilIdle() + assertThat(awaitItem()).isFalse() // Refreshing completed + } + } + + @Test + @DisplayName("should check burn status for all conversations during refresh") + fun checksBurnStatusOnRefresh() = runTest { + // Given + val conversations = TestFixtures.createMultipleConversations(2) + conversationsFlow.value = conversations + coEvery { getConversationsUseCase() } returns AppResult.Success(conversations) + coEvery { getConversationsUseCase.refresh() } returns AppResult.Success(conversations) + + val viewModel = createViewModel() + advanceUntilIdle() + + // When + viewModel.refresh() + advanceUntilIdle() + + // Then + coVerify { checkBurnStatusUseCase.checkAll(conversations) } + } + + @Test + @DisplayName("should not check burn status when no conversations") + fun skipsBurnStatusWhenEmpty() = runTest { + // Given + conversationsFlow.value = emptyList() + coEvery { getConversationsUseCase() } returns AppResult.Success(emptyList()) + coEvery { getConversationsUseCase.refresh() } returns AppResult.Success(emptyList()) + + val viewModel = createViewModel() + advanceUntilIdle() + + // When + viewModel.refresh() + advanceUntilIdle() + + // Then + coVerify(exactly = 0) { checkBurnStatusUseCase.checkAll(any()) } + } + } + + @Nested + @DisplayName("burnConversation()") + inner class BurnConversationTests { + + @Test + @DisplayName("should burn conversation successfully") + fun burnsConversationSuccessfully() = runTest { + // Given + val conversation = TestFixtures.createConversation() + conversationsFlow.value = listOf(conversation) + coEvery { getConversationsUseCase() } returns AppResult.Success(listOf(conversation)) + coEvery { burnConversationUseCase(conversation) } returns AppResult.Success(Unit) + + val viewModel = createViewModel() + advanceUntilIdle() + + // When + viewModel.burnConversation(conversation) + advanceUntilIdle() + + // Then + coVerify { burnConversationUseCase(conversation) } + assertThat(viewModel.error.value).isNull() + } + + @Test + @DisplayName("should set error when burn fails") + fun setsErrorOnBurnFailure() = runTest { + // Given + val conversation = TestFixtures.createConversation() + coEvery { getConversationsUseCase() } returns AppResult.Success(listOf(conversation)) + coEvery { burnConversationUseCase(conversation) } returns + AppResult.Error(AppError.Storage.WriteFailed("Burn failed")) + + val viewModel = createViewModel() + advanceUntilIdle() + + // When + viewModel.burnConversation(conversation) + advanceUntilIdle() + + // Then + assertThat(viewModel.error.value).contains("Failed to burn conversation") + } + } + + @Nested + @DisplayName("deleteConversation()") + inner class DeleteConversationTests { + + @Test + @DisplayName("should wipe pad and delete conversation") + fun wipesAndDeletes() = runTest { + // Given + val conversationId = "test-conversation-id" + coEvery { getConversationsUseCase() } returns AppResult.Success(emptyList()) + + val viewModel = createViewModel() + advanceUntilIdle() + + // When + viewModel.deleteConversation(conversationId) + advanceUntilIdle() + + // Then + coVerify { padRepository.wipePad(conversationId) } + coVerify { conversationRepository.deleteConversation(conversationId) } + } + } + + @Nested + @DisplayName("clearError()") + inner class ClearErrorTests { + + @Test + @DisplayName("should clear error state") + fun clearsError() = runTest { + // Given + coEvery { getConversationsUseCase() } returns + AppResult.Error(AppError.Storage.ReadFailed("Some error")) + + val viewModel = createViewModel() + advanceUntilIdle() + + assertThat(viewModel.error.value).isNotNull() + + // When + viewModel.clearError() + + // Then + assertThat(viewModel.error.value).isNull() + } + } +} diff --git a/apps/android/build.gradle.kts b/apps/android/build.gradle.kts index 73af60d..a555182 100644 --- a/apps/android/build.gradle.kts +++ b/apps/android/build.gradle.kts @@ -4,4 +4,6 @@ plugins { alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.hilt) apply false alias(libs.plugins.ksp) apply false + alias(libs.plugins.detekt) apply false + alias(libs.plugins.ktlint) apply false } diff --git a/apps/android/config/detekt/detekt.yml b/apps/android/config/detekt/detekt.yml new file mode 100644 index 0000000..1eac721 --- /dev/null +++ b/apps/android/config/detekt/detekt.yml @@ -0,0 +1,574 @@ +# Detekt configuration following Google Android coding standards +# https://developer.android.com/kotlin/style-guide + +build: + maxIssues: 0 + excludeCorrectable: false + +config: + validation: true + warningsAsErrors: false + +processors: + active: true + +console-reports: + active: true + +output-reports: + active: true + +comments: + active: true + AbsentOrWrongFileLicense: + active: false + CommentOverPrivateFunction: + active: false + CommentOverPrivateProperty: + active: false + DeprecatedBlockTag: + active: true + EndOfSentenceFormat: + active: false + KDocReferencesNonPublicProperty: + active: false + OutdatedDocumentation: + active: false + UndocumentedPublicClass: + active: false + UndocumentedPublicFunction: + active: false + UndocumentedPublicProperty: + active: false + +complexity: + active: true + CognitiveComplexMethod: + active: true + threshold: 60 + ComplexCondition: + active: true + threshold: 4 + ComplexInterface: + active: false + CyclomaticComplexMethod: + active: true + threshold: 20 + LabeledExpression: + active: false + LargeClass: + active: true + threshold: 600 + LongMethod: + active: true + threshold: 150 + ignoreAnnotated: + - 'Composable' + LongParameterList: + active: true + functionThreshold: 20 + constructorThreshold: 12 + ignoreDefaultParameters: true + ignoreDataClasses: true + ignoreAnnotatedParameter: + - 'Composable' + MethodOverloading: + active: false + NamedArguments: + active: false + NestedBlockDepth: + active: true + threshold: 6 + NestedScopeFunctions: + active: true + threshold: 3 + ReplaceSafeCallChainWithRun: + active: false + StringLiteralDuplication: + active: false + TooManyFunctions: + active: true + thresholdInFiles: 30 + thresholdInClasses: 30 + thresholdInInterfaces: 20 + thresholdInObjects: 20 + thresholdInEnums: 15 + ignoreDeprecated: true + ignorePrivate: true + ignoreOverridden: true + +coroutines: + active: true + GlobalCoroutineUsage: + active: true + InjectDispatcher: + active: true + RedundantSuspendModifier: + active: true + SleepInsteadOfDelay: + active: true + SuspendFunSwallowedCancellation: + active: true + SuspendFunWithCoroutineScopeReceiver: + active: true + SuspendFunWithFlowReturnType: + active: true + +empty-blocks: + active: true + EmptyCatchBlock: + active: true + allowedExceptionNameRegex: '_|(ignore|expected).*' + EmptyClassBlock: + active: true + EmptyDefaultConstructor: + active: true + EmptyDoWhileBlock: + active: true + EmptyElseBlock: + active: true + EmptyFinallyBlock: + active: true + EmptyForBlock: + active: true + EmptyFunctionBlock: + active: true + ignoreOverridden: true + EmptyIfBlock: + active: true + EmptyInitBlock: + active: true + EmptyKtFile: + active: true + EmptySecondaryConstructor: + active: true + EmptyTryBlock: + active: true + EmptyWhenBlock: + active: true + EmptyWhileBlock: + active: true + +exceptions: + active: true + ExceptionRaisedInUnexpectedLocation: + active: true + InstanceOfCheckForException: + active: true + NotImplementedDeclaration: + active: true + ObjectExtendsThrowable: + active: false + PrintStackTrace: + active: true + RethrowCaughtException: + active: true + ReturnFromFinally: + active: true + SwallowedException: + active: true + ignoredExceptionTypes: + - 'InterruptedException' + - 'MalformedURLException' + - 'NumberFormatException' + - 'ParseException' + - 'Exception' + allowedExceptionNameRegex: '_|ignored|expected|e|e2' + ThrowingExceptionFromFinally: + active: true + ThrowingExceptionInMain: + active: false + ThrowingExceptionsWithoutMessageOrCause: + active: true + excludes: ['**/test/**', '**/androidTest/**'] + ThrowingNewInstanceOfSameException: + active: true + TooGenericExceptionCaught: + active: true + exceptionNames: + - 'ArrayIndexOutOfBoundsException' + - 'Error' + - 'IllegalMonitorStateException' + - 'IndexOutOfBoundsException' + - 'NullPointerException' + - 'RuntimeException' + - 'Throwable' + allowedExceptionNameRegex: '_|(ignore|expected).*' + TooGenericExceptionThrown: + active: true + exceptionNames: + - 'Error' + - 'Exception' + - 'RuntimeException' + - 'Throwable' + +naming: + active: true + BooleanPropertyNaming: + active: true + allowedPattern: '^(is|has|are|should|can|was|will|does|do)' + ClassNaming: + active: true + classPattern: '[A-Z][a-zA-Z0-9]*' + ConstructorParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9_]*' + privateParameterPattern: '_?[a-z][A-Za-z0-9_]*' + excludeClassPattern: '$^' + EnumNaming: + active: true + enumEntryPattern: '[A-Z][_a-zA-Z0-9]*' + ForbiddenClassName: + active: false + FunctionMaxLength: + active: false + FunctionMinLength: + active: false + FunctionNaming: + active: true + excludes: ['**/test/**', '**/androidTest/**'] + functionPattern: '[a-z][a-zA-Z0-9]*' + excludeClassPattern: '$^' + ignoreAnnotated: + - 'Composable' + FunctionParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + InvalidPackageDeclaration: + active: true + rootPackage: 'com.monadial.ash' + LambdaParameterNaming: + active: true + parameterPattern: '[a-z][A-Za-z0-9]*|_' + MatchingDeclarationName: + active: false + MemberNameEqualsClassName: + active: true + ignoreOverridden: true + NoNameShadowing: + active: true + NonBooleanPropertyPrefixedWithIs: + active: true + ObjectPropertyNaming: + active: true + constantPattern: '[A-Za-z][_A-Za-z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*' + PackageNaming: + active: true + packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*' + TopLevelPropertyNaming: + active: true + constantPattern: '[A-Z][_A-Z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' + VariableMaxLength: + active: false + VariableMinLength: + active: false + VariableNaming: + active: true + variablePattern: '[a-z][A-Za-z0-9]*' + privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' + excludeClassPattern: '$^' + +performance: + active: true + ArrayPrimitive: + active: true + CouldBeSequence: + active: true + threshold: 3 + ForEachOnRange: + active: true + SpreadOperator: + active: false + UnnecessaryPartOfBinaryExpression: + active: true + UnnecessaryTemporaryInstantiation: + active: true + +potential-bugs: + active: true + AvoidReferentialEquality: + active: true + CastNullableToNonNullableType: + active: true + CastToNullableType: + active: false + Deprecation: + active: false + DontDowncastCollectionTypes: + active: true + DoubleMutabilityForCollection: + active: true + ElseCaseInsteadOfExhaustiveWhen: + active: true + EqualsAlwaysReturnsTrueOrFalse: + active: true + EqualsWithHashCodeExist: + active: true + ExitOutsideMain: + active: false + ExplicitGarbageCollectionCall: + active: true + HasPlatformType: + active: true + IgnoredReturnValue: + active: true + ImplicitDefaultLocale: + active: false + ImplicitUnitReturnType: + active: true + InvalidRange: + active: true + IteratorHasNextCallsNextMethod: + active: true + IteratorNotThrowingNoSuchElementException: + active: true + LateinitUsage: + active: false + MapGetWithNotNullAssertionOperator: + active: true + MissingPackageDeclaration: + active: true + NullCheckOnMutableProperty: + active: true + NullableToStringCall: + active: true + PropertyUsedBeforeDeclaration: + active: true + UnconditionalJumpStatementInLoop: + active: true + UnnecessaryNotNullCheck: + active: true + UnnecessaryNotNullOperator: + active: true + UnnecessarySafeCall: + active: true + UnreachableCatchBlock: + active: true + UnreachableCode: + active: true + UnsafeCallOnNullableType: + active: true + UnsafeCast: + active: true + UnusedUnaryOperator: + active: true + UselessPostfixExpression: + active: true + WrongEqualsTypeParameter: + active: true + +style: + active: true + AlsoCouldBeApply: + active: true + BracesOnIfStatements: + active: true + singleLine: 'consistent' + multiLine: 'always' + BracesOnWhenStatements: + active: true + singleLine: 'necessary' + multiLine: 'consistent' + CanBeNonNullable: + active: true + CascadingCallWrapping: + active: true + includeElvis: true + ClassOrdering: + active: false + CollapsibleIfStatements: + active: true + DataClassContainsFunctions: + active: false + DataClassShouldBeImmutable: + active: true + DestructuringDeclarationWithTooManyEntries: + active: true + maxDestructuringEntries: 3 + EqualsNullCall: + active: true + EqualsOnSignatureLine: + active: true + ExplicitCollectionElementAccessMethod: + active: true + ExplicitItLambdaParameter: + active: true + ExpressionBodySyntax: + active: false + ForbiddenAnnotation: + active: false + ForbiddenComment: + active: false + ForbiddenImport: + active: false + ForbiddenMethodCall: + active: false + ForbiddenSuppress: + active: false + ForbiddenVoid: + active: true + ignoreOverridden: false + ignoreUsageInGenerics: false + FunctionOnlyReturningConstant: + active: true + ignoreOverridableFunction: true + ignoreActualFunction: true + excludedFunctions: [] + LoopWithTooManyJumpStatements: + active: true + maxJumpCount: 1 + MagicNumber: + active: false + MandatoryBracesLoops: + active: true + MaxChainedCallsOnSameLine: + active: true + maxChainedCalls: 5 + MaxLineLength: + active: true + maxLineLength: 120 + excludePackageStatements: true + excludeImportStatements: true + excludeCommentStatements: false + excludeRawStrings: true + MayBeConst: + active: true + ModifierOrder: + active: true + MultilineLambdaItParameter: + active: true + MultilineRawStringIndentation: + active: true + indentSize: 4 + NestedClassesVisibility: + active: true + NewLineAtEndOfFile: + active: true + NoTabs: + active: true + NullableBooleanCheck: + active: true + ObjectLiteralToLambda: + active: true + OptionalAbstractKeyword: + active: true + OptionalUnit: + active: false + PreferToOverPairSyntax: + active: true + ProtectedMemberInFinalClass: + active: true + RedundantExplicitType: + active: true + RedundantHigherOrderMapUsage: + active: true + RedundantVisibilityModifierRule: + active: true + ReturnCount: + active: true + max: 3 + excludedFunctions: + - 'equals' + excludeLabeled: false + excludeReturnFromLambda: true + excludeGuardClauses: true + SafeCast: + active: true + SerialVersionUIDInSerializableClass: + active: true + SpacingBetweenPackageAndImports: + active: true + StringShouldBeRawString: + active: true + maxEscapedCharacterCount: 2 + ignoredCharacters: [] + ThrowsCount: + active: true + max: 2 + excludeGuardClauses: false + TrailingWhitespace: + active: true + TrimMultilineRawString: + active: true + UnderscoresInNumericLiterals: + active: false + UnnecessaryAbstractClass: + active: true + UnnecessaryAnnotationUseSiteTarget: + active: true + UnnecessaryApply: + active: true + UnnecessaryBackticks: + active: true + UnnecessaryBracesAroundTrailingLambda: + active: true + UnnecessaryFilter: + active: true + UnnecessaryInheritance: + active: true + UnnecessaryInnerClass: + active: true + UnnecessaryLet: + active: true + UnnecessaryParentheses: + active: false + UntilInsteadOfRangeTo: + active: true + UnusedImports: + active: true + UnusedParameter: + active: true + allowedNames: 'ignored|expected' + UnusedPrivateClass: + active: true + UnusedPrivateMember: + active: true + allowedNames: '' + UnusedPrivateProperty: + active: true + allowedNames: '_|ignored|expected|serialVersionUID' + UseAnyOrNoneInsteadOfFind: + active: true + UseArrayLiteralsInAnnotations: + active: true + UseCheckNotNull: + active: true + UseCheckOrError: + active: false + UseDataClass: + active: true + allowVars: false + UseEmptyCounterpart: + active: true + UseIfEmptyOrIfBlank: + active: true + UseIfInsteadOfWhen: + active: false + UseIsNullOrEmpty: + active: true + UseLet: + active: false + UseOrEmpty: + active: true + UseRequire: + active: true + UseRequireNotNull: + active: true + UseSumOfInsteadOfFlatMapSize: + active: true + UselessCallOnNotNull: + active: true + UtilityClassWithPublicConstructor: + active: true + VarCouldBeVal: + active: true + WildcardImport: + active: true + excludeImports: + - 'java.util.*' + - 'kotlinx.android.synthetic.*' diff --git a/apps/android/gradle/libs.versions.toml b/apps/android/gradle/libs.versions.toml index 480d389..e69cea9 100644 --- a/apps/android/gradle/libs.versions.toml +++ b/apps/android/gradle/libs.versions.toml @@ -1,6 +1,8 @@ [versions] agp = "8.13.2" kotlin = "2.1.0" +detekt = "1.23.7" +ktlint = "12.1.2" coreKtx = "1.15.0" lifecycleRuntimeKtx = "2.8.7" activityCompose = "1.9.3" @@ -20,6 +22,11 @@ securityCrypto = "1.1.0-alpha06" coroutines = "1.9.0" accompanist = "0.36.0" playServicesLocation = "21.3.0" +junit5 = "5.10.2" +mockk = "1.13.10" +turbine = "1.1.0" +truth = "1.4.2" +archCoreTesting = "2.2.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -80,9 +87,22 @@ accompanist-permissions = { group = "com.google.accompanist", name = "accompanis # Play Services Location play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" } +# Testing +junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit5" } +junit-jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit5" } +junit-jupiter-params = { group = "org.junit.jupiter", name = "junit-jupiter-params", version.ref = "junit5" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" } +turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } +truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } +coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } +arch-core-testing = { group = "androidx.arch.core", name = "core-testing", version.ref = "archCoreTesting" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } +ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }