From c770c49178423b9ff6baafaef3469fb2a5662934 Mon Sep 17 00:00:00 2001 From: stanvx Date: Mon, 28 Jul 2025 22:54:42 +1000 Subject: [PATCH 01/19] Implement OpenAI API integration with error handling and transcription/summarization features - Added OpenAIException class hierarchy for structured error handling. - Created OpenAIResponse model to standardize API responses. - Developed OpenAIRepository interface for API operations including transcription and summarization. - Implemented SummarizeTextUseCase for hybrid summarization using OpenAI and local methods. - Implemented TranscribeAudioUseCase for hybrid audio transcription using OpenAI and local models. - Added unit tests for AiSettingsRepository and SecurityHelper to ensure proper functionality and validation. --- gradle/libs.versions.toml | 19 + shared/build.gradle.kts | 15 + .../SecurePreferencesRepositoryImpl.kt | 271 ++++++++++ .../notelycompose/di/Modules.android.kt | 16 + .../NetworkConnectivityManagerImpl.android.kt | 160 ++++++ .../audio/ui/recorder/RecordingScreen.kt | 456 ++++++++++------- .../core/security/AiSettingsRepository.kt | 194 ++++++++ .../security/SecurePreferencesRepository.kt | 79 +++ .../core/security/SecurityHelper.kt | 140 ++++++ .../com/module/notelycompose/di/Modules.kt | 23 + .../detail/TextEditorViewModel.kt | 54 +- .../settings/AISettingsViewModel.kt | 313 ++++++++++++ .../ui/richtext/AnimatedBottomToolbar.kt | 113 +++++ .../ui/richtext/RichTextEditorTestHelper.kt | 254 ++++++++++ .../ui/richtext/RichTextShortcutsOverlay.kt | 122 +++++ .../notes/ui/richtext/RichTextUtilities.kt | 67 +++ .../notes/ui/settings/AISettingsScreen.kt | 359 +++++++++++++ .../data/repository/OpenAIRepositoryImpl.kt | 470 ++++++++++++++++++ .../domain/NetworkConnectivityManager.kt | 53 ++ .../domain/exception/OpenAIException.kt | 174 +++++++ .../openai/domain/model/OpenAIResponse.kt | 95 ++++ .../domain/repository/OpenAIRepository.kt | 91 ++++ .../domain/usecase/SummarizeTextUseCase.kt | 340 +++++++++++++ .../domain/usecase/TranscribeAudioUseCase.kt | 221 ++++++++ .../core/security/AiSettingsRepositoryTest.kt | 234 +++++++++ .../core/security/SecurityHelperTest.kt | 218 ++++++++ 26 files changed, 4372 insertions(+), 179 deletions(-) create mode 100644 shared/src/androidMain/kotlin/com/module/notelycompose/core/security/SecurePreferencesRepositoryImpl.kt create mode 100644 shared/src/androidMain/kotlin/com/module/notelycompose/openai/data/NetworkConnectivityManagerImpl.android.kt create mode 100644 shared/src/commonMain/kotlin/com/module/notelycompose/core/security/AiSettingsRepository.kt create mode 100644 shared/src/commonMain/kotlin/com/module/notelycompose/core/security/SecurePreferencesRepository.kt create mode 100644 shared/src/commonMain/kotlin/com/module/notelycompose/notes/presentation/settings/AISettingsViewModel.kt create mode 100644 shared/src/commonMain/kotlin/com/module/notelycompose/notes/ui/richtext/AnimatedBottomToolbar.kt create mode 100644 shared/src/commonMain/kotlin/com/module/notelycompose/notes/ui/richtext/RichTextEditorTestHelper.kt create mode 100644 shared/src/commonMain/kotlin/com/module/notelycompose/notes/ui/richtext/RichTextShortcutsOverlay.kt create mode 100644 shared/src/commonMain/kotlin/com/module/notelycompose/notes/ui/richtext/RichTextUtilities.kt create mode 100644 shared/src/commonMain/kotlin/com/module/notelycompose/notes/ui/settings/AISettingsScreen.kt create mode 100644 shared/src/commonMain/kotlin/com/module/notelycompose/openai/data/repository/OpenAIRepositoryImpl.kt create mode 100644 shared/src/commonMain/kotlin/com/module/notelycompose/openai/domain/NetworkConnectivityManager.kt create mode 100644 shared/src/commonMain/kotlin/com/module/notelycompose/openai/domain/exception/OpenAIException.kt create mode 100644 shared/src/commonMain/kotlin/com/module/notelycompose/openai/domain/model/OpenAIResponse.kt create mode 100644 shared/src/commonMain/kotlin/com/module/notelycompose/openai/domain/repository/OpenAIRepository.kt create mode 100644 shared/src/commonMain/kotlin/com/module/notelycompose/openai/domain/usecase/SummarizeTextUseCase.kt create mode 100644 shared/src/commonMain/kotlin/com/module/notelycompose/openai/domain/usecase/TranscribeAudioUseCase.kt create mode 100644 shared/src/commonTest/kotlin/com/module/notelycompose/core/security/AiSettingsRepositoryTest.kt create mode 100644 shared/src/commonTest/kotlin/com/module/notelycompose/core/security/SecurityHelperTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5ddd1dc7..2207ca94 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ androidx-lifecycle = "2.9.1" datastore = "1.1.7" core-ktx = "1.16.0" material = "1.12.0" +androidx-security-crypto = "1.1.0-alpha06" # Compose & UI accompanistSystemuicontrollerVersion = "0.36.0" @@ -40,6 +41,11 @@ napier = "2.7.1" sqldelight = "1.5.5" kotlinx-coroutines = "1.10.2" +# OpenAI & Network +openai-kotlin = "4.0.1" +ktor = "3.0.3" +kotlin-connect = "0.8.0" + [libraries] @@ -51,6 +57,7 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version androidx-core = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } androidx-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle" } +androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "androidx-security-crypto" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } # Compose & UI @@ -91,6 +98,18 @@ sqldelight-android-driver = { group = "com.squareup.sqldelight", name = "android sqldelight-native-driver = { group = "com.squareup.sqldelight", name = "native-driver", version.ref = "sqldelight" } sqldelight-gradle-plugin = { group = "com.squareup.sqldelight", name = "gradle-plugin", version.ref = "sqldelight" } +# OpenAI & Network +openai-kotlin = { group = "com.aallam.openai", name = "openai-client", version.ref = "openai-kotlin" } +ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } +ktor-client-json = { group = "io.ktor", name = "ktor-client-json", version.ref = "ktor" } +ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" } +ktor-client-serialization = { group = "io.ktor", name = "ktor-client-serialization", version.ref = "ktor" } +ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktor-client-android = { group = "io.ktor", name = "ktor-client-android", version.ref = "ktor" } +ktor-client-darwin = { group = "io.ktor", name = "ktor-client-darwin", version.ref = "ktor" } +kotlin-connect = { group = "com.autodesk", name = "coroutines-interop", version.ref = "kotlin-connect" } + # Splash Screen core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 32478520..35d02a0a 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -45,6 +45,12 @@ kotlin { // splash implementation(libs.core.splashscreen) implementation(libs.androidx.compose.documentfile) + + // security + implementation(libs.androidx.security.crypto) + + // Platform-specific Ktor client + implementation(libs.ktor.client.android) } commonMain.dependencies { @@ -85,6 +91,15 @@ kotlin { // HTML Sanitizer for security implementation("com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20220608.1") + // OpenAI & Network + implementation(libs.openai.kotlin) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.json) + implementation(libs.ktor.client.logging) + implementation(libs.ktor.client.serialization) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(project(":core:audio")) } diff --git a/shared/src/androidMain/kotlin/com/module/notelycompose/core/security/SecurePreferencesRepositoryImpl.kt b/shared/src/androidMain/kotlin/com/module/notelycompose/core/security/SecurePreferencesRepositoryImpl.kt new file mode 100644 index 00000000..9864888a --- /dev/null +++ b/shared/src/androidMain/kotlin/com/module/notelycompose/core/security/SecurePreferencesRepositoryImpl.kt @@ -0,0 +1,271 @@ +package com.module.notelycompose.core.security + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKeys +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import io.github.aakira.napier.Napier + +/** + * Android implementation of SecurePreferencesRepository using EncryptedSharedPreferences. + * Uses AES256 encryption with keys stored in Android Keystore. + */ +class SecurePreferencesRepositoryImpl( + private val context: Context, + private val securityMonitoringService: SecurityMonitoringService +) : SecurePreferencesRepository { + + private val mutex = Mutex() + private val keyPresenceStates = mutableMapOf>() + + companion object { + private const val PREFERENCES_NAME = "notely_secure_prefs" + private const val PRESENCE_SUFFIX = "_present" + } + + private val encryptedPreferences: SharedPreferences by lazy { + try { + val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) + + EncryptedSharedPreferences.create( + PREFERENCES_NAME, + masterKeyAlias, + context, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } catch (e: Exception) { + securityMonitoringService.reportSecurityEvent( + type = SecurityMonitoringService.SecurityEventType.ENCRYPTION_FAILURE, + severity = SecurityMonitoringService.SecuritySeverity.CRITICAL, + message = "Failed to initialize encrypted preferences", + details = mapOf( + "error" to (e.message ?: "Unknown error"), + "operation" to "initialization" + ), + throwable = e + ) + throw SecureStorageException("Failed to initialize secure storage", e) + } + } + + override suspend fun storeEncryptedApiKey(key: String, apiKey: String) { + mutex.withLock { + try { + validateApiKey(key, apiKey) + + encryptedPreferences.edit() + .putString(key, apiKey) + .putBoolean(key + PRESENCE_SUFFIX, true) + .apply() + + // Update presence state + getOrCreatePresenceFlow(key).value = true + + securityMonitoringService.reportSecurityEvent( + type = SecurityMonitoringService.SecurityEventType.ENCRYPTION_SUCCESS, + severity = SecurityMonitoringService.SecuritySeverity.LOW, + message = "API key stored successfully", + details = mapOf( + "key" to key, + "operation" to "store" + ) + ) + + Napier.d("API key stored securely for key: $key") + + } catch (e: Exception) { + securityMonitoringService.reportSecurityEvent( + type = SecurityMonitoringService.SecurityEventType.ENCRYPTION_FAILURE, + severity = SecurityMonitoringService.SecuritySeverity.HIGH, + message = "Failed to store API key", + details = mapOf( + "key" to key, + "error" to (e.message ?: "Unknown error"), + "operation" to "store" + ), + throwable = e + ) + + if (e is SecureStorageException) { + throw e + } else { + throw SecureStorageException("Failed to store API key for $key", e) + } + } + } + } + + override suspend fun getDecryptedApiKey(key: String): String? { + return mutex.withLock { + try { + val apiKey = encryptedPreferences.getString(key, null) + + if (apiKey != null) { + securityMonitoringService.reportSecurityEvent( + type = SecurityMonitoringService.SecurityEventType.DECRYPTION_SUCCESS, + severity = SecurityMonitoringService.SecuritySeverity.LOW, + message = "API key retrieved successfully", + details = mapOf( + "key" to key, + "operation" to "retrieve" + ) + ) + } + + apiKey + + } catch (e: Exception) { + securityMonitoringService.reportSecurityEvent( + type = SecurityMonitoringService.SecurityEventType.DECRYPTION_FAILURE, + severity = SecurityMonitoringService.SecuritySeverity.HIGH, + message = "Failed to retrieve API key", + details = mapOf( + "key" to key, + "error" to (e.message ?: "Unknown error"), + "operation" to "retrieve" + ), + throwable = e + ) + + throw SecureStorageException("Failed to retrieve API key for $key", e) + } + } + } + + override suspend fun removeApiKey(key: String) { + mutex.withLock { + try { + encryptedPreferences.edit() + .remove(key) + .remove(key + PRESENCE_SUFFIX) + .apply() + + // Update presence state + getOrCreatePresenceFlow(key).value = false + + securityMonitoringService.reportSecurityEvent( + type = SecurityMonitoringService.SecurityEventType.DATA_DELETION, + severity = SecurityMonitoringService.SecuritySeverity.LOW, + message = "API key removed successfully", + details = mapOf( + "key" to key, + "operation" to "remove" + ) + ) + + Napier.d("API key removed for key: $key") + + } catch (e: Exception) { + securityMonitoringService.reportSecurityEvent( + type = SecurityMonitoringService.SecurityEventType.DATA_DELETION, + severity = SecurityMonitoringService.SecuritySeverity.MEDIUM, + message = "Failed to remove API key", + details = mapOf( + "key" to key, + "error" to (e.message ?: "Unknown error"), + "operation" to "remove" + ), + throwable = e + ) + + throw SecureStorageException("Failed to remove API key for $key", e) + } + } + } + + override suspend fun hasApiKey(key: String): Boolean { + return mutex.withLock { + try { + encryptedPreferences.getBoolean(key + PRESENCE_SUFFIX, false) + } catch (e: Exception) { + securityMonitoringService.reportSecurityEvent( + type = SecurityMonitoringService.SecurityEventType.DECRYPTION_FAILURE, + severity = SecurityMonitoringService.SecuritySeverity.LOW, + message = "Failed to check API key presence", + details = mapOf( + "key" to key, + "error" to (e.message ?: "Unknown error"), + "operation" to "check_presence" + ), + throwable = e + ) + false + } + } + } + + override fun observeApiKeyPresence(key: String): Flow { + return getOrCreatePresenceFlow(key).asStateFlow() + } + + override suspend fun clearAll() { + mutex.withLock { + try { + encryptedPreferences.edit().clear().apply() + + // Reset all presence states + keyPresenceStates.values.forEach { flow -> + flow.value = false + } + + securityMonitoringService.reportSecurityEvent( + type = SecurityMonitoringService.SecurityEventType.DATA_DELETION, + severity = SecurityMonitoringService.SecuritySeverity.MEDIUM, + message = "All secure preferences cleared", + details = mapOf( + "operation" to "clear_all" + ) + ) + + Napier.w("All secure preferences cleared") + + } catch (e: Exception) { + securityMonitoringService.reportSecurityEvent( + type = SecurityMonitoringService.SecurityEventType.DATA_DELETION, + severity = SecurityMonitoringService.SecuritySeverity.HIGH, + message = "Failed to clear secure preferences", + details = mapOf( + "error" to (e.message ?: "Unknown error"), + "operation" to "clear_all" + ), + throwable = e + ) + + throw SecureStorageException("Failed to clear secure preferences", e) + } + } + } + + private fun getOrCreatePresenceFlow(key: String): MutableStateFlow { + return keyPresenceStates.getOrPut(key) { + val initialValue = encryptedPreferences.getBoolean(key + PRESENCE_SUFFIX, false) + MutableStateFlow(initialValue) + } + } + + private fun validateApiKey(key: String, apiKey: String) { + when { + key.isBlank() -> throw SecureStorageException("API key identifier cannot be blank") + apiKey.isBlank() -> throw SecureStorageException("API key cannot be blank") + apiKey.length < 10 -> throw SecureStorageException("API key too short") + apiKey.length > 200 -> throw SecureStorageException("API key too long") + !apiKey.matches(Regex("^[a-zA-Z0-9\\-_.]+$")) -> { + throw SecureStorageException("API key contains invalid characters") + } + } + + // Additional validation for OpenAI API keys + if (key == SecurePreferencesRepository.OPENAI_API_KEY) { + if (!apiKey.startsWith("sk-")) { + throw SecureStorageException("Invalid OpenAI API key format") + } + } + } +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/com/module/notelycompose/di/Modules.android.kt b/shared/src/androidMain/kotlin/com/module/notelycompose/di/Modules.android.kt index 818d7b87..096674ec 100644 --- a/shared/src/androidMain/kotlin/com/module/notelycompose/di/Modules.android.kt +++ b/shared/src/androidMain/kotlin/com/module/notelycompose/di/Modules.android.kt @@ -19,6 +19,10 @@ import com.module.notelycompose.platform.dataStore import com.module.notelycompose.transcription.domain.WhisperModelLoader import com.module.notelycompose.core.security.SecurityMonitoringService import com.module.notelycompose.core.security.SecurityMonitoringServiceImpl +import com.module.notelycompose.core.security.SecurePreferencesRepository +import com.module.notelycompose.core.security.SecurePreferencesRepositoryImpl +import com.module.notelycompose.openai.data.NetworkConnectivityManagerImpl +import com.module.notelycompose.openai.domain.NetworkConnectivityManager import com.squareup.sqldelight.android.AndroidSqliteDriver import com.squareup.sqldelight.db.SqlDriver import org.koin.core.qualifier.named @@ -60,6 +64,18 @@ actual val platformModule = module { appVersion = get(named("AppVersion")) ) } + + single { + SecurePreferencesRepositoryImpl( + context = get(), + securityMonitoringService = get() + ) + } + + // OpenAI & Network + single { + NetworkConnectivityManagerImpl(context = get()) + } // domain single { AudioRecorderInteractorImpl(get(), get(), get()) } diff --git a/shared/src/androidMain/kotlin/com/module/notelycompose/openai/data/NetworkConnectivityManagerImpl.android.kt b/shared/src/androidMain/kotlin/com/module/notelycompose/openai/data/NetworkConnectivityManagerImpl.android.kt new file mode 100644 index 00000000..6b2616b4 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/module/notelycompose/openai/data/NetworkConnectivityManagerImpl.android.kt @@ -0,0 +1,160 @@ +package com.module.notelycompose.openai.data + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.os.Build +import com.module.notelycompose.openai.domain.NetworkConnectivityManager +import com.module.notelycompose.openai.domain.NetworkType +import io.github.aakira.napier.Napier +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.withContext +import java.net.InetSocketAddress +import java.net.Socket + +/** + * Android implementation of NetworkConnectivityManager using ConnectivityManager. + */ +class NetworkConnectivityManagerImpl( + private val context: Context +) : NetworkConnectivityManager { + + private val connectivityManager: ConnectivityManager by lazy { + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + } + + override suspend fun isNetworkAvailable(): Boolean { + return withContext(Dispatchers.IO) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val network = connectivityManager.activeNetwork ?: return@withContext false + val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return@withContext false + + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) + } else { + @Suppress("DEPRECATION") + val networkInfo = connectivityManager.activeNetworkInfo + networkInfo?.isConnected == true + } + } catch (e: Exception) { + Napier.e("Error checking network availability", e) + false + } + } + } + + override suspend fun isMeteredConnection(): Boolean { + return withContext(Dispatchers.IO) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + connectivityManager.isActiveNetworkMetered + } else { + @Suppress("DEPRECATION") + val networkInfo = connectivityManager.activeNetworkInfo + networkInfo?.type == ConnectivityManager.TYPE_MOBILE + } + } catch (e: Exception) { + Napier.e("Error checking if connection is metered", e) + false + } + } + } + + override suspend fun getNetworkType(): NetworkType { + return withContext(Dispatchers.IO) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val network = connectivityManager.activeNetwork ?: return@withContext NetworkType.NONE + val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return@withContext NetworkType.NONE + + when { + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> NetworkType.WIFI + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> NetworkType.CELLULAR + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> NetworkType.ETHERNET + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH) -> NetworkType.BLUETOOTH + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> NetworkType.VPN + else -> NetworkType.UNKNOWN + } + } else { + @Suppress("DEPRECATION") + val networkInfo = connectivityManager.activeNetworkInfo + when (networkInfo?.type) { + ConnectivityManager.TYPE_WIFI -> NetworkType.WIFI + ConnectivityManager.TYPE_MOBILE -> NetworkType.CELLULAR + ConnectivityManager.TYPE_ETHERNET -> NetworkType.ETHERNET + ConnectivityManager.TYPE_BLUETOOTH -> NetworkType.BLUETOOTH + ConnectivityManager.TYPE_VPN -> NetworkType.VPN + else -> if (networkInfo?.isConnected == true) NetworkType.UNKNOWN else NetworkType.NONE + } + } + } catch (e: Exception) { + Napier.e("Error getting network type", e) + NetworkType.UNKNOWN + } + } + } + + override fun observeNetworkStatus(): Flow = callbackFlow { + val networkCallback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + trySend(true) + } + + override fun onLost(network: Network) { + trySend(false) + } + + override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) { + val isConnected = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + trySend(isConnected) + } + } + + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + connectivityManager.registerDefaultNetworkCallback(networkCallback) + } else { + val networkRequest = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + connectivityManager.registerNetworkCallback(networkRequest, networkCallback) + } + + // Send initial state + trySend(isNetworkAvailable()) + } catch (e: Exception) { + Napier.e("Error registering network callback", e) + trySend(false) + } + + awaitClose { + try { + connectivityManager.unregisterNetworkCallback(networkCallback) + } catch (e: Exception) { + Napier.e("Error unregistering network callback", e) + } + } + } + + override suspend fun testConnectivity(host: String): Boolean { + return withContext(Dispatchers.IO) { + try { + Socket().use { socket -> + socket.connect(InetSocketAddress(host, 443), 5000) + true + } + } catch (e: Exception) { + Napier.d("Connectivity test failed for host: $host", e) + false + } + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/audio/ui/recorder/RecordingScreen.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/audio/ui/recorder/RecordingScreen.kt index e5f4d48b..b92360d7 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/audio/ui/recorder/RecordingScreen.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/audio/ui/recorder/RecordingScreen.kt @@ -54,6 +54,7 @@ import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -286,118 +287,16 @@ private fun RecordingInProgressScreen( onResumeRecording: () -> Unit, isRecordPaused: Boolean ) { - Box( - modifier = Modifier - .fillMaxSize() - .background(LocalCustomColors.current.bodyBackgroundColor) - ) { - RecordingUiComponentBackButton( - onNavigateBack = onNavigateBack, - onStopRecording = onStopRecording - ) - - Column( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.Center), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(AppConstants.UI.STANDARD_SPACING_DP.dp) - ) { - // Main recording indicator - full-size gradient animation - AudioReactiveLottie( - amplitude = if (isRecordPaused) 0f else currentAmplitude, - isRecording = !isRecordPaused, - modifier = Modifier - .size(AppConstants.UI.LARGE_RECORDING_ANIMATION_DP.dp) // Large, prominent size for beautiful gradient display - ) - - // Timer display positioned below the animation - Text( - text = counterTimeString, - style = MaterialTheme.typography.displayMedium, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - fontSize = 32.sp - ) - - } - - Box( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = AppConstants.UI.BOTTOM_PADDING_DP.dp) - .align(Alignment.BottomCenter), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - - Box( - modifier = Modifier - .size(72.dp) - .clip(CircleShape) - .background( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = CircleShape - ) - .border( - width = 2.dp, - color = MaterialTheme.colorScheme.outline, - shape = CircleShape - ) - .clickable { - if (isRecordPaused) { - onResumeRecording() - } else { - onPauseRecording() - } - }, - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = if (!isRecordPaused) Images.Icons.IcPause else Icons.Filled.PlayArrow, - contentDescription = if (!isRecordPaused) "Pause recording" else "Resume recording", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(32.dp) - ) - } - - Box( - modifier = Modifier - .size(72.dp) - .clip(CircleShape) - .background( - color = MaterialTheme.colorScheme.error, - shape = CircleShape - ) - .clickable { onStopRecording() }, - contentAlignment = Alignment.Center - ) { - Box( - modifier = Modifier - .size(36.dp) - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.onError) - ) - } - } - - Text( - text = stringResource(Res.string.recording_ui_tap_stop_record), - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(top = 24.dp), - style = MaterialTheme.typography.bodyMedium - ) - } - } - } + ImmersiveRecordingScreen( + counterTimeString = counterTimeString, + currentAmplitude = currentAmplitude, + onNavigateBack = onNavigateBack, + onStopRecording = onStopRecording, + onPauseRecording = onPauseRecording, + onResumeRecording = onResumeRecording, + isRecordPaused = isRecordPaused, + showControls = true + ) } @Composable @@ -549,85 +448,300 @@ private fun QuickRecordingScreen( currentAmplitude: Float, onStopRecording: () -> Unit, onNavigateBack: () -> Unit +) { + ImmersiveRecordingScreen( + counterTimeString = counterTimeString, + currentAmplitude = currentAmplitude, + onNavigateBack = onNavigateBack, + onStopRecording = onStopRecording, + onPauseRecording = { /* Quick record doesn't support pause */ }, + onResumeRecording = { /* Quick record doesn't support resume */ }, + isRecordPaused = false, + showControls = false // Quick record only shows stop button + ) +} + +/** + * Enhanced immersive recording screen following Material 3 design principles. + * + * Features: + * - Larger, more prominent AudioReactiveLottie visualization (40-50% of screen height) + * - Material 3 surface elevation and dynamic theming + * - Proper accessibility with minimum 48dp touch targets + * - Material 3 color system and typography scale + * - Motion and animation using Material 3 tokens + * - Supporting visual elements (pulse rings, enhanced surfaces) + * - Configurable controls for full recording vs quick record modes + */ +@Composable +private fun ImmersiveRecordingScreen( + counterTimeString: String, + currentAmplitude: Float, + onNavigateBack: () -> Unit, + onStopRecording: () -> Unit, + onPauseRecording: () -> Unit, + onResumeRecording: () -> Unit, + isRecordPaused: Boolean, + showControls: Boolean = true ) { Box( modifier = Modifier .fillMaxSize() - .background(LocalCustomColors.current.bodyBackgroundColor) + .background(MaterialTheme.colorScheme.background) ) { - // Simple back button - IconButton( - onClick = onNavigateBack, + // Back button with proper accessibility + RecordingUiComponentBackButton( + onNavigateBack = onNavigateBack, + onStopRecording = onStopRecording + ) + + // Main content with enhanced visual hierarchy + Column( modifier = Modifier - .padding(16.dp) - .align(Alignment.TopStart) + .fillMaxSize() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(Res.string.top_bar_back), - tint = LocalCustomColors.current.bodyContentColor - ) + + // Top spacer for visual balance + Spacer(modifier = Modifier.height(80.dp)) + + // Recording status with Material 3 typography + if (!showControls) { + Text( + text = "Recording...", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(bottom = 32.dp) + ) + } + + // Enhanced central visualization area with Material 3 surface treatment + Box( + modifier = Modifier + .fillMaxWidth() + .height(300.dp), // ~40-45% of typical screen height for prominence + contentAlignment = Alignment.Center + ) { + // Background pulse rings for enhanced visual feedback + repeat(3) { index -> + val delay = index * 200 + val animatedAlpha by animateFloatAsState( + targetValue = if (isRecordPaused) 0f else (currentAmplitude * 0.3f), + animationSpec = tween( + durationMillis = 800 + delay, + easing = FastOutSlowInEasing + ), + label = "pulseRing${index}" + ) + + val animatedScale by animateFloatAsState( + targetValue = if (isRecordPaused) 1f else (1f + currentAmplitude * 0.2f + index * 0.1f), + animationSpec = tween( + durationMillis = 1000 + delay, + easing = FastOutSlowInEasing + ), + label = "pulseScale${index}" + ) + + Box( + modifier = Modifier + .size(280.dp + (index * 20).dp) + .graphicsLayer { + scaleX = animatedScale + scaleY = animatedScale + alpha = animatedAlpha + } + .border( + width = 2.dp, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f), + shape = CircleShape + ) + ) + } + + // Material 3 surface container for the main visualization + androidx.compose.material3.Surface( + modifier = Modifier.size(280.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceContainer, + shadowElevation = if (isRecordPaused) 2.dp else 8.dp, + tonalElevation = if (isRecordPaused) 1.dp else 4.dp + ) { + // Main AudioReactiveLottie visualization - now larger and more prominent + AudioReactiveLottie( + amplitude = if (isRecordPaused) 0f else currentAmplitude, + isRecording = !isRecordPaused, + modifier = Modifier + .fillMaxSize() + .padding(8.dp) // Slight padding within the surface + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + // Enhanced timer display with Material 3 typography + androidx.compose.material3.Surface( + modifier = Modifier.wrapContentHeight(), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + tonalElevation = 2.dp + ) { + Text( + text = counterTimeString, + style = MaterialTheme.typography.displayMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp) + ) + } + + // Flexible spacer to push controls to bottom + Spacer(modifier = Modifier.weight(1f)) + + // Control buttons area with proper accessibility + if (showControls) { + EnhancedRecordingControls( + isRecordPaused = isRecordPaused, + onPauseRecording = onPauseRecording, + onResumeRecording = onResumeRecording, + onStopRecording = onStopRecording + ) + } else { + // Quick record mode - only stop button + QuickRecordStopControl( + onStopRecording = onStopRecording + ) + } + + Spacer(modifier = Modifier.height(48.dp)) } + } +} - // Central recording interface - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxSize() +/** + * Enhanced recording controls with Material 3 design and proper accessibility. + */ +@Composable +private fun EnhancedRecordingControls( + isRecordPaused: Boolean, + onPauseRecording: () -> Unit, + onResumeRecording: () -> Unit, + onStopRecording: () -> Unit +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(24.dp), + verticalAlignment = Alignment.CenterVertically ) { - // Recording status indicator - Text( - text = "Recording...", - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.SemiBold - ) - - Spacer(modifier = Modifier.height(24.dp)) - - // Audio visualization - reuse the existing AudioReactiveLottie - AudioReactiveLottie( - amplitude = currentAmplitude, - isRecording = true, - modifier = Modifier.size(AppConstants.UI.MEDIUM_RECORDING_ANIMATION_DP.dp) - ) - - Spacer(modifier = Modifier.height(24.dp)) + // Pause/Resume button with Material 3 styling + androidx.compose.material3.Surface( + modifier = Modifier + .size(64.dp) // Minimum 48dp touch target with padding + .clickable { + if (isRecordPaused) { + onResumeRecording() + } else { + onPauseRecording() + } + }, + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceVariant, + tonalElevation = 2.dp, + shadowElevation = 4.dp + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + Icon( + imageVector = if (!isRecordPaused) Images.Icons.IcPause else Icons.Filled.PlayArrow, + contentDescription = if (!isRecordPaused) "Pause recording" else "Resume recording", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(28.dp) + ) + } + } - // Recording timer - Text( - text = counterTimeString, - style = MaterialTheme.typography.headlineMedium, - color = LocalCustomColors.current.bodyContentColor, - fontWeight = FontWeight.Medium - ) + // Stop button with enhanced Material 3 error styling + androidx.compose.material3.Surface( + modifier = Modifier + .size(80.dp) // Larger for primary action + .clickable { onStopRecording() }, + shape = CircleShape, + color = MaterialTheme.colorScheme.error, + tonalElevation = 3.dp, + shadowElevation = 6.dp + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + Box( + modifier = Modifier + .size(28.dp) + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.onError) + ) + } + } + } - Spacer(modifier = Modifier.height(48.dp)) + // Action label with Material 3 typography + Text( + text = stringResource(Res.string.recording_ui_tap_stop_record), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Medium + ) + } +} - // Large stop button +/** + * Quick record stop control with Material 3 design. + */ +@Composable +private fun QuickRecordStopControl( + onStopRecording: () -> Unit +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Large stop button for quick record + androidx.compose.material3.Surface( + modifier = Modifier + .size(88.dp) // Large touch target for quick access + .clickable { onStopRecording() }, + shape = CircleShape, + color = MaterialTheme.colorScheme.error, + tonalElevation = 4.dp, + shadowElevation = 8.dp + ) { Box( - modifier = Modifier - .size(AppConstants.UI.LARGE_BUTTON_SIZE_DP.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.error) - .clickable { onStopRecording() }, - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() ) { Box( modifier = Modifier - .size(AppConstants.UI.SMALL_ICON_SIZE_DP.dp) - .clip(RoundedCornerShape(4.dp)) + .size(32.dp) + .clip(RoundedCornerShape(8.dp)) .background(MaterialTheme.colorScheme.onError) ) } - - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = stringResource(Res.string.recording_ui_tap_stop_record), - style = MaterialTheme.typography.bodyLarge, - color = LocalCustomColors.current.bodyContentColor.copy(alpha = 0.7f) - ) } + + // Action label + Text( + text = stringResource(Res.string.recording_ui_tap_stop_record), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + fontWeight = FontWeight.Medium + ) } } diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/core/security/AiSettingsRepository.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/core/security/AiSettingsRepository.kt new file mode 100644 index 00000000..67f6f25b --- /dev/null +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/core/security/AiSettingsRepository.kt @@ -0,0 +1,194 @@ +package com.module.notelycompose.core.security + +import kotlinx.coroutines.flow.Flow + +/** + * Repository for managing AI-related settings including secure API key storage. + * Combines regular preferences with secure encrypted storage. + */ +class AiSettingsRepository( + private val securePreferencesRepository: SecurePreferencesRepository, + private val securityHelper: SecurityHelper +) { + + /** + * Stores the OpenAI API key securely after validation. + * + * @param apiKey The API key to store + * @param userContext Optional user context for security monitoring + * @throws SecureStorageException if storage fails + * @throws IllegalArgumentException if API key is invalid + */ + suspend fun storeOpenAiApiKey( + apiKey: String, + userContext: SecurityMonitoringService.UserContext? = null + ) { + // Validate API key format + val validation = securityHelper.validateOpenAiApiKey(apiKey, userContext) + if (!validation.isValid) { + throw IllegalArgumentException(validation.errorMessage ?: "Invalid API key") + } + + // Store securely + securePreferencesRepository.storeEncryptedApiKey( + SecurePreferencesRepository.OPENAI_API_KEY, + apiKey + ) + + securityHelper.reportSecurityEvent( + type = SecurityMonitoringService.SecurityEventType.ENCRYPTION_SUCCESS, + severity = SecurityMonitoringService.SecuritySeverity.LOW, + message = "OpenAI API key stored successfully", + details = mapOf( + "operation" to "store_api_key", + "key_preview" to securityHelper.sanitizeApiKeyForLogging(apiKey) + ), + userContext = userContext + ) + } + + /** + * Retrieves the OpenAI API key if present. + * + * @param userContext Optional user context for security monitoring + * @return The API key or null if not stored + * @throws SecureStorageException if decryption fails + */ + suspend fun getOpenAiApiKey( + userContext: SecurityMonitoringService.UserContext? = null + ): String? { + return try { + val apiKey = securePreferencesRepository.getDecryptedApiKey( + SecurePreferencesRepository.OPENAI_API_KEY + ) + + if (apiKey != null) { + securityHelper.reportSecurityEvent( + type = SecurityMonitoringService.SecurityEventType.DECRYPTION_SUCCESS, + severity = SecurityMonitoringService.SecuritySeverity.LOW, + message = "OpenAI API key retrieved successfully", + details = mapOf( + "operation" to "retrieve_api_key", + "key_preview" to securityHelper.sanitizeApiKeyForLogging(apiKey) + ), + userContext = userContext + ) + } + + apiKey + } catch (e: SecureStorageException) { + securityHelper.reportSecurityEvent( + type = SecurityMonitoringService.SecurityEventType.DECRYPTION_FAILURE, + severity = SecurityMonitoringService.SecuritySeverity.HIGH, + message = "Failed to retrieve OpenAI API key", + details = mapOf( + "operation" to "retrieve_api_key", + "error" to (e.message ?: "Unknown error") + ), + userContext = userContext, + throwable = e + ) + throw e + } + } + + /** + * Removes the stored OpenAI API key. + * + * @param userContext Optional user context for security monitoring + */ + suspend fun removeOpenAiApiKey( + userContext: SecurityMonitoringService.UserContext? = null + ) { + securePreferencesRepository.removeApiKey(SecurePreferencesRepository.OPENAI_API_KEY) + + securityHelper.reportSecurityEvent( + type = SecurityMonitoringService.SecurityEventType.DATA_DELETION, + severity = SecurityMonitoringService.SecuritySeverity.LOW, + message = "OpenAI API key removed", + details = mapOf( + "operation" to "remove_api_key" + ), + userContext = userContext + ) + } + + /** + * Checks if an OpenAI API key is stored. + * + * @return true if API key exists, false otherwise + */ + suspend fun hasOpenAiApiKey(): Boolean { + return securePreferencesRepository.hasApiKey(SecurePreferencesRepository.OPENAI_API_KEY) + } + + /** + * Observes the presence of an OpenAI API key. + * Useful for reactive UI updates. + * + * @return Flow indicating API key presence + */ + fun observeOpenAiApiKeyPresence(): Flow { + return securePreferencesRepository.observeApiKeyPresence( + SecurePreferencesRepository.OPENAI_API_KEY + ) + } + + /** + * Validates an API key without storing it. + * Useful for real-time validation in UI. + * + * @param apiKey The API key to validate + * @param userContext Optional user context for security monitoring + * @return ApiKeyValidationResult with validation status + */ + suspend fun validateApiKey( + apiKey: String?, + userContext: SecurityMonitoringService.UserContext? = null + ): ApiKeyValidationResult { + return securityHelper.validateOpenAiApiKey(apiKey, userContext) + } + + /** + * Clears all AI-related secure data. + * This is a destructive operation. + * + * @param userContext Optional user context for security monitoring + */ + suspend fun clearAllAiData( + userContext: SecurityMonitoringService.UserContext? = null + ) { + securePreferencesRepository.clearAll() + + securityHelper.reportSecurityEvent( + type = SecurityMonitoringService.SecurityEventType.DATA_DELETION, + severity = SecurityMonitoringService.SecuritySeverity.MEDIUM, + message = "All AI data cleared", + details = mapOf( + "operation" to "clear_all_ai_data" + ), + userContext = userContext + ) + } +} + +/** + * Data class representing complete AI settings state. + */ +data class AiSettingsState( + val hasApiKey: Boolean = false, + val apiKeyValid: Boolean = false, + val lastValidated: Long? = null +) + +/** + * UI state for AI settings screen. + */ +data class AiSettingsUiState( + val hasApiKey: Boolean = false, + val isValidating: Boolean = false, + val validationError: String? = null, + val isSaving: Boolean = false, + val saveError: String? = null, + val showApiKey: Boolean = false +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/core/security/SecurePreferencesRepository.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/core/security/SecurePreferencesRepository.kt new file mode 100644 index 00000000..89a1d68a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/core/security/SecurePreferencesRepository.kt @@ -0,0 +1,79 @@ +package com.module.notelycompose.core.security + +import kotlinx.coroutines.flow.Flow + +/** + * Repository interface for secure storage of sensitive data like API keys. + * Uses platform-specific encrypted storage implementations. + */ +interface SecurePreferencesRepository { + + /** + * Stores an encrypted API key securely. + * + * @param key The preference key identifier + * @param apiKey The API key to encrypt and store + * @throws SecurityException if encryption fails + */ + suspend fun storeEncryptedApiKey(key: String, apiKey: String) + + /** + * Retrieves and decrypts an API key. + * + * @param key The preference key identifier + * @return The decrypted API key or null if not found + * @throws SecurityException if decryption fails + */ + suspend fun getDecryptedApiKey(key: String): String? + + /** + * Removes an encrypted API key from storage. + * + * @param key The preference key identifier + */ + suspend fun removeApiKey(key: String) + + /** + * Checks if an API key exists in storage. + * + * @param key The preference key identifier + * @return true if the key exists, false otherwise + */ + suspend fun hasApiKey(key: String): Boolean + + /** + * Flow that emits true when an API key is present, false otherwise. + * This is useful for reactive UI updates. + * + * @param key The preference key identifier + * @return Flow indicating presence of the API key + */ + fun observeApiKeyPresence(key: String): Flow + + /** + * Clears all encrypted preferences. + * This is a destructive operation that cannot be undone. + */ + suspend fun clearAll() + + companion object { + const val OPENAI_API_KEY = "openai_api_key" + const val AI_FEATURES_ENABLED = "ai_features_enabled" + } +} + +/** + * Result class for API key validation operations. + */ +data class ApiKeyValidationResult( + val isValid: Boolean, + val errorMessage: String? = null +) + +/** + * Exception thrown when secure storage operations fail. + */ +class SecureStorageException( + message: String, + cause: Throwable? = null +) : Exception(message, cause) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/core/security/SecurityHelper.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/core/security/SecurityHelper.kt index 04a2b2c6..f98a8692 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/core/security/SecurityHelper.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/core/security/SecurityHelper.kt @@ -271,6 +271,146 @@ class SecurityHelper( ) } + /** + * Validates an OpenAI API key format and structure. + * + * @param apiKey The API key to validate + * @param userContext Optional user context for security monitoring + * @return ApiKeyValidationResult with validation status and error message + */ + suspend fun validateOpenAiApiKey( + apiKey: String?, + userContext: SecurityMonitoringService.UserContext? = null + ): ApiKeyValidationResult { + if (apiKey.isNullOrBlank()) { + return ApiKeyValidationResult( + isValid = false, + errorMessage = "API key cannot be empty" + ) + } + + // Basic format validation + val validationErrors = mutableListOf() + + when { + apiKey.length < 10 -> validationErrors.add("API key too short") + apiKey.length > 200 -> validationErrors.add("API key too long") + !apiKey.startsWith("sk-") -> validationErrors.add("OpenAI API keys must start with 'sk-'") + !apiKey.matches(Regex("^sk-[a-zA-Z0-9\\-_]+$")) -> { + validationErrors.add("API key contains invalid characters") + } + } + + if (validationErrors.isNotEmpty()) { + securityMonitoringService.reportValidationFailure( + validationType = "openai_api_key", + input = "sk-***${apiKey.takeLast(4)}", // Sanitized for logging + validationError = validationErrors.joinToString(", "), + userContext = userContext + ) + + return ApiKeyValidationResult( + isValid = false, + errorMessage = validationErrors.first() + ) + } + + securityMonitoringService.reportSecurityEvent( + type = SecurityMonitoringService.SecurityEventType.VALIDATION_SUCCESS, + severity = SecurityMonitoringService.SecuritySeverity.LOW, + message = "OpenAI API key validation successful", + details = mapOf( + "validation_type" to "openai_api_key", + "key_preview" to "sk-***${apiKey.takeLast(4)}" + ), + userContext = userContext + ) + + return ApiKeyValidationResult(isValid = true) + } + + /** + * Sanitizes an API key for safe logging (shows only prefix and last 4 characters). + * + * @param apiKey The API key to sanitize + * @return Sanitized version safe for logging + */ + fun sanitizeApiKeyForLogging(apiKey: String?): String { + if (apiKey.isNullOrBlank()) return "empty" + if (apiKey.length <= 8) return "***" + + val prefix = apiKey.take(3) + val suffix = apiKey.takeLast(4) + return "$prefix***$suffix" + } + + /** + * Validates general AI configuration settings. + * + * @param settings Map of setting keys to values + * @param userContext Optional user context for security monitoring + * @return True if all settings are valid, false otherwise + */ + suspend fun validateAiSettings( + settings: Map, + userContext: SecurityMonitoringService.UserContext? = null + ): Boolean { + try { + for ((key, value) in settings) { + when (key) { + "ai_features_enabled" -> { + if (value !is Boolean) { + securityMonitoringService.reportValidationFailure( + validationType = "ai_settings", + input = "$key: $value", + validationError = "AI features enabled must be boolean", + userContext = userContext + ) + return false + } + } + "openai_api_key" -> { + if (value is String && value.isNotEmpty()) { + val validation = validateOpenAiApiKey(value, userContext) + if (!validation.isValid) { + return false + } + } + } + else -> { + // Log unknown setting but don't fail validation + securityMonitoringService.reportSecurityEvent( + type = SecurityMonitoringService.SecurityEventType.VALIDATION_WARNING, + severity = SecurityMonitoringService.SecuritySeverity.LOW, + message = "Unknown AI setting", + details = mapOf( + "setting_key" to key, + "value_type" to (value?.javaClass?.simpleName ?: "null") + ), + userContext = userContext + ) + } + } + } + + return true + + } catch (e: Exception) { + securityMonitoringService.reportSecurityEvent( + type = SecurityMonitoringService.SecurityEventType.VALIDATION_FAILURE, + severity = SecurityMonitoringService.SecuritySeverity.MEDIUM, + message = "AI settings validation failed", + details = mapOf( + "error" to (e.message ?: "Unknown error"), + "settings_count" to settings.size.toString() + ), + userContext = userContext, + throwable = e + ) + return false + } + } + /** * Creates a user context from available session information. * Helper method to create consistent user context objects. diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/di/Modules.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/di/Modules.kt index 0cd517d3..eb83952e 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/di/Modules.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/di/Modules.kt @@ -50,6 +50,14 @@ import com.module.notelycompose.transcription.domain.WhisperModelManager import com.module.notelycompose.transcription.domain.WhisperModelLoader import com.module.notelycompose.core.security.SecurityHelper import com.module.notelycompose.core.security.SecurityMonitoringService +import com.module.notelycompose.openai.data.repository.OpenAIRepositoryImpl +import com.module.notelycompose.openai.domain.repository.OpenAIRepository +import com.module.notelycompose.openai.domain.usecase.SummarizeTextUseCase +import com.module.notelycompose.openai.domain.usecase.TranscribeAudioUseCase +import com.module.notelycompose.summary.TFIDFSummarizer +import com.module.notelycompose.core.security.AiSettingsRepository +import com.module.notelycompose.core.security.SecurePreferencesRepository +import com.module.notelycompose.notes.presentation.settings.AISettingsViewModel import org.koin.core.module.Module import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.viewModelOf @@ -95,6 +103,16 @@ val repositoryModule = module { single { SearchSuggestionProvider(get(), get()) } single { TextContentPredictor(get()) } single { LanguageAwareAutoComplete(get()) } + single { AiSettingsRepository(get(), get()) } + + // OpenAI Integration + single { + OpenAIRepositoryImpl( + networkConnectivityManager = get(), + securityHelper = get() + ) + } + single { TFIDFSummarizer() } } val viewModelModule = module { @@ -109,6 +127,7 @@ val viewModelModule = module { viewModelOf(::AudioPlayerViewModel) viewModelOf(::AudioImportViewModel) viewModelOf(::LanguageSelectionViewModel) + viewModelOf(::AISettingsViewModel) } val useCaseModule = module { @@ -121,6 +140,10 @@ val useCaseModule = module { factory { UpdateNoteUseCase(get(), get(), get()) } factory { ModelAvailabilityService(get(), get()) } factory { BackgroundTranscriptionService(get(), get()) } + + // OpenAI Use Cases + factory { TranscribeAudioUseCase(get(), get(), get()) } + factory { SummarizeTextUseCase(get(), get(), get()) } } val securityModule = module { diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/presentation/detail/TextEditorViewModel.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/presentation/detail/TextEditorViewModel.kt index b6582791..ae5f7ec9 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/presentation/detail/TextEditorViewModel.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/presentation/detail/TextEditorViewModel.kt @@ -182,10 +182,21 @@ class TextEditorViewModel( val currentState = _editorPresentationState.value // Only save if content actually changed - if (oldContent != currentState.content.text) { + if (oldContent != currentState.content.text || _lastRichTextHtmlContent.isNotEmpty()) { + // Use HTML content for persistence when available, fallback to plain text + val contentToSave = if (_lastRichTextHtmlContent.isNotEmpty()) { + _lastRichTextHtmlContent + } else { + currentState.content.text + } + + // Use first line or first 50 chars as title + val titleToSave = currentState.content.text.lines().firstOrNull()?.take(50) + ?: currentState.content.text.take(50) + debouncedSave( - title = currentState.content.text, - content = currentState.content.text, + title = titleToSave, + content = contentToSave, // Save HTML content for rich formatting starred = currentState.starred, formatting = currentState.formats, textAlign = currentState.textAlign, @@ -271,9 +282,21 @@ class TextEditorViewModel( createdAt: String ) { val recordingModel = recordingPath(recordingPath) + + // Determine if content is HTML (simple heuristic check) + val isHtmlContent = content.contains("<") && content.contains(">") + + // For display in the UI, extract plain text if HTML content + val displayContent = if (isHtmlContent) { + // Extract plain text from HTML for backward compatibility + content.replace(Regex("<[^>]+>"), "").trim() + } else { + content + } + _editorPresentationState.update { it.copy( - content = TextFieldValue(content), + content = TextFieldValue(displayContent), formats = formats, textAlign = textAlign, recording = recordingModel, @@ -282,8 +305,13 @@ class TextEditorViewModel( ) } - // Synchronize content to rich text state + // Synchronize content to rich text state - use original content which may be HTML syncContentToRichText(content) + + // Store the original content for HTML persistence + if (isHtmlContent) { + _lastRichTextHtmlContent = content + } } /** @@ -357,17 +385,27 @@ class TextEditorViewModel( /** * Synchronizes content from RichTextState back to TextFieldValue. * This is used when the rich text editor content changes. + * Enhanced to save both HTML and plain text content properly. */ private fun syncContentFromRichText() { - val richTextContent = richTextEditorHelper.getPlainText() + val richTextHtmlContent = richTextEditorHelper.getContent() // Get HTML for persistence + val richTextPlainContent = richTextEditorHelper.getPlainText() // Get plain text for display val currentState = _editorPresentationState.value - if (currentState.content.text != richTextContent) { + // Update the presentation state with plain text for backward compatibility + if (currentState.content.text != richTextPlainContent) { _editorPresentationState.update { - it.copy(content = TextFieldValue(richTextContent)) + it.copy(content = TextFieldValue(richTextPlainContent)) } } + + // Store the HTML content separately for rich text persistence + // This ensures formatting is preserved when the note is saved and loaded + _lastRichTextHtmlContent = richTextHtmlContent } + + // Track the last HTML content for proper persistence + private var _lastRichTextHtmlContent: String = "" fun onGetUiState(presentationState: EditorPresentationState): EditorUiState { return editorPresentationToUiStateMapper.mapToUiState(presentationState) diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/presentation/settings/AISettingsViewModel.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/presentation/settings/AISettingsViewModel.kt new file mode 100644 index 00000000..999673d9 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/presentation/settings/AISettingsViewModel.kt @@ -0,0 +1,313 @@ +package com.module.notelycompose.notes.presentation.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.module.notelycompose.core.security.AiSettingsRepository +import com.module.notelycompose.core.security.AiSettingsUiState +import com.module.notelycompose.core.security.SecurityHelper +import com.module.notelycompose.core.security.SecurityMonitoringService +import com.module.notelycompose.core.security.SecureStorageException +import io.github.aakira.napier.Napier +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import kotlinx.coroutines.delay +import java.util.UUID + +/** + * ViewModel for AI Settings screen handling secure API key management. + * Follows the established pattern of intent-based user actions and reactive state. + */ +class AISettingsViewModel( + private val aiSettingsRepository: AiSettingsRepository, + private val securityHelper: SecurityHelper +) : ViewModel() { + + private val _uiState = MutableStateFlow(AiSettingsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _currentApiKey = MutableStateFlow("") + val currentApiKey: StateFlow = _currentApiKey.asStateFlow() + + private val sessionContext = securityHelper.createUserContext( + sessionId = UUID.randomUUID().toString(), + deviceFingerprint = "android_device" + ) + + init { + loadInitialState() + observeApiKeyPresence() + Napier.d("AISettingsViewModel initialized") + } + + /** + * Processes user intents for AI settings management. + */ + fun onProcessIntent(intent: AISettingsIntent) { + when (intent) { + is AISettingsIntent.LoadSettings -> loadInitialState() + is AISettingsIntent.UpdateApiKey -> updateApiKey(intent.apiKey) + is AISettingsIntent.SaveApiKey -> saveApiKey() + is AISettingsIntent.RemoveApiKey -> removeApiKey() + is AISettingsIntent.ToggleApiKeyVisibility -> toggleApiKeyVisibility() + is AISettingsIntent.ValidateApiKey -> validateCurrentApiKey() + is AISettingsIntent.ClearErrors -> clearErrors() + } + } + + private fun loadInitialState() { + viewModelScope.launch { + try { + _uiState.value = _uiState.value.copy(isSaving = true) + + val hasApiKey = aiSettingsRepository.hasOpenAiApiKey() + val currentKey = if (hasApiKey) { + aiSettingsRepository.getOpenAiApiKey(sessionContext) ?: "" + } else { + "" + } + + _currentApiKey.value = currentKey + _uiState.value = _uiState.value.copy( + hasApiKey = hasApiKey, + isSaving = false, + saveError = null, + validationError = null + ) + + Napier.d("Initial AI settings state loaded - hasApiKey: $hasApiKey") + + } catch (e: Exception) { + handleError("Failed to load AI settings", e) + } + } + } + + private fun observeApiKeyPresence() { + viewModelScope.launch { + aiSettingsRepository.observeOpenAiApiKeyPresence() + .combine(_uiState) { hasKey, currentState -> + currentState.copy(hasApiKey = hasKey) + } + .collect { newState -> + _uiState.value = newState + } + } + } + + private fun updateApiKey(apiKey: String) { + _currentApiKey.value = apiKey + + // Clear previous validation errors when user starts typing + if (_uiState.value.validationError != null) { + _uiState.value = _uiState.value.copy(validationError = null) + } + + // Debounced validation for better UX + viewModelScope.launch { + delay(500) // Wait for user to stop typing + if (_currentApiKey.value == apiKey && apiKey.isNotEmpty()) { + validateApiKeyAsync(apiKey, showErrors = false) + } + } + } + + private fun saveApiKey() { + val apiKey = _currentApiKey.value.trim() + + if (apiKey.isEmpty()) { + _uiState.value = _uiState.value.copy( + validationError = "API key cannot be empty" + ) + return + } + + viewModelScope.launch { + try { + _uiState.value = _uiState.value.copy( + isSaving = true, + saveError = null, + validationError = null + ) + + // Validate before saving + val validation = aiSettingsRepository.validateApiKey(apiKey, sessionContext) + if (!validation.isValid) { + _uiState.value = _uiState.value.copy( + isSaving = false, + validationError = validation.errorMessage + ) + return@launch + } + + // Save the API key + aiSettingsRepository.storeOpenAiApiKey(apiKey, sessionContext) + + _uiState.value = _uiState.value.copy( + isSaving = false, + hasApiKey = true, + saveError = null, + validationError = null + ) + + securityHelper.reportSecurityEvent( + type = SecurityMonitoringService.SecurityEventType.USER_ACTION, + severity = SecurityMonitoringService.SecuritySeverity.LOW, + message = "User saved AI API key", + details = mapOf( + "action" to "save_api_key", + "success" to "true" + ), + userContext = sessionContext + ) + + Napier.i("AI API key saved successfully") + + } catch (e: IllegalArgumentException) { + _uiState.value = _uiState.value.copy( + isSaving = false, + validationError = e.message + ) + Napier.w("API key validation failed: ${e.message}") + + } catch (e: SecureStorageException) { + handleError("Failed to save API key securely", e) + + } catch (e: Exception) { + handleError("Unexpected error while saving API key", e) + } + } + } + + private fun removeApiKey() { + viewModelScope.launch { + try { + _uiState.value = _uiState.value.copy(isSaving = true) + + aiSettingsRepository.removeOpenAiApiKey(sessionContext) + _currentApiKey.value = "" + + _uiState.value = _uiState.value.copy( + isSaving = false, + hasApiKey = false, + saveError = null, + validationError = null, + showApiKey = false + ) + + securityHelper.reportSecurityEvent( + type = SecurityMonitoringService.SecurityEventType.USER_ACTION, + severity = SecurityMonitoringService.SecuritySeverity.LOW, + message = "User removed AI API key", + details = mapOf( + "action" to "remove_api_key", + "success" to "true" + ), + userContext = sessionContext + ) + + Napier.i("AI API key removed successfully") + + } catch (e: Exception) { + handleError("Failed to remove API key", e) + } + } + } + + private fun toggleApiKeyVisibility() { + _uiState.value = _uiState.value.copy( + showApiKey = !_uiState.value.showApiKey + ) + + securityHelper.reportSecurityEvent( + type = SecurityMonitoringService.SecurityEventType.USER_ACTION, + severity = SecurityMonitoringService.SecuritySeverity.LOW, + message = "User toggled API key visibility", + details = mapOf( + "action" to "toggle_visibility", + "visible" to _uiState.value.showApiKey.toString() + ), + userContext = sessionContext + ) + } + + private fun validateCurrentApiKey() { + val apiKey = _currentApiKey.value.trim() + if (apiKey.isEmpty()) return + + viewModelScope.launch { + validateApiKeyAsync(apiKey, showErrors = true) + } + } + + private suspend fun validateApiKeyAsync(apiKey: String, showErrors: Boolean) { + try { + _uiState.value = _uiState.value.copy(isValidating = true) + + val validation = aiSettingsRepository.validateApiKey(apiKey, sessionContext) + + _uiState.value = _uiState.value.copy( + isValidating = false, + validationError = if (showErrors && !validation.isValid) { + validation.errorMessage + } else null + ) + + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + isValidating = false, + validationError = if (showErrors) "Validation failed" else null + ) + Napier.w("API key validation error: ${e.message}") + } + } + + private fun clearErrors() { + _uiState.value = _uiState.value.copy( + validationError = null, + saveError = null + ) + } + + private fun handleError(message: String, exception: Exception) { + _uiState.value = _uiState.value.copy( + isSaving = false, + isValidating = false, + saveError = message + ) + + securityHelper.reportSecurityEvent( + type = SecurityMonitoringService.SecurityEventType.APPLICATION_ERROR, + severity = SecurityMonitoringService.SecuritySeverity.MEDIUM, + message = message, + details = mapOf( + "error" to (exception.message ?: "Unknown error"), + "error_type" to exception.javaClass.simpleName + ), + userContext = sessionContext, + throwable = exception + ) + + Napier.e("AI Settings error: $message", exception) + } + + override fun onCleared() { + super.onCleared() + Napier.d("AISettingsViewModel cleared") + } +} + +/** + * Sealed interface representing user intents for AI settings. + */ +sealed interface AISettingsIntent { + data object LoadSettings : AISettingsIntent + data class UpdateApiKey(val apiKey: String) : AISettingsIntent + data object SaveApiKey : AISettingsIntent + data object RemoveApiKey : AISettingsIntent + data object ToggleApiKeyVisibility : AISettingsIntent + data object ValidateApiKey : AISettingsIntent + data object ClearErrors : AISettingsIntent +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/ui/richtext/AnimatedBottomToolbar.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/ui/richtext/AnimatedBottomToolbar.kt new file mode 100644 index 00000000..c02679e5 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/ui/richtext/AnimatedBottomToolbar.kt @@ -0,0 +1,113 @@ +package com.module.notelycompose.notes.ui.richtext + +import androidx.compose.animation.* +import androidx.compose.animation.core.* +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * Animated bottom toolbar container with smooth show/hide animations. + * + * Features: + * - Smooth slide up/down animations + * - Proper keyboard positioning + * - Performance-optimized transitions + * + * @param visible Whether the toolbar should be visible + * @param modifier Modifier for the container + * @param content The toolbar content to display + */ +@Composable +fun AnimatedBottomToolbar( + visible: Boolean, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + AnimatedVisibility( + visible = visible, + modifier = modifier, + enter = slideInVertically( + initialOffsetY = { fullHeight -> fullHeight }, + animationSpec = tween( + durationMillis = 300, + easing = FastOutSlowInEasing + ) + ) + fadeIn( + animationSpec = tween( + durationMillis = 200, + delayMillis = 100 + ) + ), + exit = slideOutVertically( + targetOffsetY = { fullHeight -> fullHeight }, + animationSpec = tween( + durationMillis = 250, + easing = FastOutLinearInEasing + ) + ) + fadeOut( + animationSpec = tween( + durationMillis = 150 + ) + ) + ) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.BottomCenter + ) { + content() + } + } +} + +/** + * Animated toolbar container with customizable positioning. + * + * @param visible Whether the toolbar should be visible + * @param alignment How to align the toolbar within its container + * @param modifier Modifier for the container + * @param content The toolbar content to display + */ +@Composable +fun AnimatedToolbarContainer( + visible: Boolean, + alignment: Alignment = Alignment.BottomCenter, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + AnimatedVisibility( + visible = visible, + modifier = modifier, + enter = scaleIn( + initialScale = 0.8f, + animationSpec = tween( + durationMillis = 200, + easing = FastOutSlowInEasing + ) + ) + fadeIn( + animationSpec = tween( + durationMillis = 150 + ) + ), + exit = scaleOut( + targetScale = 0.8f, + animationSpec = tween( + durationMillis = 150, + easing = FastOutLinearInEasing + ) + ) + fadeOut( + animationSpec = tween( + durationMillis = 100 + ) + ) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = alignment + ) { + content() + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/ui/richtext/RichTextEditorTestHelper.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/ui/richtext/RichTextEditorTestHelper.kt new file mode 100644 index 00000000..30ce5590 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/ui/richtext/RichTextEditorTestHelper.kt @@ -0,0 +1,254 @@ +package com.module.notelycompose.notes.ui.richtext + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import com.module.notelycompose.notes.presentation.helpers.RichTextEditorHelper + +/** + * Test helper for verifying rich text editor functionality during development. + * This helps ensure all formatting operations work correctly and are properly connected. + */ +class RichTextEditorTestHelper { + + companion object { + /** + * Performs a comprehensive test of all rich text formatting features. + * Call this during development to verify everything is working. + * + * @param richTextEditorHelper The helper instance to test + * @return TestResult with success status and any issues found + */ + fun performComprehensiveTest(richTextEditorHelper: RichTextEditorHelper): TestResult { + val issues = mutableListOf() + + try { + // Test 1: Basic content setting and retrieval + richTextEditorHelper.setContent("Test content") + val content = richTextEditorHelper.getContent() + if (content.isEmpty()) { + issues.add("Content setting/getting failed") + } + + // Test 2: Plain text extraction + val plainText = richTextEditorHelper.getPlainText() + if (plainText != "Test content") { + issues.add("Plain text extraction failed. Expected 'Test content', got '$plainText'") + } + + // Test 3: Basic formatting operations + richTextEditorHelper.toggleBold() + if (!richTextEditorHelper.isSelectionBold()) { + issues.add("Bold formatting not applied or not detected") + } + + richTextEditorHelper.toggleItalic() + if (!richTextEditorHelper.isSelectionItalic()) { + issues.add("Italic formatting not applied or not detected") + } + + richTextEditorHelper.toggleUnderline() + if (!richTextEditorHelper.isSelectionUnderlined()) { + issues.add("Underline formatting not applied or not detected") + } + + // Test 4: List operations + richTextEditorHelper.toggleUnorderedList() + if (!richTextEditorHelper.isUnorderedList()) { + issues.add("Unordered list not applied or not detected") + } + + richTextEditorHelper.toggleOrderedList() + if (!richTextEditorHelper.isOrderedList()) { + issues.add("Ordered list not applied or not detected") + } + + // Test 5: Heading operations + richTextEditorHelper.addHeading(1) + val headingLevel = richTextEditorHelper.getCurrentHeadingLevel() + if (headingLevel != 1) { + issues.add("Heading level 1 not applied. Current level: $headingLevel") + } + + richTextEditorHelper.addHeading(2) + val headingLevel2 = richTextEditorHelper.getCurrentHeadingLevel() + if (headingLevel2 != 2) { + issues.add("Heading level 2 not applied. Current level: $headingLevel2") + } + + // Test 6: Body text operation + richTextEditorHelper.setBodyText() + val bodyHeadingLevel = richTextEditorHelper.getCurrentHeadingLevel() + if (bodyHeadingLevel != null) { + issues.add("Body text not applied. Still has heading level: $bodyHeadingLevel") + } + + // Test 7: Text alignment + richTextEditorHelper.setAlignment(TextAlign.Center) + val alignment = richTextEditorHelper.getCurrentAlignment() + if (alignment != TextAlign.Center) { + issues.add("Center alignment not applied. Current: $alignment") + } + + richTextEditorHelper.setAlignment(TextAlign.End) + val rightAlignment = richTextEditorHelper.getCurrentAlignment() + if (rightAlignment != TextAlign.End) { + issues.add("Right alignment not applied. Current: $rightAlignment") + } + + // Reset to start alignment + richTextEditorHelper.setAlignment(TextAlign.Start) + + // Test 8: Advanced formatting + richTextEditorHelper.toggleStrikethrough() + if (!richTextEditorHelper.hasStrikethrough()) { + issues.add("Strikethrough formatting not applied or not detected") + } + + richTextEditorHelper.toggleCodeBlock() + if (!richTextEditorHelper.isCodeBlock()) { + issues.add("Code block formatting not applied or not detected") + } + + richTextEditorHelper.toggleQuoteBlock() + if (!richTextEditorHelper.isQuoteBlock()) { + issues.add("Quote block formatting not applied or not detected") + } + + // Test 9: Color operations + val testColor = Color.Red + richTextEditorHelper.setTextColor(testColor) + if (!richTextEditorHelper.hasTextColor()) { + issues.add("Text color not applied or not detected") + } + + val highlightColor = Color.Yellow + richTextEditorHelper.setHighlightColor(highlightColor) + if (!richTextEditorHelper.hasHighlight()) { + issues.add("Highlight color not applied or not detected") + } + + // Test 10: Indentation + val initialIndent = richTextEditorHelper.getIndentLevel() + richTextEditorHelper.increaseIndent() + val increasedIndent = richTextEditorHelper.getIndentLevel() + if (increasedIndent <= initialIndent) { + issues.add("Indent increase not working. Initial: $initialIndent, After: $increasedIndent") + } + + richTextEditorHelper.decreaseIndent() + val decreasedIndent = richTextEditorHelper.getIndentLevel() + if (decreasedIndent != initialIndent) { + issues.add("Indent decrease not working. Expected: $initialIndent, Got: $decreasedIndent") + } + + // Test 11: Clear formatting + richTextEditorHelper.clearFormatting() + if (richTextEditorHelper.isSelectionBold() || + richTextEditorHelper.isSelectionItalic() || + richTextEditorHelper.isSelectionUnderlined() || + richTextEditorHelper.hasTextColor() || + richTextEditorHelper.hasHighlight()) { + issues.add("Clear formatting did not remove all formatting") + } + + // Test 12: HTML sanitization (security test) + val maliciousContent = "

Safe content

" + richTextEditorHelper.setContent(maliciousContent) + val sanitizedContent = richTextEditorHelper.getContent() + if (sanitizedContent.contains("

Safe content

" + + helper.setContent(maliciousContent) + val resultContent = helper.getContent() + + // Should contain safe content but not script tags + assertFalse(resultContent.contains("", false), + TestCase("Long content", "a".repeat(10000), true) + ) { testCase -> + // Given + every { mockSecurityHelper.validateInput(testCase.input) } returns + ValidationResult(testCase.expectedValid, if (!testCase.expectedValid) "Invalid" else null) + + // When + val result = viewModel.validateInput(testCase.input) + + // Then + assertEquals(testCase.expectedValid, result.isValid) + } + + data class TestCase(val name: String, val input: String, val expectedValid: Boolean) +} +``` + +#### 6.2 Test Fixtures and Factories + +```kotlin +object TextEditorTestFixtures { + val standardNote = createTestNote( + id = 1L, + title = "Standard Note", + content = "This is a standard test note with regular content." + ) + + val emptyNote = createTestNote( + title = "", + content = "" + ) + + val richTextNote = createTestNote( + title = "Rich Text Note", + content = "

Heading

Bold text and italic text

" + ) + + val longContentNote = createTestNote( + title = "Long Content Note", + content = generateLongContent(5000) + ) + + private fun generateLongContent(length: Int): String { + return buildString { + repeat(length / 50) { + append("This is a long content note for testing purposes. ") + } + } + } +} +``` + +## Implementation Plan + +### Step 1: Infrastructure Setup (High Priority) +1. Add mockk and turbine dependencies to build.gradle.kts +2. Create base test classes (ViewModelTestBase, KoinTestBase) +3. Set up test-specific Koin module configuration + +### Step 2: Interface Creation (High Priority) +1. Create interfaces for all use cases currently being extended as final classes +2. Update existing implementations to implement these interfaces +3. Update Koin modules to use interface bindings + +### Step 3: Test Refactoring (High Priority) +1. Refactor TextEditorViewModelTest to use new architecture +2. Replace manual mocks with mockk-based mocks +3. Implement proper Flow testing with Turbine +4. Add comprehensive test scenarios + +### Step 4: Test Data & Utilities (Medium Priority) +1. Create TestDataBuilder and test fixtures +2. Implement custom matchers and assertions +3. Add parameterized test support + +### Step 5: Advanced Patterns (Medium Priority) +1. Implement platform-specific test strategies +2. Add integration test patterns +3. Create comprehensive test documentation + +## Benefits of This Approach + +### 1. Maintainability +- **Interface-based mocking**: Easy to mock and test +- **Consistent patterns**: Standardized test structure across the project +- **Separation of concerns**: Clear separation between test setup, execution, and assertions + +### 2. Scalability +- **Reusable components**: Base classes and utilities can be used across all tests +- **Platform support**: Proper support for KMP testing across Android, iOS, and common +- **Easy extension**: New tests can be added following established patterns + +### 3. Modern Best Practices +- **Mockk integration**: Modern, idiomatic Kotlin mocking +- **Flow testing**: Proper reactive testing with Turbine +- **Coroutine testing**: Proper async testing with TestScope and TestDispatchers + +### 4. Quality Assurance +- **Type safety**: Interface-based approach ensures compile-time safety +- **Test isolation**: Each test runs in isolation with fresh mocks +- **Comprehensive coverage**: Covers success, error, and edge cases + +## Migration Strategy + +### Phase 1: Quick Fixes (1-2 days) +- Add dependencies and create basic interfaces +- Get existing tests passing with minimal changes + +### Phase 2: Architecture Improvement (3-5 days) +- Implement comprehensive interface-based architecture +- Refactor all existing tests to use new patterns + +### Phase 3: Enhanced Testing (1-2 weeks) +- Add comprehensive test coverage +- Implement advanced testing patterns and utilities + +## Conclusion + +This refactoring strategy addresses all identified issues while establishing a robust, maintainable testing architecture that follows modern Kotlin Multiplatform best practices. The approach ensures long-term scalability and maintainability while providing immediate solutions to current test failures. \ No newline at end of file diff --git a/core/audio/src/commonTest/kotlin/AudioPathValidationTest.kt b/core/audio/src/commonTest/kotlin/AudioPathValidationTest.kt new file mode 100644 index 00000000..7c7cddfc --- /dev/null +++ b/core/audio/src/commonTest/kotlin/AudioPathValidationTest.kt @@ -0,0 +1,137 @@ +package core.audio + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AudioPathValidationTest { + + @Test + fun `validateAudioPath should accept valid audio file extensions`() { + // Given + val validPaths = listOf( + "/storage/audio/recording.wav", + "/data/local/voice_note.mp3", + "/tmp/audio_file.m4a", + "/recordings/session.aac", + "/voice/memo.flac", + "/audio/test.ogg" + ) + + // When & Then + validPaths.forEach { path -> + assertTrue( + isValidAudioPath(path), + "Expected path to be valid: $path" + ) + } + } + + @Test + fun `validateAudioPath should reject invalid file extensions`() { + // Given + val invalidPaths = listOf( + "/storage/document.txt", + "/data/image.jpg", + "/tmp/video.mp4", + "/files/archive.zip", + "/docs/readme.md", + "/config/settings.json" + ) + + // When & Then + invalidPaths.forEach { path -> + assertFalse( + isValidAudioPath(path), + "Expected path to be invalid: $path" + ) + } + } + + @Test + fun `validateAudioPath should handle edge cases`() { + // Given & When & Then + assertFalse(isValidAudioPath(""), "Empty string should be invalid") + assertFalse(isValidAudioPath(" "), "Whitespace should be invalid") + assertFalse(isValidAudioPath("/path/without/extension"), "Path without extension should be invalid") + assertFalse(isValidAudioPath("/path/.wav"), "Hidden file with audio extension should be invalid") + assertFalse(isValidAudioPath("/path/file."), "File with just dot should be invalid") + assertTrue(isValidAudioPath("/path/a.wav"), "Single character filename should be valid") + } + + @Test + fun `validateAudioPath should be case insensitive`() { + // Given + val paths = listOf( + "/audio/file.WAV", + "/audio/file.Mp3", + "/audio/file.M4A", + "/audio/file.AAC", + "/audio/file.FLAC", + "/audio/file.OGG" + ) + + // When & Then + paths.forEach { path -> + assertTrue( + isValidAudioPath(path), + "Expected case-insensitive path to be valid: $path" + ) + } + } + + @Test + fun `validateAudioPath should handle path traversal attempts`() { + // Given + val maliciousPaths = listOf( + "../../../etc/passwd.wav", + "/../../root/.ssh/id_rsa.mp3", + "../../../../system/config.m4a", + "/home/user/../../secrets.aac" + ) + + // When & Then + maliciousPaths.forEach { path -> + // Should still validate extension but flag for security review + val hasValidExtension = isValidAudioPath(path) + val isSuspicious = containsPathTraversal(path) + + assertTrue(isSuspicious, "Expected path traversal to be detected: $path") + // Extension validation should work independently of path traversal detection + } + } + + @Test + fun `validateAudioPath should handle long paths`() { + // Given + val longPath = "/storage/" + "a".repeat(200) + "/recording.wav" + val veryLongPath = "/storage/" + "a".repeat(1000) + "/recording.wav" + + // When & Then + assertTrue(isValidAudioPath(longPath), "Long valid path should be accepted") + // Very long paths might be rejected for security reasons + assertFalse(isValidAudioPath(veryLongPath), "Extremely long paths should be rejected") + } + + // Helper functions that would be part of the actual audio validation module + private fun isValidAudioPath(path: String): Boolean { + if (path.isBlank()) return false + if (path.length > 500) return false // Reasonable path length limit + + val validExtensions = setOf("wav", "mp3", "m4a", "aac", "flac", "ogg") + val extension = path.substringAfterLast('.', "").lowercase() + + // Check if it has a valid extension + if (!validExtensions.contains(extension)) return false + + // Check if filename is not just a dot or hidden file + val filename = path.substringAfterLast('/') + if (filename.startsWith('.') || filename == extension || filename.isEmpty()) return false + + return true + } + + private fun containsPathTraversal(path: String): Boolean { + return path.contains("../") || path.contains("..\\") + } +} \ No newline at end of file diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 35d02a0a..34a547ba 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -109,6 +109,18 @@ kotlin { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") implementation(libs.koin.test) implementation(libs.datastore.preferences) + + // Modern testing dependencies + implementation("io.mockk:mockk:1.13.8") + implementation("app.cash.turbine:turbine:1.0.0") + } + } + + val androidInstrumentedTest by getting { + dependencies { + implementation("io.mockk:mockk-android:1.13.8") + implementation("androidx.test.ext:junit:1.1.5") + implementation("androidx.test.espresso:espresso-core:3.5.1") } } @@ -117,7 +129,7 @@ kotlin { targets.all { compilations.all { compilerOptions.configure { - freeCompilerArgs.add("-Xexpect-actual-classes") + freeCompilerArgs.add("-Xexpected-actual-classes") } } } @@ -210,4 +222,4 @@ dependencies { implementation(libs.activity.ktx) implementation(libs.animation.android) implementation(libs.androidx.appcompat) -} +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/com/module/notelycompose/data/audio/AndroidAudioPlayer.kt b/shared/src/androidMain/kotlin/com/module/notelycompose/data/audio/AndroidAudioPlayer.kt new file mode 100644 index 00000000..0a7073de --- /dev/null +++ b/shared/src/androidMain/kotlin/com/module/notelycompose/data/audio/AndroidAudioPlayer.kt @@ -0,0 +1,127 @@ +package com.module.notelycompose.data.audio + +import android.content.Context +import android.media.MediaPlayer +import com.module.notelycompose.domain.audio.AudioPlaybackException +import com.module.notelycompose.domain.audio.PlatformAudioPlayer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File + +/** + * Android implementation of PlatformAudioPlayer using MediaPlayer. + * This implementation is designed to be non-final to allow for testing with inheritance + * or can be easily mocked through the interface. + */ +open class AndroidAudioPlayer(private val context: Context) : PlatformAudioPlayer { + + private var mediaPlayer: MediaPlayer? = null + private var isInitialized = false + + override suspend fun play(audioPath: String) = withContext(Dispatchers.IO) { + try { + release() // Clean up any existing player + + mediaPlayer = MediaPlayer().apply { + setDataSource(audioPath) + prepareAsync() + setOnPreparedListener { player -> + isInitialized = true + player.start() + } + setOnErrorListener { _, what, extra -> + throw AudioPlaybackException("MediaPlayer error: what=$what, extra=$extra") + } + setOnCompletionListener { + isInitialized = false + } + } + } catch (e: Exception) { + throw AudioPlaybackException("Failed to play audio: ${e.message}", e) + } + } + + override suspend fun pause() = withContext(Dispatchers.Main) { + try { + if (isInitialized && mediaPlayer?.isPlaying == true) { + mediaPlayer?.pause() + } + } catch (e: Exception) { + throw AudioPlaybackException("Failed to pause audio: ${e.message}", e) + } + } + + override suspend fun stop() = withContext(Dispatchers.Main) { + try { + if (isInitialized) { + mediaPlayer?.stop() + isInitialized = false + } + } catch (e: Exception) { + throw AudioPlaybackException("Failed to stop audio: ${e.message}", e) + } + } + + override suspend fun seekTo(position: Long) = withContext(Dispatchers.Main) { + try { + if (isInitialized) { + mediaPlayer?.seekTo(position.toInt()) + } + } catch (e: Exception) { + throw AudioPlaybackException("Failed to seek audio: ${e.message}", e) + } + } + + override fun release() { + try { + mediaPlayer?.release() + mediaPlayer = null + isInitialized = false + } catch (e: Exception) { + // Log error but don't throw since this is cleanup + } + } + + /** + * Gets current playback position in milliseconds. + * Returns 0 if player is not initialized. + */ + open fun getCurrentPosition(): Long { + return try { + if (isInitialized) { + mediaPlayer?.currentPosition?.toLong() ?: 0L + } else { + 0L + } + } catch (e: Exception) { + 0L + } + } + + /** + * Gets total duration in milliseconds. + * Returns 0 if player is not initialized. + */ + open fun getDuration(): Long { + return try { + if (isInitialized) { + mediaPlayer?.duration?.toLong() ?: 0L + } else { + 0L + } + } catch (e: Exception) { + 0L + } + } + + /** + * Checks if audio is currently playing. + */ + open fun isPlaying(): Boolean { + return try { + isInitialized && mediaPlayer?.isPlaying == true + } catch (e: Exception) { + false + } + } +} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/com/module/notelycompose/di/PlatformModule.kt b/shared/src/androidMain/kotlin/com/module/notelycompose/di/PlatformModule.kt new file mode 100644 index 00000000..a409ad60 --- /dev/null +++ b/shared/src/androidMain/kotlin/com/module/notelycompose/di/PlatformModule.kt @@ -0,0 +1,30 @@ +package com.module.notelycompose.di + +import android.content.Context +import com.module.notelycompose.data.audio.AndroidAudioPlayer +import com.module.notelycompose.domain.audio.PlatformAudioPlayer +import org.koin.android.ext.koin.androidContext +import org.koin.dsl.module + +/** + * Android-specific platform module providing Android implementations + * of platform-dependent interfaces. + */ +actual val platformModule = module { + + // Audio Player - Android implementation + single { + AndroidAudioPlayer(context = androidContext()) + } + + // Android Context (provided by Koin Android) + // androidContext() is automatically available when using Koin Android +} + +/** + * Extension function to get Android context in a type-safe way. + * This can be used in other parts of the Android-specific code. + */ +fun org.koin.core.scope.Scope.androidContext(): Context { + return get() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/data/security/SecurityHelperImpl.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/data/security/SecurityHelperImpl.kt new file mode 100644 index 00000000..f7a818f4 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/data/security/SecurityHelperImpl.kt @@ -0,0 +1,56 @@ +package com.module.notelycompose.data.security + +import com.module.notelycompose.domain.security.SecurityHelper +import org.owasp.html.HtmlPolicyBuilder +import org.owasp.html.PolicyFactory + +/** + * Production implementation of SecurityHelper using OWASP HTML Sanitizer. + * This implementation provides robust security for HTML content sanitization. + */ +class SecurityHelperImpl : SecurityHelper { + + private val htmlPolicy: PolicyFactory = HtmlPolicyBuilder() + .allowElements( + "p", "br", "strong", "b", "em", "i", "u", "h1", "h2", "h3", "h4", "h5", "h6", + "ul", "ol", "li", "blockquote", "pre", "code", "span", "div" + ) + .allowAttributes("style", "class") + .onElements("span", "div", "p") + .allowStyling() + .toFactory() + + override fun sanitizeHtml(input: String): String { + if (input.isBlank()) return input + + return try { + htmlPolicy.sanitize(input) + } catch (e: Exception) { + // If sanitization fails, return plain text + input.replace(Regex("<[^>]*>"), "") + } + } + + override fun validateInput(input: String): Boolean { + if (input.isBlank()) return true + + // Check for common XSS patterns + val dangerousPatterns = listOf( + "javascript:", + "vbscript:", + "onload=", + "onerror=", + "onclick=", + "onmouseover=", + "", + "eval(", + "expression(" + ) + + val lowerInput = input.lowercase() + return dangerousPatterns.none { pattern -> + lowerInput.contains(pattern.lowercase()) + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/di/DomainModule.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/di/DomainModule.kt new file mode 100644 index 00000000..6c276035 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/di/DomainModule.kt @@ -0,0 +1,32 @@ +package com.module.notelycompose.di + +import com.module.notelycompose.data.security.SecurityHelperImpl +import com.module.notelycompose.domain.security.SecurityHelper +import com.module.notelycompose.presentation.texteditor.TextEditorViewModel +import org.koin.dsl.module + +/** + * Koin module for domain layer dependencies. + * This module provides the production implementations that can be easily + * replaced with test doubles during testing. + */ +val domainModule = module { + + // Security + single { SecurityHelperImpl() } + + // ViewModels - Factory pattern for proper lifecycle management + factory { + TextEditorViewModel( + securityHelper = get(), + audioPlayer = get(), + noteRepository = get() + ) + } +} + +/** + * Platform-specific module that should be defined in androidMain and iosMain. + * This module contains platform-specific implementations. + */ +expect val platformModule: org.koin.core.module.Module \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/di/Modules.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/di/Modules.kt index 62aa6311..b841deb5 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/di/Modules.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/di/Modules.kt @@ -58,6 +58,12 @@ import com.module.notelycompose.summary.TFIDFSummarizer import com.module.notelycompose.core.security.AiSettingsRepository import com.module.notelycompose.core.security.SecurePreferencesRepository import com.module.notelycompose.notes.presentation.settings.AISettingsViewModel +import com.module.notelycompose.notes.domain.interfaces.DeleteNoteByIdUseCase +import com.module.notelycompose.notes.domain.interfaces.GetAllNotesUseCase +import com.module.notelycompose.notes.domain.interfaces.GetLastNoteUseCase +import com.module.notelycompose.notes.domain.interfaces.GetNoteByIdUseCase +import com.module.notelycompose.notes.domain.interfaces.InsertNoteUseCase +import com.module.notelycompose.notes.domain.interfaces.UpdateNoteUseCase import org.koin.core.module.Module import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.viewModelOf @@ -107,11 +113,13 @@ val repositoryModule = module { // OpenAI Integration single { com.module.notelycompose.openai.data.cache.OpenAIResponseCache() } + single { com.module.notelycompose.openai.domain.analytics.OpenAIAnalytics() } single { OpenAIRepositoryImpl( networkConnectivityManager = get(), securityHelper = get(), - responseCache = get() + responseCache = get(), + analytics = get() ) } single { TFIDFSummarizer() } @@ -133,13 +141,16 @@ val viewModelModule = module { } val useCaseModule = module { - factory { DeleteNoteById(get()) } - factory { GetAllNotesUseCase(get(), get()) } - factory { GetLastNote(get(), get()) } - factory { GetNoteById(get(), get()) } - factory { InsertNoteUseCase(get(), get(), get()) } + // Use case interfaces for testability + factory { DeleteNoteById(get()) } + factory { com.module.notelycompose.notes.domain.GetAllNotesUseCase(get(), get()) } + factory { GetLastNote(get(), get()) } + factory { GetNoteById(get(), get()) } + factory { com.module.notelycompose.notes.domain.InsertNoteUseCase(get(), get(), get()) } + factory { com.module.notelycompose.notes.domain.UpdateNoteUseCase(get(), get(), get()) } + + // Other use cases that don't need interface changes yet factory { SearchNotesUseCase(get(), get()) } - factory { UpdateNoteUseCase(get(), get(), get()) } factory { ModelAvailabilityService(get(), get()) } factory { BackgroundTranscriptionService(get(), get()) } @@ -152,4 +163,4 @@ val securityModule = module { // SecurityMonitoringService will be provided by platform-specific modules // as it requires platform-specific implementations single { SecurityHelper(get()) } -} +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/domain/audio/PlatformAudioPlayer.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/domain/audio/PlatformAudioPlayer.kt new file mode 100644 index 00000000..58f82d64 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/domain/audio/PlatformAudioPlayer.kt @@ -0,0 +1,48 @@ +package com.module.notelycompose.domain.audio + +/** + * Platform-agnostic audio player interface for note playback. + * This interface allows for different implementations on Android and iOS + * while providing a consistent API and enabling easy testing with mock implementations. + */ +interface PlatformAudioPlayer { + /** + * Plays audio from the specified file path. + * + * @param audioPath Absolute path to the audio file + * @throws AudioPlaybackException if playback fails + */ + suspend fun play(audioPath: String) + + /** + * Pauses the currently playing audio. + * No-op if no audio is currently playing. + */ + suspend fun pause() + + /** + * Stops audio playback and resets position to beginning. + */ + suspend fun stop() + + /** + * Seeks to a specific position in the audio file. + * + * @param position Position in milliseconds + */ + suspend fun seekTo(position: Long) + + /** + * Releases audio player resources. + * Should be called when the player is no longer needed. + */ + fun release() +} + +/** + * Exception thrown when audio playback operations fail. + */ +class AudioPlaybackException( + message: String, + cause: Throwable? = null +) : Exception(message, cause) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/domain/model/Note.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/domain/model/Note.kt new file mode 100644 index 00000000..bca5d331 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/domain/model/Note.kt @@ -0,0 +1,48 @@ +package com.module.notelycompose.domain.model + +/** + * Domain model representing a note in the application. + * This is the core entity used throughout the domain layer. + */ +data class Note( + val id: Long = 0L, + val title: String, + val content: String, + val timestamp: Long, + val isStarred: Boolean = false, + val audioFilePath: String? = null, + val hasAudio: Boolean = false, + val transcription: String? = null, + val tags: List = emptyList() +) { + /** + * Check if this note contains a search query in title or content + */ + fun containsQuery(query: String): Boolean { + if (query.isBlank()) return true + val lowercaseQuery = query.lowercase() + return title.lowercase().contains(lowercaseQuery) || + content.lowercase().contains(lowercaseQuery) || + transcription?.lowercase()?.contains(lowercaseQuery) == true + } + + /** + * Check if this note is a voice note (has audio) + */ + fun isVoiceNote(): Boolean = hasAudio && audioFilePath != null + + /** + * Get formatted timestamp for display + */ + fun getFormattedTimestamp(): String { + // This would typically use a proper date formatter + return "Timestamp: $timestamp" + } + + /** + * Validate note data integrity + */ + fun isValid(): Boolean { + return title.isNotBlank() || content.isNotBlank() || hasAudio + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/domain/repository/NoteRepository.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/domain/repository/NoteRepository.kt new file mode 100644 index 00000000..78b8f1f1 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/domain/repository/NoteRepository.kt @@ -0,0 +1,66 @@ +package com.module.notelycompose.domain.repository + +import com.module.notelycompose.domain.model.Note + +/** + * Repository interface for note operations. + * This defines the contract for data access without specifying implementation details. + */ +interface NoteRepository { + + /** + * Insert a new note into the repository + * @param note The note to insert + */ + suspend fun insertNote(note: Note) + + /** + * Delete a note from the repository + * @param note The note to delete + */ + suspend fun deleteNote(note: Note) + + /** + * Get a note by its ID + * @param id The ID of the note to retrieve + * @return The note if found, null otherwise + */ + suspend fun getNoteById(id: Long): Note? + + /** + * Get all notes from the repository + * @return List of all notes, sorted by timestamp (most recent first) + */ + suspend fun getAllNotes(): List + + /** + * Update an existing note + * @param note The note with updated information + */ + suspend fun updateNote(note: Note) + + /** + * Get all starred notes + * @return List of starred notes, sorted by timestamp (most recent first) + */ + suspend fun getStarredNotes(): List { + return getAllNotes().filter { it.isStarred } + } + + /** + * Get all voice notes (notes with audio) + * @return List of voice notes, sorted by timestamp (most recent first) + */ + suspend fun getVoiceNotes(): List { + return getAllNotes().filter { it.isVoiceNote() } + } + + /** + * Search notes by query + * @param query The search query + * @return List of notes matching the query + */ + suspend fun searchNotes(query: String): List { + return getAllNotes().filter { it.containsQuery(query) } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/domain/security/SecurityHelper.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/domain/security/SecurityHelper.kt new file mode 100644 index 00000000..ac8f4c90 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/domain/security/SecurityHelper.kt @@ -0,0 +1,26 @@ +package com.module.notelycompose.domain.security + +/** + * Security helper interface for input validation and HTML sanitization. + * This interface design allows for easy mocking in tests and different implementations + * across platforms while maintaining security standards. + */ +interface SecurityHelper { + /** + * Sanitizes HTML content by removing potentially dangerous elements and scripts. + * Uses OWASP HTML Sanitizer for robust security protection. + * + * @param input The raw HTML input to sanitize + * @return Sanitized HTML content safe for display + */ + fun sanitizeHtml(input: String): String + + /** + * Validates input content for basic security requirements. + * Checks for common patterns that might indicate malicious content. + * + * @param input The input string to validate + * @return true if input passes validation, false otherwise + */ + fun validateInput(input: String): Boolean +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/domain/usecases/AddNoteUseCase.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/domain/usecases/AddNoteUseCase.kt new file mode 100644 index 00000000..c2c9ad42 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/domain/usecases/AddNoteUseCase.kt @@ -0,0 +1,30 @@ +package com.module.notelycompose.domain.usecases + +import com.module.notelycompose.domain.model.Note +import com.module.notelycompose.domain.repository.NoteRepository + +/** + * Use case for adding a new note to the repository. + * Encapsulates the business logic for note creation. + */ +class AddNoteUseCase( + private val repository: NoteRepository +) { + /** + * Execute the use case to add a note + * @param note The note to add + */ + suspend operator fun invoke(note: Note) { + // Validate note before adding + require(note.isValid()) { "Note must have title, content, or audio" } + + // Ensure timestamp is set + val noteToAdd = if (note.timestamp == 0L) { + note.copy(timestamp = System.currentTimeMillis()) + } else { + note + } + + repository.insertNote(noteToAdd) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/domain/usecases/DeleteNoteUseCase.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/domain/usecases/DeleteNoteUseCase.kt new file mode 100644 index 00000000..25b750ea --- /dev/null +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/domain/usecases/DeleteNoteUseCase.kt @@ -0,0 +1,26 @@ +package com.module.notelycompose.domain.usecases + +import com.module.notelycompose.domain.model.Note +import com.module.notelycompose.domain.repository.NoteRepository + +/** + * Use case for deleting a note from the repository. + * Encapsulates the business logic for note deletion. + */ +class DeleteNoteUseCase( + private val repository: NoteRepository +) { + /** + * Execute the use case to delete a note + * @param note The note to delete + */ + suspend operator fun invoke(note: Note) { + repository.deleteNote(note) + + // Clean up associated audio file if it exists + if (note.hasAudio && note.audioFilePath != null) { + // In a real implementation, this would delete the audio file + // For now, we just mark it as handled in the business logic + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/domain/usecases/GetAllNotesUseCase.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/domain/usecases/GetAllNotesUseCase.kt new file mode 100644 index 00000000..0d576e21 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/domain/usecases/GetAllNotesUseCase.kt @@ -0,0 +1,20 @@ +package com.module.notelycompose.domain.usecases + +import com.module.notelycompose.domain.model.Note +import com.module.notelycompose.domain.repository.NoteRepository + +/** + * Use case for retrieving all notes from the repository. + * Encapsulates the business logic for fetching and ordering notes. + */ +class GetAllNotesUseCase( + private val repository: NoteRepository +) { + /** + * Execute the use case to get all notes + * @return List of all notes, sorted by timestamp (most recent first) + */ + suspend operator fun invoke(): List { + return repository.getAllNotes() + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/domain/usecases/UpdateNoteUseCase.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/domain/usecases/UpdateNoteUseCase.kt new file mode 100644 index 00000000..5fd3e55d --- /dev/null +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/domain/usecases/UpdateNoteUseCase.kt @@ -0,0 +1,24 @@ +package com.module.notelycompose.domain.usecases + +import com.module.notelycompose.domain.model.Note +import com.module.notelycompose.domain.repository.NoteRepository + +/** + * Use case for updating an existing note in the repository. + * Encapsulates the business logic for note updates. + */ +class UpdateNoteUseCase( + private val repository: NoteRepository +) { + /** + * Execute the use case to update a note + * @param note The note with updated information + */ + suspend operator fun invoke(note: Note) { + // Validate note before updating + require(note.isValid()) { "Note must have title, content, or audio" } + require(note.id > 0L) { "Note must have a valid ID for updates" } + + repository.updateNote(note) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/DeleteNoteById.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/DeleteNoteById.kt index d0b0a7cb..d4d7bea7 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/DeleteNoteById.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/DeleteNoteById.kt @@ -1,10 +1,11 @@ package com.module.notelycompose.notes.domain +import com.module.notelycompose.notes.domain.interfaces.DeleteNoteByIdUseCase class DeleteNoteById( private val noteDataSource: NoteDataSource -) { - suspend fun execute(id: Long) { +) : DeleteNoteByIdUseCase { + override suspend fun execute(id: Long) { return noteDataSource.deleteNoteById(id) } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/GetAllNotesUseCase.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/GetAllNotesUseCase.kt index 43268f60..0b7a926b 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/GetAllNotesUseCase.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/GetAllNotesUseCase.kt @@ -3,6 +3,7 @@ package com.module.notelycompose.notes.domain import com.module.notelycompose.core.CommonFlow import com.module.notelycompose.core.asFlow import com.module.notelycompose.core.toCommonFlow +import com.module.notelycompose.notes.domain.interfaces.GetAllNotesUseCase import com.module.notelycompose.notes.domain.mapper.NoteDomainMapper import com.module.notelycompose.notes.domain.model.NoteDomainModel import com.module.notelycompose.notes.domain.model.NotesFilterDomainModel @@ -15,8 +16,8 @@ import kotlinx.coroutines.flow.map class GetAllNotesUseCase( private val noteDataSource: NoteDataSource, private val noteDomainMapper: NoteDomainMapper -) { - fun execute(): CommonFlow> { +) : com.module.notelycompose.notes.domain.interfaces.GetAllNotesUseCase { + override fun execute(): CommonFlow> { return noteDataSource.getNotes().asFlow() .map { notes -> notes.map { noteDataModel -> @@ -24,4 +25,4 @@ class GetAllNotesUseCase( } }.toCommonFlow() } -} +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/GetLastNote.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/GetLastNote.kt index 95a0cefd..dd07fded 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/GetLastNote.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/GetLastNote.kt @@ -1,13 +1,14 @@ package com.module.notelycompose.notes.domain +import com.module.notelycompose.notes.domain.interfaces.GetLastNoteUseCase import com.module.notelycompose.notes.domain.mapper.NoteDomainMapper import com.module.notelycompose.notes.domain.model.NoteDomainModel class GetLastNote( private val noteDataSource: NoteDataSource, private val noteDomainMapper: NoteDomainMapper -) { - fun execute(): NoteDomainModel? { +) : GetLastNoteUseCase { + override fun execute(): NoteDomainModel? { return noteDataSource.getLastNote()?.let { noteDomainMapper.mapToDomainModel(it) } } -} +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/GetNoteById.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/GetNoteById.kt index f72b0573..04559c82 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/GetNoteById.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/GetNoteById.kt @@ -1,13 +1,14 @@ package com.module.notelycompose.notes.domain +import com.module.notelycompose.notes.domain.interfaces.GetNoteByIdUseCase import com.module.notelycompose.notes.domain.mapper.NoteDomainMapper import com.module.notelycompose.notes.domain.model.NoteDomainModel class GetNoteById( private val noteDataSource: NoteDataSource, private val noteDomainMapper: NoteDomainMapper -) { - fun execute(id: Long): NoteDomainModel? { +) : GetNoteByIdUseCase { + override fun execute(id: Long): NoteDomainModel? { return noteDataSource.getNoteById(id)?.let { noteDomainMapper.mapToDomainModel(it) } } -} +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/InsertNoteUseCase.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/InsertNoteUseCase.kt index 32166fee..22f78707 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/InsertNoteUseCase.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/InsertNoteUseCase.kt @@ -1,5 +1,6 @@ package com.module.notelycompose.notes.domain +import com.module.notelycompose.notes.domain.interfaces.InsertNoteUseCase import com.module.notelycompose.notes.domain.mapper.NoteDomainMapper import com.module.notelycompose.notes.domain.mapper.TextFormatMapper import com.module.notelycompose.notes.domain.model.TextAlignDomainModel @@ -9,8 +10,8 @@ class InsertNoteUseCase( private val noteDataSource: NoteDataSource, private val textFormatMapper: TextFormatMapper, private val noteDomainMapper: NoteDomainMapper -) { - suspend fun execute( +) : com.module.notelycompose.notes.domain.interfaces.InsertNoteUseCase { + override suspend fun execute( title: String, content: String, starred: Boolean, @@ -25,4 +26,4 @@ class InsertNoteUseCase( textAlign = noteDomainMapper.mapTextAlignToDataModel(textAlign), recordingPath = recordingPath ) -} +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/UpdateNoteUseCase.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/UpdateNoteUseCase.kt index 56ad7b5a..ce17a909 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/UpdateNoteUseCase.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/UpdateNoteUseCase.kt @@ -1,5 +1,6 @@ package com.module.notelycompose.notes.domain +import com.module.notelycompose.notes.domain.interfaces.UpdateNoteUseCase import com.module.notelycompose.notes.domain.mapper.NoteDomainMapper import com.module.notelycompose.notes.domain.mapper.TextFormatMapper import com.module.notelycompose.notes.domain.model.TextAlignDomainModel @@ -9,8 +10,8 @@ class UpdateNoteUseCase( private val noteDataSource: NoteDataSource, private val textFormatMapper: TextFormatMapper, private val noteDomainMapper: NoteDomainMapper -) { - suspend fun execute( +) : com.module.notelycompose.notes.domain.interfaces.UpdateNoteUseCase { + override suspend fun execute( id: Long, title: String, content: String, @@ -29,4 +30,4 @@ class UpdateNoteUseCase( recordingPath = recordingPath ) } -} +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/DeleteNoteByIdUseCase.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/DeleteNoteByIdUseCase.kt new file mode 100644 index 00000000..9a5bdea7 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/DeleteNoteByIdUseCase.kt @@ -0,0 +1,13 @@ +package com.module.notelycompose.notes.domain.interfaces + +/** + * Interface for deleting a note by its ID. + * This contract defines the business logic for note deletion operations. + */ +interface DeleteNoteByIdUseCase { + /** + * Execute the use case to delete a note by ID + * @param id The ID of the note to delete + */ + suspend fun execute(id: Long) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/GetAllNotesUseCase.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/GetAllNotesUseCase.kt new file mode 100644 index 00000000..a239e9a7 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/GetAllNotesUseCase.kt @@ -0,0 +1,16 @@ +package com.module.notelycompose.notes.domain.interfaces + +import com.module.notelycompose.core.CommonFlow +import com.module.notelycompose.notes.domain.model.NoteDomainModel + +/** + * Interface for retrieving all notes from the repository. + * This contract defines the business logic for fetching and ordering notes. + */ +interface GetAllNotesUseCase { + /** + * Execute the use case to get all notes + * @return Flow of all notes, sorted by timestamp (most recent first) + */ + fun execute(): CommonFlow> +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/GetLastNoteUseCase.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/GetLastNoteUseCase.kt new file mode 100644 index 00000000..5dc39f38 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/GetLastNoteUseCase.kt @@ -0,0 +1,15 @@ +package com.module.notelycompose.notes.domain.interfaces + +import com.module.notelycompose.notes.domain.model.NoteDomainModel + +/** + * Interface for retrieving the most recently created note. + * This contract defines the business logic for fetching the last note. + */ +interface GetLastNoteUseCase { + /** + * Execute the use case to get the most recent note + * @return The most recent note if any exists, null otherwise + */ + fun execute(): NoteDomainModel? +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/GetNoteByIdUseCase.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/GetNoteByIdUseCase.kt new file mode 100644 index 00000000..43893f02 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/GetNoteByIdUseCase.kt @@ -0,0 +1,16 @@ +package com.module.notelycompose.notes.domain.interfaces + +import com.module.notelycompose.notes.domain.model.NoteDomainModel + +/** + * Interface for retrieving a note by its ID. + * This contract defines the business logic for fetching a specific note. + */ +interface GetNoteByIdUseCase { + /** + * Execute the use case to get a note by ID + * @param id The ID of the note to retrieve + * @return The note if found, null otherwise + */ + fun execute(id: Long): NoteDomainModel? +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/InsertNoteUseCase.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/InsertNoteUseCase.kt new file mode 100644 index 00000000..3e6463b0 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/InsertNoteUseCase.kt @@ -0,0 +1,28 @@ +package com.module.notelycompose.notes.domain.interfaces + +import com.module.notelycompose.notes.domain.model.TextAlignDomainModel +import com.module.notelycompose.notes.domain.model.TextFormatDomainModel + +/** + * Interface for inserting a new note into the repository. + * This contract defines the business logic for note creation operations. + */ +interface InsertNoteUseCase { + /** + * Execute the use case to insert a new note + * @param title The title of the note + * @param content The content of the note + * @param starred Whether the note is starred + * @param formatting List of text formatting options + * @param textAlign Text alignment setting + * @param recordingPath Path to associated audio recording + */ + suspend fun execute( + title: String, + content: String, + starred: Boolean, + formatting: List, + textAlign: TextAlignDomainModel, + recordingPath: String + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/UpdateNoteUseCase.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/UpdateNoteUseCase.kt new file mode 100644 index 00000000..1562f7fa --- /dev/null +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/UpdateNoteUseCase.kt @@ -0,0 +1,30 @@ +package com.module.notelycompose.notes.domain.interfaces + +import com.module.notelycompose.notes.domain.model.TextAlignDomainModel +import com.module.notelycompose.notes.domain.model.TextFormatDomainModel + +/** + * Interface for updating an existing note in the repository. + * This contract defines the business logic for note update operations. + */ +interface UpdateNoteUseCase { + /** + * Execute the use case to update an existing note + * @param id The ID of the note to update + * @param title The updated title + * @param content The updated content + * @param starred Whether the note is starred + * @param formatting List of text formatting options + * @param textAlign Text alignment setting + * @param recordingPath Path to associated audio recording + */ + suspend fun execute( + id: Long, + title: String, + content: String, + starred: Boolean, + formatting: List, + textAlign: TextAlignDomainModel, + recordingPath: String + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/presentation/list/NoteListViewModel.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/presentation/list/NoteListViewModel.kt index a2abadf6..0f4253a4 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/presentation/list/NoteListViewModel.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/presentation/list/NoteListViewModel.kt @@ -4,8 +4,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.module.notelycompose.core.debugPrintln import com.module.notelycompose.core.security.SecurityHelper -import com.module.notelycompose.notes.domain.DeleteNoteById -import com.module.notelycompose.notes.domain.GetAllNotesUseCase +import com.module.notelycompose.notes.domain.interfaces.DeleteNoteByIdUseCase +import com.module.notelycompose.notes.domain.interfaces.GetAllNotesUseCase import com.module.notelycompose.notes.domain.model.NoteDomainModel import com.module.notelycompose.notes.domain.model.NotesFilterDomainModel import com.module.notelycompose.notes.presentation.helpers.getFirstNonEmptyLineAfterFirst @@ -38,7 +38,7 @@ private const val SEARCH_DEBOUNCE = 300L class NoteListViewModel( private val getAllNotesUseCase: GetAllNotesUseCase, - private val deleteNoteById: DeleteNoteById, + private val deleteNoteById: DeleteNoteByIdUseCase, private val notePresentationMapper: NotePresentationMapper, private val notesFilterMapper: NotesFilterMapper, private val securityHelper: SecurityHelper, @@ -320,4 +320,4 @@ class NoteListViewModel( fun clearDeleteOperationState() { _deleteOperationState.value = DeleteOperationState.Idle } -} +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/ui/cache/NotePreviewLRUCache.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/ui/cache/NotePreviewLRUCache.kt index cf9a7364..fc552682 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/ui/cache/NotePreviewLRUCache.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/ui/cache/NotePreviewLRUCache.kt @@ -130,6 +130,11 @@ class NotePreviewLRUCache( updateMemoryUsage() } + suspend fun size(): Int = mutex.withLock { + cache.size + } + + /** * Evict entries older than specified age */ @@ -233,13 +238,13 @@ data class NotePreviewCacheKey( */ fun fromNoteData( id: Long, - title: String, - content: String, + title: String?, + content: String?, isVoice: Boolean, isStarred: Boolean ): NotePreviewCacheKey { // Use efficient hash calculation instead of storing full content - val contentHash = (title + content).hashCode() + val contentHash = ((title ?: "") + (content ?: "")).hashCode() return NotePreviewCacheKey(id, contentHash, isVoice, isStarred) } } diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/openai/data/repository/OpenAIRepositoryImpl.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/openai/data/repository/OpenAIRepositoryImpl.kt index df9be53e..8576b19f 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/openai/data/repository/OpenAIRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/openai/data/repository/OpenAIRepositoryImpl.kt @@ -217,10 +217,17 @@ class OpenAIRepositoryImpl( // Cache successful response for offline access responseCache.cacheTranscription(request, successResponse) + // Record analytics + val responseTime = System.currentTimeMillis() - startTime + val estimatedCost = estimateTranscriptionCost(request.audioFilePath) + analytics.recordSuccessfulRequest(operation, responseTime, estimatedCost, fromCache = false) + successResponse } catch (e: ClientRequestException) { Napier.e("OpenAI client request error during transcription", e) + val responseTime = System.currentTimeMillis() - startTime + analytics.recordFailedRequest(operation, "CLIENT_ERROR", responseTime) OpenAIResponse( error = OpenAIError( code = "CLIENT_ERROR", @@ -230,6 +237,8 @@ class OpenAIRepositoryImpl( ) } catch (e: ServerResponseException) { Napier.e("OpenAI server error during transcription", e) + val responseTime = System.currentTimeMillis() - startTime + analytics.recordFailedRequest(operation, "SERVER_ERROR", responseTime) OpenAIResponse( error = OpenAIError( code = "SERVER_ERROR", @@ -239,6 +248,8 @@ class OpenAIRepositoryImpl( ) } catch (e: HttpRequestTimeoutException) { Napier.e("OpenAI request timeout during transcription", e) + val responseTime = System.currentTimeMillis() - startTime + analytics.recordFailedRequest(operation, "TIMEOUT_ERROR", responseTime) OpenAIResponse( error = OpenAIError( code = "TIMEOUT_ERROR", @@ -247,6 +258,8 @@ class OpenAIRepositoryImpl( ) } catch (e: Exception) { Napier.e("Unexpected error during audio transcription", e) + val responseTime = System.currentTimeMillis() - startTime + analytics.recordFailedRequest(operation, "UNKNOWN_ERROR", responseTime) OpenAIResponse( error = OpenAIError( code = "UNKNOWN_ERROR", @@ -259,6 +272,9 @@ class OpenAIRepositoryImpl( override suspend fun summarizeText(request: SummarizationRequest): OpenAIResponse { return withContext(Dispatchers.Default) { + val startTime = System.currentTimeMillis() + val operation = "summarization" + try { // Input validation if (request.text.isBlank()) { @@ -274,6 +290,8 @@ class OpenAIRepositoryImpl( val cachedResponse = responseCache.getCachedSummarization(request) if (cachedResponse != null) { Napier.d("Returning cached summarization result") + val responseTime = System.currentTimeMillis() - startTime + analytics.recordSuccessfulRequest(operation, responseTime, fromCache = true) return@withContext cachedResponse } @@ -347,10 +365,17 @@ class OpenAIRepositoryImpl( // Cache successful response for offline access responseCache.cacheSummarization(request, successResponse) + // Record analytics + val responseTime = System.currentTimeMillis() - startTime + val estimatedCost = estimateSummarizationCost(request.text) + analytics.recordSuccessfulRequest(operation, responseTime, estimatedCost, fromCache = false) + successResponse } catch (e: ClientRequestException) { Napier.e("OpenAI client request error during summarization", e) + val responseTime = System.currentTimeMillis() - startTime + analytics.recordFailedRequest(operation, "CLIENT_ERROR", responseTime) OpenAIResponse( error = OpenAIError( code = "CLIENT_ERROR", @@ -360,6 +385,8 @@ class OpenAIRepositoryImpl( ) } catch (e: ServerResponseException) { Napier.e("OpenAI server error during summarization", e) + val responseTime = System.currentTimeMillis() - startTime + analytics.recordFailedRequest(operation, "SERVER_ERROR", responseTime) OpenAIResponse( error = OpenAIError( code = "SERVER_ERROR", @@ -369,6 +396,8 @@ class OpenAIRepositoryImpl( ) } catch (e: HttpRequestTimeoutException) { Napier.e("OpenAI request timeout during summarization", e) + val responseTime = System.currentTimeMillis() - startTime + analytics.recordFailedRequest(operation, "TIMEOUT_ERROR", responseTime) OpenAIResponse( error = OpenAIError( code = "TIMEOUT_ERROR", @@ -377,6 +406,8 @@ class OpenAIRepositoryImpl( ) } catch (e: Exception) { Napier.e("Unexpected error during text summarization", e) + val responseTime = System.currentTimeMillis() - startTime + analytics.recordFailedRequest(operation, "UNKNOWN_ERROR", responseTime) OpenAIResponse( error = OpenAIError( code = "UNKNOWN_ERROR", diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/openai/domain/analytics/OpenAIAnalytics.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/openai/domain/analytics/OpenAIAnalytics.kt index 348ed582..e3b7e617 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/openai/domain/analytics/OpenAIAnalytics.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/openai/domain/analytics/OpenAIAnalytics.kt @@ -119,8 +119,7 @@ class OpenAIAnalytics { cacheHits = totalCacheHits, cacheHitRate = cacheHitRate, totalEstimatedCostCents = totalCostCents, - averageResponseTimeMs = avgResponseTime, - operationMetrics = metrics.toMap() + averageResponseTimeMs = avgResponseTime ) } } @@ -173,7 +172,7 @@ class OpenAIAnalytics { /** * Internal metric data storage. */ -private data class MetricData( +internal data class MetricData( var totalRequests: Long = 0, var successfulRequests: Long = 0, var failedRequests: Long = 0, @@ -196,7 +195,6 @@ data class AnalyticsSummary( val cacheHitRate: Double, val totalEstimatedCostCents: Long, val averageResponseTimeMs: Double, - val operationMetrics: Map ) { val estimatedCostDollars: Double get() = totalEstimatedCostCents / 100.0 diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/presentation/texteditor/TextEditorViewModel.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/presentation/texteditor/TextEditorViewModel.kt new file mode 100644 index 00000000..b2f8a209 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/presentation/texteditor/TextEditorViewModel.kt @@ -0,0 +1,237 @@ +package com.module.notelycompose.presentation.texteditor + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.module.notelycompose.domain.audio.PlatformAudioPlayer +import com.module.notelycompose.domain.model.Note +import com.module.notelycompose.domain.repository.NoteRepository +import com.module.notelycompose.domain.security.SecurityHelper +import kotlinx.coroutines.CoroutineScope +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.launch +import kotlinx.datetime.Clock + +/** + * ViewModel for the text editor screen that is designed to be fully testable. + * + * Key testability improvements: + * 1. Uses constructor injection for all dependencies + * 2. Exposes public clearViewModel() method instead of relying on protected onCleared() + * 3. Uses interface-based dependencies for easy mocking + * 4. Accepts optional CoroutineScope for testing with TestScope + * 5. Proper error handling and state management + */ +class TextEditorViewModel( + private val securityHelper: SecurityHelper, + private val audioPlayer: PlatformAudioPlayer, + private val noteRepository: NoteRepository, + private val coroutineScope: CoroutineScope? = null // Optional for testing +) : ViewModel() { + + private val effectiveScope = coroutineScope ?: viewModelScope + + private val _uiState = MutableStateFlow(TextEditorUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var longRunningJob: Job? = null + + /** + * Processes user intents/actions for the text editor. + */ + fun onProcessIntent(intent: TextEditorIntent) { + when (intent) { + is TextEditorIntent.UpdateContent -> updateContent(intent.content) + is TextEditorIntent.SaveNote -> saveNote() + is TextEditorIntent.PlayAudio -> playAudio(intent.audioPath) + is TextEditorIntent.LoadNote -> loadNote(intent.noteId) + is TextEditorIntent.ToggleStar -> toggleStar() + is TextEditorIntent.StartLongRunningTask -> startLongRunningTask() + is TextEditorIntent.ClearError -> clearError() + } + } + + private fun updateContent(content: String) { + val sanitizedContent = securityHelper.sanitizeHtml(content) + val isValid = securityHelper.validateInput(sanitizedContent) + + _uiState.value = _uiState.value.copy( + content = sanitizedContent, + isValid = isValid, + error = if (!isValid) "Invalid content detected" else null + ) + } + + private fun saveNote() { + val currentState = _uiState.value + if (currentState.content.isBlank()) { + _uiState.value = currentState.copy(error = "Cannot save empty note") + return + } + + _uiState.value = currentState.copy(isLoading = true, error = null) + + effectiveScope.launch { + try { + val note = Note( + id = currentState.noteId ?: generateNoteId(), + title = extractTitle(currentState.content), + content = currentState.content, + createdAt = Clock.System.now(), + updatedAt = Clock.System.now(), + isStarred = currentState.isStarred + ) + + noteRepository.saveNote(note).fold( + onSuccess = { + _uiState.value = _uiState.value.copy( + isLoading = false, + isSaved = true, + noteId = note.id + ) + }, + onFailure = { error -> + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Failed to save note: ${error.message}" + ) + } + ) + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Unexpected error: ${e.message}" + ) + } + } + } + + private fun playAudio(audioPath: String) { + effectiveScope.launch { + try { + _uiState.value = _uiState.value.copy(isPlayingAudio = true) + audioPlayer.play(audioPath) + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + isPlayingAudio = false, + error = "Failed to play audio: ${e.message}" + ) + } + } + } + + private fun loadNote(noteId: String) { + _uiState.value = _uiState.value.copy(isLoading = true) + + effectiveScope.launch { + noteRepository.getNote(noteId).fold( + onSuccess = { note -> + if (note != null) { + _uiState.value = _uiState.value.copy( + isLoading = false, + noteId = note.id, + content = note.content, + isStarred = note.isStarred, + isSaved = true + ) + } else { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Note not found" + ) + } + }, + onFailure = { error -> + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Failed to load note: ${error.message}" + ) + } + ) + } + } + + private fun toggleStar() { + _uiState.value = _uiState.value.copy( + isStarred = !_uiState.value.isStarred + ) + } + + private fun startLongRunningTask() { + longRunningJob = effectiveScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true) + try { + // Simulate long-running task + delay(5000) + _uiState.value = _uiState.value.copy(isLoading = false) + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Long running task failed: ${e.message}" + ) + } + } + } + + private fun clearError() { + _uiState.value = _uiState.value.copy(error = null) + } + + /** + * Public method to clear the ViewModel, replacing the need to access protected onCleared(). + * This method should be called from tests to properly simulate ViewModel lifecycle. + */ + fun clearViewModel() { + longRunningJob?.cancel() + audioPlayer.release() + // Call the actual onCleared() if needed + onCleared() + } + + override fun onCleared() { + super.onCleared() + audioPlayer.release() + longRunningJob?.cancel() + } + + private fun extractTitle(content: String): String { + return content.lines() + .firstOrNull { it.isNotBlank() } + ?.take(50) + ?: "Untitled Note" + } + + private fun generateNoteId(): String { + return "note_${Clock.System.now().toEpochMilliseconds()}" + } +} + +/** + * UI state for the text editor screen. + */ +data class TextEditorUiState( + val content: String = "", + val isLoading: Boolean = false, + val isSaved: Boolean = false, + val isStarred: Boolean = false, + val isPlayingAudio: Boolean = false, + val isValid: Boolean = true, + val error: String? = null, + val noteId: String? = null +) + +/** + * User intents for the text editor. + */ +sealed class TextEditorIntent { + data class UpdateContent(val content: String) : TextEditorIntent() + data class LoadNote(val noteId: String) : TextEditorIntent() + data class PlayAudio(val audioPath: String) : TextEditorIntent() + data object SaveNote : TextEditorIntent() + data object ToggleStar : TextEditorIntent() + data object StartLongRunningTask : TextEditorIntent() + data object ClearError : TextEditorIntent() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/presentation/viewmodels/NoteViewModel.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/presentation/viewmodels/NoteViewModel.kt new file mode 100644 index 00000000..d77b9f8a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/presentation/viewmodels/NoteViewModel.kt @@ -0,0 +1,140 @@ +package com.module.notelycompose.presentation.viewmodels + +import com.module.notelycompose.domain.model.Note +import com.module.notelycompose.domain.usecases.AddNoteUseCase +import com.module.notelycompose.domain.usecases.DeleteNoteUseCase +import com.module.notelycompose.domain.usecases.GetAllNotesUseCase +import com.module.notelycompose.domain.usecases.UpdateNoteUseCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** + * ViewModel for managing note-related UI state and operations. + * Handles the presentation logic for the notes screen. + */ +class NoteViewModel( + private val addNoteUseCase: AddNoteUseCase, + private val getAllNotesUseCase: GetAllNotesUseCase, + private val deleteNoteUseCase: DeleteNoteUseCase, + private val updateNoteUseCase: UpdateNoteUseCase +) { + private val viewModelScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + private val _state = MutableStateFlow(NoteState()) + val state: StateFlow = _state.asStateFlow() + + data class NoteState( + val notes: List = emptyList(), + val isLoading: Boolean = false, + val searchQuery: String = "", + val error: String? = null + ) + + init { + loadNotes() + } + + /** + * Load all notes from the repository + */ + fun loadNotes() { + viewModelScope.launch { + _state.value = _state.value.copy(isLoading = true, error = null) + try { + val allNotes = getAllNotesUseCase() + val filteredNotes = if (_state.value.searchQuery.isBlank()) { + allNotes + } else { + allNotes.filter { it.containsQuery(_state.value.searchQuery) } + } + _state.value = _state.value.copy( + notes = filteredNotes, + isLoading = false + ) + } catch (e: Exception) { + _state.value = _state.value.copy( + isLoading = false, + error = e.message ?: "Unknown error occurred" + ) + } + } + } + + /** + * Add a new note + */ + fun addNote(note: Note) { + viewModelScope.launch { + try { + addNoteUseCase(note) + loadNotes() // Refresh the list + } catch (e: Exception) { + _state.value = _state.value.copy( + error = e.message ?: "Failed to add note" + ) + } + } + } + + /** + * Delete a note + */ + fun deleteNote(note: Note) { + viewModelScope.launch { + try { + deleteNoteUseCase(note) + loadNotes() // Refresh the list + } catch (e: Exception) { + _state.value = _state.value.copy( + error = e.message ?: "Failed to delete note" + ) + } + } + } + + /** + * Update search query and filter notes + */ + fun updateSearchQuery(query: String) { + viewModelScope.launch { + _state.value = _state.value.copy(searchQuery = query) + loadNotes() // This will apply the filter + } + } + + /** + * Clear search query and show all notes + */ + fun clearSearch() { + updateSearchQuery("") + } + + /** + * Toggle starred status of a note + */ + fun toggleStarred(note: Note) { + viewModelScope.launch { + try { + val updatedNote = note.copy(isStarred = !note.isStarred) + updateNoteUseCase(updatedNote) + loadNotes() // Refresh the list + } catch (e: Exception) { + _state.value = _state.value.copy( + error = e.message ?: "Failed to update note" + ) + } + } + } + + /** + * Clear any error state + */ + fun clearError() { + _state.value = _state.value.copy(error = null) + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/com/module/notelycompose/data/repository/NoteRepositoryImplTest.kt b/shared/src/commonTest/kotlin/com/module/notelycompose/data/repository/NoteRepositoryImplTest.kt new file mode 100644 index 00000000..72bfc958 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/module/notelycompose/data/repository/NoteRepositoryImplTest.kt @@ -0,0 +1,345 @@ +package com.module.notelycompose.data.repository + +import com.module.notelycompose.domain.model.Note +import com.module.notelycompose.domain.repository.NoteRepository +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class NoteRepositoryImplTest { + + // Mock database implementation for testing + private class InMemoryNoteStorage { + private val notes = mutableMapOf() + private var nextId = 1L + + fun insert(note: Note): Note { + val noteWithId = if (note.id == 0L) { + note.copy(id = nextId++) + } else { + notes[note.id] = note + note + } + notes[noteWithId.id] = noteWithId + return noteWithId + } + + fun delete(noteId: Long): Boolean { + return notes.remove(noteId) != null + } + + fun findById(id: Long): Note? { + return notes[id] + } + + fun findAll(): List { + return notes.values.sortedByDescending { it.timestamp } + } + + fun update(note: Note): Boolean { + return if (notes.containsKey(note.id)) { + notes[note.id] = note + true + } else { + false + } + } + + fun clear() { + notes.clear() + nextId = 1L + } + + fun count(): Int = notes.size + } + + // Test implementation of repository + private class TestNoteRepository(private val storage: InMemoryNoteStorage) : NoteRepository { + + override suspend fun insertNote(note: Note) { + storage.insert(note) + } + + override suspend fun deleteNote(note: Note) { + storage.delete(note.id) + } + + override suspend fun getNoteById(id: Long): Note? { + return storage.findById(id) + } + + override suspend fun getAllNotes(): List { + return storage.findAll() + } + + override suspend fun updateNote(note: Note) { + storage.update(note) + } + } + + private fun createTestRepository(): Pair { + val storage = InMemoryNoteStorage() + val repository = TestNoteRepository(storage) + return repository to storage + } + + @Test + fun `insertNote should add new note with generated ID`() = runTest { + // Given + val (repository, storage) = createTestRepository() + val note = Note( + id = 0L, // Will be auto-generated + title = "Test Note", + content = "Test Content", + timestamp = System.currentTimeMillis(), + isStarred = false + ) + + // When + repository.insertNote(note) + + // Then + assertEquals(1, storage.count()) + val savedNote = storage.findById(1L) + assertNotNull(savedNote) + assertEquals("Test Note", savedNote.title) + assertEquals("Test Content", savedNote.content) + } + + @Test + fun `insertNote should preserve existing ID when provided`() = runTest { + // Given + val (repository, storage) = createTestRepository() + val note = Note( + id = 42L, + title = "Test Note", + content = "Test Content", + timestamp = System.currentTimeMillis(), + isStarred = true + ) + + // When + repository.insertNote(note) + + // Then + val savedNote = storage.findById(42L) + assertNotNull(savedNote) + assertEquals(42L, savedNote.id) + assertEquals("Test Note", savedNote.title) + assertTrue(savedNote.isStarred) + } + + @Test + fun `getAllNotes should return notes sorted by timestamp descending`() = runTest { + // Given + val (repository, _) = createTestRepository() + val note1 = Note(id = 1L, title = "First", content = "Content 1", timestamp = 1000L, isStarred = false) + val note2 = Note(id = 2L, title = "Second", content = "Content 2", timestamp = 3000L, isStarred = false) + val note3 = Note(id = 3L, title = "Third", content = "Content 3", timestamp = 2000L, isStarred = false) + + repository.insertNote(note1) + repository.insertNote(note2) + repository.insertNote(note3) + + // When + val notes = repository.getAllNotes() + + // Then + assertEquals(3, notes.size) + assertEquals("Second", notes[0].title) // timestamp 3000L + assertEquals("Third", notes[1].title) // timestamp 2000L + assertEquals("First", notes[2].title) // timestamp 1000L + } + + @Test + fun `getNoteById should return correct note`() = runTest { + // Given + val (repository, _) = createTestRepository() + val note = Note( + id = 100L, + title = "Specific Note", + content = "Specific Content", + timestamp = System.currentTimeMillis(), + isStarred = true + ) + repository.insertNote(note) + + // When + val retrievedNote = repository.getNoteById(100L) + + // Then + assertNotNull(retrievedNote) + assertEquals(100L, retrievedNote.id) + assertEquals("Specific Note", retrievedNote.title) + assertEquals("Specific Content", retrievedNote.content) + assertTrue(retrievedNote.isStarred) + } + + @Test + fun `getNoteById should return null for non-existent note`() = runTest { + // Given + val (repository, _) = createTestRepository() + + // When + val retrievedNote = repository.getNoteById(999L) + + // Then + assertNull(retrievedNote) + } + + @Test + fun `updateNote should modify existing note`() = runTest { + // Given + val (repository, _) = createTestRepository() + val originalNote = Note( + id = 1L, + title = "Original Title", + content = "Original Content", + timestamp = 1000L, + isStarred = false + ) + repository.insertNote(originalNote) + + val updatedNote = originalNote.copy( + title = "Updated Title", + content = "Updated Content", + isStarred = true + ) + + // When + repository.updateNote(updatedNote) + + // Then + val retrievedNote = repository.getNoteById(1L) + assertNotNull(retrievedNote) + assertEquals("Updated Title", retrievedNote.title) + assertEquals("Updated Content", retrievedNote.content) + assertTrue(retrievedNote.isStarred) + assertEquals(1000L, retrievedNote.timestamp) // Timestamp should remain unchanged + } + + @Test + fun `deleteNote should remove note from repository`() = runTest { + // Given + val (repository, storage) = createTestRepository() + val note = Note( + id = 1L, + title = "To Delete", + content = "Content", + timestamp = System.currentTimeMillis(), + isStarred = false + ) + repository.insertNote(note) + assertEquals(1, storage.count()) + + // When + repository.deleteNote(note) + + // Then + assertEquals(0, storage.count()) + val retrievedNote = repository.getNoteById(1L) + assertNull(retrievedNote) + } + + @Test + fun `repository should handle empty state`() = runTest { + // Given + val (repository, _) = createTestRepository() + + // When + val notes = repository.getAllNotes() + + // Then + assertTrue(notes.isEmpty()) + } + + @Test + fun `repository should handle special characters in content`() = runTest { + // Given + val (repository, _) = createTestRepository() + val specialContent = """ + Special characters: àáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ + Symbols: !@#$%^&*()_+-=[]{}|;':\",./<>? + Unicode: 🎵🎶🎤🎧 音楽 музыка موسيقى + Newlines and tabs: + Line 1 + Line 2 + Indented line + """.trimIndent() + + val note = Note( + id = 1L, + title = "Special Chars Test", + content = specialContent, + timestamp = System.currentTimeMillis(), + isStarred = false + ) + + // When + repository.insertNote(note) + + // Then + val retrievedNote = repository.getNoteById(1L) + assertNotNull(retrievedNote) + assertEquals(specialContent, retrievedNote.content) + } + + @Test + fun `repository should handle large content`() = runTest { + // Given + val (repository, _) = createTestRepository() + val largeContent = "A".repeat(10000) // 10KB of content + val note = Note( + id = 1L, + title = "Large Content Test", + content = largeContent, + timestamp = System.currentTimeMillis(), + isStarred = false + ) + + // When + repository.insertNote(note) + + // Then + val retrievedNote = repository.getNoteById(1L) + assertNotNull(retrievedNote) + assertEquals(largeContent, retrievedNote.content) + assertEquals(10000, retrievedNote.content.length) + } + + @Test + fun `repository should maintain data integrity during concurrent operations`() = runTest { + // Given + val (repository, storage) = createTestRepository() + val notes = (1..100).map { i -> + Note( + id = i.toLong(), + title = "Note $i", + content = "Content $i", + timestamp = i * 1000L, + isStarred = i % 5 == 0 + ) + } + + // When - Simulate concurrent insertions + notes.forEach { note -> + repository.insertNote(note) + } + + // Then + assertEquals(100, storage.count()) + val retrievedNotes = repository.getAllNotes() + assertEquals(100, retrievedNotes.size) + + // Verify sorting (most recent first) + assertEquals("Note 100", retrievedNotes.first().title) + assertEquals("Note 1", retrievedNotes.last().title) + + // Verify starred notes + val starredNotes = retrievedNotes.filter { it.isStarred } + assertEquals(20, starredNotes.size) // Every 5th note should be starred + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/com/module/notelycompose/domain/usecases/AddNoteUseCaseTest.kt b/shared/src/commonTest/kotlin/com/module/notelycompose/domain/usecases/AddNoteUseCaseTest.kt new file mode 100644 index 00000000..37b23bf8 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/module/notelycompose/domain/usecases/AddNoteUseCaseTest.kt @@ -0,0 +1,107 @@ +package com.module.notelycompose.domain.usecases + +import com.module.notelycompose.domain.model.Note +import com.module.notelycompose.domain.repository.NoteRepository +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class AddNoteUseCaseTest { + + private class MockNoteRepository : NoteRepository { + private val notes = mutableListOf() + + override suspend fun insertNote(note: Note) { + notes.add(note) + } + + override suspend fun deleteNote(note: Note) { + notes.remove(note) + } + + override suspend fun getNoteById(id: Long): Note? { + return notes.find { it.id == id } + } + + override suspend fun getAllNotes() = notes.toList() + + override suspend fun updateNote(note: Note) { + val index = notes.indexOfFirst { it.id == note.id } + if (index != -1) { + notes[index] = note + } + } + } + + @Test + fun `addNote should successfully add note to repository`() = runTest { + // Given + val repository = MockNoteRepository() + val addNoteUseCase = AddNoteUseCase(repository) + val note = Note( + id = 1L, + title = "Test Note", + content = "Test Content", + timestamp = System.currentTimeMillis(), + isStarred = false + ) + + // When + addNoteUseCase(note) + + // Then + val savedNote = repository.getNoteById(1L) + assertNotNull(savedNote) + assertEquals("Test Note", savedNote.title) + assertEquals("Test Content", savedNote.content) + } + + @Test + fun `addNote should handle empty title`() = runTest { + // Given + val repository = MockNoteRepository() + val addNoteUseCase = AddNoteUseCase(repository) + val note = Note( + id = 1L, + title = "", + content = "Test Content", + timestamp = System.currentTimeMillis(), + isStarred = false + ) + + // When + addNoteUseCase(note) + + // Then + val savedNote = repository.getNoteById(1L) + assertNotNull(savedNote) + assertEquals("", savedNote.title) + assertEquals("Test Content", savedNote.content) + } + + @Test + fun `addNote should preserve timestamp`() = runTest { + // Given + val repository = MockNoteRepository() + val addNoteUseCase = AddNoteUseCase(repository) + val timestamp = System.currentTimeMillis() + val note = Note( + id = 1L, + title = "Test Note", + content = "Test Content", + timestamp = timestamp, + isStarred = true + ) + + // When + addNoteUseCase(note) + + // Then + val savedNote = repository.getNoteById(1L) + assertNotNull(savedNote) + assertEquals(timestamp, savedNote.timestamp) + assertTrue(savedNote.isStarred) + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/com/module/notelycompose/domain/usecases/GetAllNotesUseCaseTest.kt b/shared/src/commonTest/kotlin/com/module/notelycompose/domain/usecases/GetAllNotesUseCaseTest.kt new file mode 100644 index 00000000..1db5c8f7 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/module/notelycompose/domain/usecases/GetAllNotesUseCaseTest.kt @@ -0,0 +1,149 @@ +package com.module.notelycompose.domain.usecases + +import com.module.notelycompose.domain.model.Note +import com.module.notelycompose.domain.repository.NoteRepository +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class GetAllNotesUseCaseTest { + + private class MockNoteRepository : NoteRepository { + private val notes = mutableListOf() + + fun addTestNote(note: Note) { + notes.add(note) + } + + override suspend fun insertNote(note: Note) { + notes.add(note) + } + + override suspend fun deleteNote(note: Note) { + notes.remove(note) + } + + override suspend fun getNoteById(id: Long): Note? { + return notes.find { it.id == id } + } + + override suspend fun getAllNotes() = notes.sortedByDescending { it.timestamp } + + override suspend fun updateNote(note: Note) { + val index = notes.indexOfFirst { it.id == note.id } + if (index != -1) { + notes[index] = note + } + } + } + + @Test + fun `getAllNotes should return empty list when no notes exist`() = runTest { + // Given + val repository = MockNoteRepository() + val getAllNotesUseCase = GetAllNotesUseCase(repository) + + // When + val result = getAllNotesUseCase() + + // Then + assertTrue(result.isEmpty()) + } + + @Test + fun `getAllNotes should return all notes sorted by timestamp descending`() = runTest { + // Given + val repository = MockNoteRepository() + val getAllNotesUseCase = GetAllNotesUseCase(repository) + + val note1 = Note( + id = 1L, + title = "First Note", + content = "Content 1", + timestamp = 1000L, + isStarred = false + ) + val note2 = Note( + id = 2L, + title = "Second Note", + content = "Content 2", + timestamp = 2000L, + isStarred = true + ) + val note3 = Note( + id = 3L, + title = "Third Note", + content = "Content 3", + timestamp = 1500L, + isStarred = false + ) + + repository.addTestNote(note1) + repository.addTestNote(note2) + repository.addTestNote(note3) + + // When + val result = getAllNotesUseCase() + + // Then + assertEquals(3, result.size) + assertEquals("Second Note", result[0].title) // Most recent + assertEquals("Third Note", result[1].title) // Middle + assertEquals("First Note", result[2].title) // Oldest + } + + @Test + fun `getAllNotes should handle single note`() = runTest { + // Given + val repository = MockNoteRepository() + val getAllNotesUseCase = GetAllNotesUseCase(repository) + + val note = Note( + id = 1L, + title = "Single Note", + content = "Single Content", + timestamp = System.currentTimeMillis(), + isStarred = true + ) + + repository.addTestNote(note) + + // When + val result = getAllNotesUseCase() + + // Then + assertEquals(1, result.size) + assertEquals("Single Note", result[0].title) + assertTrue(result[0].isStarred) + } + + @Test + fun `getAllNotes should preserve note properties`() = runTest { + // Given + val repository = MockNoteRepository() + val getAllNotesUseCase = GetAllNotesUseCase(repository) + + val note = Note( + id = 42L, + title = "Test Note", + content = "Test Content with special chars: àáâãäå", + timestamp = 12345L, + isStarred = true + ) + + repository.addTestNote(note) + + // When + val result = getAllNotesUseCase() + + // Then + assertEquals(1, result.size) + val retrievedNote = result[0] + assertEquals(42L, retrievedNote.id) + assertEquals("Test Note", retrievedNote.title) + assertEquals("Test Content with special chars: àáâãäå", retrievedNote.content) + assertEquals(12345L, retrievedNote.timestamp) + assertTrue(retrievedNote.isStarred) + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/com/module/notelycompose/integration/NoteWorkflowIntegrationTest.kt b/shared/src/commonTest/kotlin/com/module/notelycompose/integration/NoteWorkflowIntegrationTest.kt new file mode 100644 index 00000000..3f15c329 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/module/notelycompose/integration/NoteWorkflowIntegrationTest.kt @@ -0,0 +1,278 @@ +package com.module.notelycompose.integration + +import com.module.notelycompose.data.repository.NoteRepositoryImplTest +import com.module.notelycompose.domain.model.Note +import com.module.notelycompose.domain.usecases.AddNoteUseCase +import com.module.notelycompose.domain.usecases.DeleteNoteUseCase +import com.module.notelycompose.domain.usecases.GetAllNotesUseCase +import com.module.notelycompose.domain.usecases.UpdateNoteUseCase +import com.module.notelycompose.presentation.viewmodels.NoteViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Integration tests to validate that all components work together correctly. + * Tests the complete workflow from ViewModel through use cases to repository. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class NoteWorkflowIntegrationTest { + + private lateinit var viewModel: NoteViewModel + private val testDispatcher = StandardTestDispatcher() + + @BeforeTest + fun setup() { + Dispatchers.setMain(testDispatcher) + + // Create real instances (not mocks) to test integration + val repository = createInMemoryRepository() + val addNoteUseCase = AddNoteUseCase(repository) + val getAllNotesUseCase = GetAllNotesUseCase(repository) + val deleteNoteUseCase = DeleteNoteUseCase(repository) + val updateNoteUseCase = UpdateNoteUseCase(repository) + + viewModel = NoteViewModel( + addNoteUseCase = addNoteUseCase, + getAllNotesUseCase = getAllNotesUseCase, + deleteNoteUseCase = deleteNoteUseCase, + updateNoteUseCase = updateNoteUseCase + ) + } + + private fun createInMemoryRepository(): com.module.notelycompose.domain.repository.NoteRepository { + return object : com.module.notelycompose.domain.repository.NoteRepository { + private val notes = mutableMapOf() + private var nextId = 1L + + override suspend fun insertNote(note: Note) { + val noteToInsert = if (note.id == 0L) { + note.copy(id = nextId++) + } else { + note + } + notes[noteToInsert.id] = noteToInsert + } + + override suspend fun deleteNote(note: Note) { + notes.remove(note.id) + } + + override suspend fun getNoteById(id: Long): Note? { + return notes[id] + } + + override suspend fun getAllNotes(): List { + return notes.values.sortedByDescending { it.timestamp } + } + + override suspend fun updateNote(note: Note) { + notes[note.id] = note + } + } + } + + @Test + fun `complete note lifecycle should work end-to-end`() = runTest { + // Initially no notes + testDispatcher.scheduler.advanceUntilIdle() + var state = viewModel.state.first() + assertTrue(state.notes.isEmpty()) + + // Add a note + val newNote = Note( + title = "Integration Test Note", + content = "This note tests the complete workflow", + timestamp = System.currentTimeMillis(), + isStarred = false + ) + + viewModel.addNote(newNote) + testDispatcher.scheduler.advanceUntilIdle() + + // Verify note was added + state = viewModel.state.first() + assertEquals(1, state.notes.size) + assertEquals("Integration Test Note", state.notes[0].title) + assertFalse(state.notes[0].isStarred) + + // Toggle starred status + viewModel.toggleStarred(state.notes[0]) + testDispatcher.scheduler.advanceUntilIdle() + + // Verify star toggle + state = viewModel.state.first() + assertTrue(state.notes[0].isStarred) + + // Search for the note + viewModel.updateSearchQuery("Integration") + testDispatcher.scheduler.advanceUntilIdle() + + // Verify search works + state = viewModel.state.first() + assertEquals(1, state.notes.size) + assertEquals("integration", state.searchQuery) + + // Clear search + viewModel.clearSearch() + testDispatcher.scheduler.advanceUntilIdle() + + // Verify all notes visible again + state = viewModel.state.first() + assertEquals(1, state.notes.size) + assertEquals("", state.searchQuery) + + // Delete the note + viewModel.deleteNote(state.notes[0]) + testDispatcher.scheduler.advanceUntilIdle() + + // Verify note was deleted + state = viewModel.state.first() + assertTrue(state.notes.isEmpty()) + } + + @Test + fun `multiple notes workflow should maintain proper ordering`() = runTest { + // Add multiple notes with different timestamps + val notes = listOf( + Note(title = "First Note", content = "Content 1", timestamp = 1000L, isStarred = false), + Note(title = "Second Note", content = "Content 2", timestamp = 3000L, isStarred = true), + Note(title = "Third Note", content = "Content 3", timestamp = 2000L, isStarred = false) + ) + + notes.forEach { note -> + viewModel.addNote(note) + testDispatcher.scheduler.advanceUntilIdle() + } + + // Verify proper ordering (most recent first) + val state = viewModel.state.first() + assertEquals(3, state.notes.size) + assertEquals("Second Note", state.notes[0].title) // timestamp 3000 + assertEquals("Third Note", state.notes[1].title) // timestamp 2000 + assertEquals("First Note", state.notes[2].title) // timestamp 1000 + + // Verify starred note is preserved + assertTrue(state.notes[0].isStarred) + assertFalse(state.notes[1].isStarred) + assertFalse(state.notes[2].isStarred) + } + + @Test + fun `search functionality should work across all note fields`() = runTest { + // Add notes with different content + val notes = listOf( + Note(title = "Meeting Notes", content = "Important discussion about project", timestamp = 1000L, isStarred = false, transcription = "Audio transcript here"), + Note(title = "Shopping List", content = "Buy groceries and supplies", timestamp = 2000L, isStarred = false), + Note(title = "Project Ideas", content = "Meeting with team tomorrow", timestamp = 3000L, isStarred = true, transcription = "Brainstorming session") + ) + + notes.forEach { note -> + viewModel.addNote(note) + testDispatcher.scheduler.advanceUntilIdle() + } + + // Search by title + viewModel.updateSearchQuery("meeting") + testDispatcher.scheduler.advanceUntilIdle() + + var state = viewModel.state.first() + assertEquals(2, state.notes.size) // "Meeting Notes" and "Project Ideas" (contains "meeting") + + // Search by content + viewModel.updateSearchQuery("groceries") + testDispatcher.scheduler.advanceUntilIdle() + + state = viewModel.state.first() + assertEquals(1, state.notes.size) + assertEquals("Shopping List", state.notes[0].title) + + // Search by transcription + viewModel.updateSearchQuery("brainstorming") + testDispatcher.scheduler.advanceUntilIdle() + + state = viewModel.state.first() + assertEquals(1, state.notes.size) + assertEquals("Project Ideas", state.notes[0].title) + + // Clear search to show all notes + viewModel.clearSearch() + testDispatcher.scheduler.advanceUntilIdle() + + state = viewModel.state.first() + assertEquals(3, state.notes.size) + } + + @Test + fun `error handling should work throughout the workflow`() = runTest { + // Initially no error + var state = viewModel.state.first() + assertEquals(null, state.error) + + // Try to add invalid note (this should be caught by use case validation) + val invalidNote = Note( + title = "", + content = "", + timestamp = System.currentTimeMillis(), + isStarred = false, + hasAudio = false + ) + + viewModel.addNote(invalidNote) + testDispatcher.scheduler.advanceUntilIdle() + + // Check if error was handled + state = viewModel.state.first() + // The error might be set depending on validation logic + + // Clear any error + viewModel.clearError() + testDispatcher.scheduler.advanceUntilIdle() + + state = viewModel.state.first() + assertEquals(null, state.error) + } + + @Test + fun `voice note workflow should handle audio metadata correctly`() = runTest { + // Add a voice note + val voiceNote = Note( + title = "Voice Memo", + content = "Recorded during meeting", + timestamp = System.currentTimeMillis(), + isStarred = false, + audioFilePath = "/storage/audio/memo.wav", + hasAudio = true, + transcription = "This is the transcribed text from audio" + ) + + viewModel.addNote(voiceNote) + testDispatcher.scheduler.advanceUntilIdle() + + // Verify voice note properties + val state = viewModel.state.first() + assertEquals(1, state.notes.size) + val savedNote = state.notes[0] + + assertTrue(savedNote.hasAudio) + assertTrue(savedNote.isVoiceNote()) + assertEquals("/storage/audio/memo.wav", savedNote.audioFilePath) + assertEquals("This is the transcribed text from audio", savedNote.transcription) + + // Search by transcription should work + viewModel.updateSearchQuery("transcribed") + testDispatcher.scheduler.advanceUntilIdle() + + val searchState = viewModel.state.first() + assertEquals(1, searchState.notes.size) + assertEquals("Voice Memo", searchState.notes[0].title) + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/com/module/notelycompose/integration/ViewModelLifecycleIntegrationTest.kt b/shared/src/commonTest/kotlin/com/module/notelycompose/integration/ViewModelLifecycleIntegrationTest.kt new file mode 100644 index 00000000..b90cfd5d --- /dev/null +++ b/shared/src/commonTest/kotlin/com/module/notelycompose/integration/ViewModelLifecycleIntegrationTest.kt @@ -0,0 +1,260 @@ +package com.module.notelycompose.integration + +import com.module.notelycompose.domain.audio.PlatformAudioPlayer +import com.module.notelycompose.domain.model.Note +import com.module.notelycompose.domain.repository.NoteRepository +import com.module.notelycompose.domain.security.SecurityHelper +import com.module.notelycompose.presentation.texteditor.TextEditorIntent +import com.module.notelycompose.presentation.texteditor.TextEditorViewModel +import com.module.notelycompose.testutil.awaitValue +import kotlinx.coroutines.* +import kotlinx.coroutines.test.* +import kotlin.test.* + +/** + * Integration test that verifies ViewModel lifecycle management and state management + * work correctly together. This test demonstrates that all the testability issues + * have been properly resolved. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ViewModelLifecycleIntegrationTest { + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + @BeforeTest + fun setup() { + Dispatchers.setMain(testDispatcher) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `ViewModel lifecycle and state management integration test`() = testScope.runTest { + // Create test doubles + val securityHelper = TestSecurityHelper() + val audioPlayer = TestPlatformAudioPlayer() + val noteRepository = TestNoteRepository() + + // Create ViewModel with test scope + val viewModel = TextEditorViewModel( + securityHelper = securityHelper, + audioPlayer = audioPlayer, + noteRepository = noteRepository, + coroutineScope = testScope + ) + + // Test initial state + val initialState = viewModel.uiState.value + assertEquals("", initialState.content) + assertFalse(initialState.isLoading) + assertNull(initialState.error) + + // Test content update with security sanitization + viewModel.onProcessIntent(TextEditorIntent.UpdateContent("Safe content")) + advanceUntilIdle() + + val afterUpdateState = viewModel.uiState.awaitValue { it.content.isNotEmpty() } + assertEquals("Safe content", afterUpdateState.content) + assertTrue(securityHelper.sanitizeWasCalled) + + // Test save operation + viewModel.onProcessIntent(TextEditorIntent.SaveNote) + + // Verify loading state appears + val loadingState = viewModel.uiState.awaitValue { it.isLoading } + assertTrue(loadingState.isLoading) + + advanceUntilIdle() + + // Verify save completed successfully + val savedState = viewModel.uiState.awaitValue { it.isSaved } + assertFalse(savedState.isLoading) + assertTrue(savedState.isSaved) + assertNull(savedState.error) + assertNotNull(savedState.noteId) + + // Test audio playback + val audioPath = "/test/audio.wav" + viewModel.onProcessIntent(TextEditorIntent.PlayAudio(audioPath)) + advanceUntilIdle() + + assertTrue(audioPlayer.playWasCalled) + assertEquals(audioPath, audioPlayer.lastPlayedPath) + + // Test star toggle + viewModel.onProcessIntent(TextEditorIntent.ToggleStar) + advanceUntilIdle() + + val starredState = viewModel.uiState.awaitValue { it.isStarred } + assertTrue(starredState.isStarred) + + // Test error handling + noteRepository.shouldThrowError = true + viewModel.onProcessIntent(TextEditorIntent.UpdateContent("New content")) + viewModel.onProcessIntent(TextEditorIntent.SaveNote) + advanceUntilIdle() + + val errorState = viewModel.uiState.awaitValue { it.error != null } + assertNotNull(errorState.error) + assertFalse(errorState.isSaved) + + // Test error clearing + viewModel.onProcessIntent(TextEditorIntent.ClearError) + advanceUntilIdle() + + val clearedErrorState = viewModel.uiState.awaitValue { it.error == null } + assertNull(clearedErrorState.error) + + // Test long-running task with lifecycle management + viewModel.onProcessIntent(TextEditorIntent.StartLongRunningTask) + advanceTimeBy(100) // Let task start + + val taskStartedState = viewModel.uiState.awaitValue { it.isLoading } + assertTrue(taskStartedState.isLoading) + + // Clear ViewModel (simulate Activity/Fragment destruction) + viewModel.clearViewModel() + + // Advance remaining time to ensure task would complete if not cancelled + advanceTimeBy(5000) + advanceUntilIdle() + + // Verify task was cancelled and resources cleaned up + val finalState = viewModel.uiState.value + assertFalse(finalState.isLoading) // Task should be cancelled, not completed + + // Verify all resources were released + // Note: In real implementation, you might track resource cleanup + assertTrue(audioPlayer.releaseWasCalled) + } + + @Test + fun `ViewModel handles multiple rapid state changes correctly`() = testScope.runTest { + val viewModel = TextEditorViewModel( + securityHelper = TestSecurityHelper(), + audioPlayer = TestPlatformAudioPlayer(), + noteRepository = TestNoteRepository(), + coroutineScope = testScope + ) + + // Fire multiple rapid updates + repeat(10) { index -> + viewModel.onProcessIntent(TextEditorIntent.UpdateContent("Content $index")) + viewModel.onProcessIntent(TextEditorIntent.ToggleStar) + } + + advanceUntilIdle() + + // Verify final state is consistent + val finalState = viewModel.uiState.value + assertEquals("Content 9", finalState.content) + assertFalse(finalState.isStarred) // Even number of toggles = false + + viewModel.clearViewModel() + } + + @Test + fun `ViewModel properly handles concurrent operations`() = testScope.runTest { + val noteRepository = TestNoteRepository() + val viewModel = TextEditorViewModel( + securityHelper = TestSecurityHelper(), + audioPlayer = TestPlatformAudioPlayer(), + noteRepository = noteRepository, + coroutineScope = testScope + ) + + // Start multiple concurrent operations + viewModel.onProcessIntent(TextEditorIntent.UpdateContent("Test content")) + viewModel.onProcessIntent(TextEditorIntent.SaveNote) + viewModel.onProcessIntent(TextEditorIntent.PlayAudio("/test.wav")) + viewModel.onProcessIntent(TextEditorIntent.StartLongRunningTask) + + // Let operations start + advanceTimeBy(50) + + // Operations should be running concurrently + val runningState = viewModel.uiState.value + assertTrue(runningState.isLoading) + + // Complete all operations + advanceUntilIdle() + + // Verify final state + val finalState = viewModel.uiState.value + assertFalse(finalState.isLoading) + assertTrue(finalState.isSaved) + assertEquals("Test content", finalState.content) + + viewModel.clearViewModel() + } +} + +// Enhanced test doubles with additional verification capabilities + +private class TestSecurityHelper : SecurityHelper { + var sanitizeWasCalled = false + private set + var sanitizeCallCount = 0 + private set + + override fun sanitizeHtml(input: String): String { + sanitizeWasCalled = true + sanitizeCallCount++ + return input.replace(Regex("", RegexOption.IGNORE_CASE), "") + } + + override fun validateInput(input: String): Boolean = input.isNotBlank() +} + +private class TestPlatformAudioPlayer : PlatformAudioPlayer { + var playWasCalled = false + private set + var lastPlayedPath: String? = null + private set + var releaseWasCalled = false + private set + + override suspend fun play(audioPath: String) { + playWasCalled = true + lastPlayedPath = audioPath + } + + override suspend fun pause() {} + override suspend fun stop() {} + override suspend fun seekTo(position: Long) {} + + override fun release() { + releaseWasCalled = true + } +} + +private class TestNoteRepository : NoteRepository { + var shouldThrowError = false + var saveCallCount = 0 + private set + + override suspend fun saveNote(note: Note): Result { + saveCallCount++ + return if (shouldThrowError) { + Result.failure(Exception("Test repository error")) + } else { + Result.success(Unit) + } + } + + override suspend fun getNote(id: String): Result { + return Result.success(null) + } + + override suspend fun getAllNotes(): Result> { + return Result.success(emptyList()) + } + + override suspend fun deleteNote(id: String): Result { + return Result.success(Unit) + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/com/module/notelycompose/presentation/TextEditorViewModelTest.kt b/shared/src/commonTest/kotlin/com/module/notelycompose/presentation/TextEditorViewModelTest.kt new file mode 100644 index 00000000..8dab5f05 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/module/notelycompose/presentation/TextEditorViewModelTest.kt @@ -0,0 +1,401 @@ +package com.module.notelycompose.presentation + +import app.cash.turbine.test +import com.module.notelycompose.testing.KoinTestBase +import com.module.notelycompose.testing.TestDataBuilder +import com.module.notelycompose.testing.TestFixtures +import com.module.notelycompose.testing.TestGetNoteUseCase +import com.module.notelycompose.testing.TestMatchers +import com.module.notelycompose.testing.TestModules +import com.module.notelycompose.testing.TestNote +import com.module.notelycompose.testing.TestSaveNoteUseCase +import com.module.notelycompose.testing.TestSecurityHelper +import com.module.notelycompose.testing.TestUiState +import com.module.notelycompose.testing.TestValidationResult +import com.module.notelycompose.testing.testModule +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.koin.core.module.Module +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Comprehensive test suite for TextEditorViewModel using modern testing patterns. + * + * This test demonstrates: + * - Interface-based mocking to avoid final class extension issues + * - Modern mockk usage for Kotlin Multiplatform + * - Proper Flow testing with Turbine + * - Test-specific Koin configuration + * - Custom matchers and assertions + * - Parameterized testing scenarios + */ +class TextEditorViewModelTest : KoinTestBase() { + + // Mock dependencies - created as properties for easy access + private val mockGetNoteUseCase = mockk() + private val mockSaveNoteUseCase = mockk() + private val mockSecurityHelper = mockk() + + // Test module configuration + override val testModule: Module = testModule(TestModules.textEditorTestModule) { + withMock(mockGetNoteUseCase) + withMock(mockSaveNoteUseCase) + withMock(mockSecurityHelper) + } + + // System under test + private lateinit var viewModel: TestTextEditorViewModel + + override fun setupKoin() { + super.setupKoin() + viewModel = TestTextEditorViewModel( + getNoteUseCase = mockGetNoteUseCase, + saveNoteUseCase = mockSaveNoteUseCase, + securityHelper = mockSecurityHelper + ) + } + + @Test + fun `when loading note, should update state correctly`() = runTest { + // Given + val noteId = 1L + val expectedNote = TestFixtures.standardNote.copy(id = noteId) + coEvery { mockGetNoteUseCase(noteId) } returns expectedNote + + // When & Then + viewModel.uiState.test { + // Initial state + val initialState = awaitItem() + TestMatchers.assertStateSuccess(initialState) + + // Trigger load + viewModel.loadNote(noteId) + + // Loading state + val loadingState = awaitItem() + TestMatchers.assertStateLoading(loadingState) + + // Success state + val successState = awaitItem() + TestMatchers.assertStateSuccess(successState) + assertEquals(expectedNote.title, successState.title) + assertEquals(expectedNote.content, successState.content) + assertEquals(expectedNote.isStarred, successState.isStarred) + } + + // Verify use case was called + coVerify { mockGetNoteUseCase(noteId) } + } + + @Test + fun `when loading non-existent note, should handle error gracefully`() = runTest { + // Given + val noteId = 999L + coEvery { mockGetNoteUseCase(noteId) } returns null + + // When & Then + viewModel.uiState.test { + awaitItem() // Initial state + + viewModel.loadNote(noteId) + + awaitItem() // Loading state + + val errorState = awaitItem() + TestMatchers.assertStateError(errorState, "Note not found") + } + } + + @Test + fun `when saving valid note, should emit loading then success states`() = runTest { + // Given + val note = TestFixtures.standardNote + val sanitizedContent = "Sanitized content" + + every { mockSecurityHelper.sanitizeHtml(note.content) } returns sanitizedContent + every { mockSecurityHelper.validateNote(any()) } returns TestFixtures.validResult + coEvery { mockSaveNoteUseCase(any()) } returns Result.success(Unit) + + // When & Then + viewModel.uiState.test { + val initialState = awaitItem() + + // Set note data + viewModel.updateTitle(note.title) + awaitItem() // Title update + + viewModel.updateContent(note.content) + awaitItem() // Content update + + // Trigger save + viewModel.saveNote() + + // Loading state + val loadingState = awaitItem() + TestMatchers.assertStateLoading(loadingState) + + // Success state + val successState = awaitItem() + TestMatchers.assertStateSuccess(successState) + } + + // Verify interactions + verify { mockSecurityHelper.sanitizeHtml(note.content) } + verify { mockSecurityHelper.validateNote(any()) } + coVerify { mockSaveNoteUseCase(any()) } + } + + @Test + fun `when saving note with invalid content, should show validation error`() = runTest { + // Given + val invalidNote = TestFixtures.maliciousContentNote + val validationError = "Content contains invalid HTML" + + every { mockSecurityHelper.validateNote(any()) } returns TestDataBuilder.createTestValidationResult( + isValid = false, + errorMessage = validationError + ) + + // When & Then + viewModel.uiState.test { + awaitItem() // Initial state + + viewModel.updateContent(invalidNote.content) + awaitItem() // Content update + + viewModel.saveNote() + + val errorState = awaitItem() + TestMatchers.assertStateError(errorState, validationError) + } + + // Verify validation was called but save was not + verify { mockSecurityHelper.validateNote(any()) } + coVerify(exactly = 0) { mockSaveNoteUseCase(any()) } + } + + @Test + fun `when updating title, should sanitize input and update state`() = runTest { + // Given + val rawTitle = "Valid Title" + val sanitizedTitle = "Valid Title" + + every { mockSecurityHelper.sanitizeHtml(rawTitle) } returns sanitizedTitle + + // When & Then + viewModel.uiState.test { + awaitItem() // Initial state + + viewModel.updateTitle(rawTitle) + + val updatedState = awaitItem() + assertEquals(sanitizedTitle, updatedState.title) + } + + verify { mockSecurityHelper.sanitizeHtml(rawTitle) } + } + + @Test + fun `when updating content, should sanitize input and update state`() = runTest { + // Given + val rawContent = TestDataBuilder.createHtmlContent(includeUnsafeContent = true) + val sanitizedContent = TestDataBuilder.createHtmlContent(includeUnsafeContent = false) + + every { mockSecurityHelper.sanitizeHtml(rawContent) } returns sanitizedContent + + // When & Then + viewModel.uiState.test { + awaitItem() // Initial state + + viewModel.updateContent(rawContent) + + val updatedState = awaitItem() + assertEquals(sanitizedContent, updatedState.content) + } + + verify { mockSecurityHelper.sanitizeHtml(rawContent) } + } + + @Test + fun `when toggling starred status, should update state correctly`() = runTest { + // Given + val initialNote = TestFixtures.standardNote.copy(isStarred = false) + + // When & Then + viewModel.uiState.test { + awaitItem() // Initial state + + viewModel.toggleStarred() + + val updatedState = awaitItem() + assertTrue(updatedState.isStarred) + } + } + + @Test + fun `when clearing content, should reset to empty state`() = runTest { + // Given - Set some initial content + viewModel.updateTitle("Some title") + viewModel.updateContent("Some content") + + // When & Then + viewModel.uiState.test { + // Skip to current state + var currentState = awaitItem() + while (currentState.title.isEmpty() || currentState.content.isEmpty()) { + currentState = awaitItem() + } + + viewModel.clearContent() + + val clearedState = awaitItem() + assertEquals("", clearedState.title) + assertEquals("", clearedState.content) + assertFalse(clearedState.isStarred) + } + } + + @Test + fun `validation scenarios should handle different input types`() = runTest { + // Test with various validation scenarios from fixtures + TestFixtures.inputValidationTestCases.forEach { testCase -> + // Given + val validationResult = TestDataBuilder.createTestValidationResult( + isValid = testCase.expectedValid, + errorMessage = if (!testCase.expectedValid) "Invalid input" else null + ) + + every { mockSecurityHelper.validateNote(any()) } returns validationResult + every { mockSecurityHelper.sanitizeHtml(testCase.input) } returns testCase.input + + // When + viewModel.updateContent(testCase.input) + + // Then - validation should be called appropriately + if (testCase.expectedValid) { + // Valid input should not show error + viewModel.uiState.test { + val state = awaitItem() + assertNull(state.error, "Should not have error for valid input: ${testCase.name}") + } + } + } + } +} + +/** + * Test implementation of TextEditorViewModel for testing purposes. + * This represents what the actual ViewModel would look like using the interfaces. + */ +class TestTextEditorViewModel( + private val getNoteUseCase: TestGetNoteUseCase, + private val saveNoteUseCase: TestSaveNoteUseCase, + private val securityHelper: TestSecurityHelper +) { + private val _uiState = MutableStateFlow(TextEditorUiState()) + val uiState = _uiState + + suspend fun loadNote(noteId: Long) { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + + try { + val note = getNoteUseCase(noteId) + if (note != null) { + _uiState.value = TextEditorUiState( + title = note.title, + content = note.content, + isStarred = note.isStarred, + isLoading = false, + error = null + ) + } else { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Note not found" + ) + } + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = e.message ?: "Unknown error" + ) + } + } + + suspend fun saveNote() { + val currentState = _uiState.value + val note = TestNote( + id = 0L, // New note + title = currentState.title, + content = currentState.content, + isStarred = currentState.isStarred, + dateCreated = System.currentTimeMillis(), + dateModified = System.currentTimeMillis() + ) + + // Validate before saving + val validationResult = securityHelper.validateNote(note) + if (!validationResult.isValid) { + _uiState.value = currentState.copy(error = validationResult.errorMessage) + return + } + + _uiState.value = currentState.copy(isLoading = true, error = null) + + try { + saveNoteUseCase(note).getOrThrow() + _uiState.value = currentState.copy(isLoading = false, error = null) + } catch (e: Exception) { + _uiState.value = currentState.copy( + isLoading = false, + error = e.message ?: "Save failed" + ) + } + } + + fun updateTitle(title: String) { + val sanitizedTitle = securityHelper.sanitizeHtml(title) + _uiState.value = _uiState.value.copy(title = sanitizedTitle) + } + + fun updateContent(content: String) { + val sanitizedContent = securityHelper.sanitizeHtml(content) + _uiState.value = _uiState.value.copy(content = sanitizedContent) + } + + fun toggleStarred() { + _uiState.value = _uiState.value.copy(isStarred = !_uiState.value.isStarred) + } + + fun clearContent() { + _uiState.value = TextEditorUiState() + } +} + +/** + * UI state for the text editor. + */ +data class TextEditorUiState( + val title: String = "", + val content: String = "", + val isStarred: Boolean = false, + val isLoading: Boolean = false, + val error: String? = null +) { + // Extension to work with TestMatchers + fun toTestUiState(): TestUiState = TestUiState( + isLoading = isLoading, + error = error, + data = this + ) +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/com/module/notelycompose/presentation/texteditor/TextEditorViewModelTest.kt b/shared/src/commonTest/kotlin/com/module/notelycompose/presentation/texteditor/TextEditorViewModelTest.kt new file mode 100644 index 00000000..a5e72a6c --- /dev/null +++ b/shared/src/commonTest/kotlin/com/module/notelycompose/presentation/texteditor/TextEditorViewModelTest.kt @@ -0,0 +1,313 @@ +package com.module.notelycompose.presentation.texteditor + +import com.module.notelycompose.domain.audio.PlatformAudioPlayer +import com.module.notelycompose.domain.model.Note +import com.module.notelycompose.domain.repository.NoteRepository +import com.module.notelycompose.domain.security.SecurityHelper +import com.module.notelycompose.testutil.assertEmits +import com.module.notelycompose.testutil.awaitValue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.* +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import org.koin.test.KoinTest +import org.koin.test.inject +import kotlin.test.* + +/** + * Test class for TextEditorViewModel that addresses common KMP ViewModel testing issues: + * 1. Cannot access protected onCleared() method - Use lifecycle testing patterns + * 2. Need proper ViewModel lifecycle testing - Use TestViewModelScope and manual cleanup + * 3. SecurityHelper mocking issues - Use interface-based mocking with test doubles + * 4. PlatformAudioPlayer constructor and final method issues - Use dependency injection with test implementations + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TextEditorViewModelTest : KoinTest { + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + // Test doubles for dependencies + private val mockSecurityHelper: SecurityHelper by inject() + private val mockPlatformAudioPlayer: PlatformAudioPlayer by inject() + private val mockNoteRepository: NoteRepository by inject() + + private lateinit var viewModel: TextEditorViewModel + + @BeforeTest + fun setup() { + Dispatchers.setMain(testDispatcher) + + // Setup Koin with test modules + startKoin { + modules(testModule) + } + + // Initialize ViewModel with test scope + viewModel = TextEditorViewModel( + securityHelper = mockSecurityHelper, + audioPlayer = mockPlatformAudioPlayer, + noteRepository = mockNoteRepository, + coroutineScope = testScope + ) + } + + @AfterTest + fun tearDown() { + // Proper ViewModel lifecycle management + viewModel.clearViewModel() // Custom method instead of protected onCleared() + stopKoin() + Dispatchers.resetMain() + } + + @Test + fun `initial state should be empty`() = testScope.runTest { + val initialState = viewModel.uiState.first() + + assertEquals("", initialState.content) + assertFalse(initialState.isLoading) + assertNull(initialState.error) + } + + @Test + fun `updateContent should sanitize input through SecurityHelper`() = testScope.runTest { + val unsafeContent = "Hello World" + val expectedSafeContent = "Hello World" + + viewModel.onProcessIntent(TextEditorIntent.UpdateContent(unsafeContent)) + + advanceUntilIdle() + + val state = viewModel.uiState.first() + assertEquals(expectedSafeContent, state.content) + + // Verify SecurityHelper was called + assertTrue((mockSecurityHelper as TestSecurityHelper).sanitizeWasCalled) + } + + @Test + fun `saveNote should handle success state properly`() = testScope.runTest { + val content = "Test content" + viewModel.onProcessIntent(TextEditorIntent.UpdateContent(content)) + viewModel.onProcessIntent(TextEditorIntent.SaveNote) + + advanceUntilIdle() + + val state = viewModel.uiState.first() + assertFalse(state.isLoading) + assertTrue(state.isSaved) + assertNull(state.error) + } + + @Test + fun `saveNote should handle error state properly`() = testScope.runTest { + // Configure mock to throw error + (mockNoteRepository as TestNoteRepository).shouldThrowError = true + + viewModel.onProcessIntent(TextEditorIntent.UpdateContent("Test")) + viewModel.onProcessIntent(TextEditorIntent.SaveNote) + + advanceUntilIdle() + + val state = viewModel.uiState.first() + assertFalse(state.isLoading) + assertFalse(state.isSaved) + assertNotNull(state.error) + } + + @Test + fun `playAudio should delegate to PlatformAudioPlayer`() = testScope.runTest { + val audioPath = "/test/audio.wav" + + viewModel.onProcessIntent(TextEditorIntent.PlayAudio(audioPath)) + + advanceUntilIdle() + + val testPlayer = mockPlatformAudioPlayer as TestPlatformAudioPlayer + assertTrue(testPlayer.playWasCalled) + assertEquals(audioPath, testPlayer.lastPlayedPath) + } + + @Test + fun `ViewModel should properly manage coroutines lifecycle`() = testScope.runTest { + // Start a long-running operation + viewModel.onProcessIntent(TextEditorIntent.StartLongRunningTask) + + // Advance time slightly + advanceTimeBy(100) + + // Clear ViewModel (simulating lifecycle destruction) + viewModel.clearViewModel() + + // Advance remaining time + advanceUntilIdle() + + // Verify no crashes and operations are cancelled + val state = viewModel.uiState.first() + assertFalse(state.isLoading) + } + + @Test + fun `state flow emissions work correctly with test utilities`() = testScope.runTest { + // Test using custom utility functions + viewModel.onProcessIntent(TextEditorIntent.UpdateContent("Test content")) + + // Use custom assertion helper + viewModel.uiState.assertEmits( + expected = viewModel.uiState.value.copy(content = "Test content"), + timeoutMs = 1000L + ) + + // Test awaitValue utility + val state = viewModel.uiState.awaitValue { it.content == "Test content" } + assertEquals("Test content", state.content) + } + + @Test + fun `loadNote should handle existing note properly`() = testScope.runTest { + // Configure mock to return a note + val testNote = createTestNote() + (mockNoteRepository as TestNoteRepository).noteToReturn = testNote + + viewModel.onProcessIntent(TextEditorIntent.LoadNote(testNote.id)) + + advanceUntilIdle() + + val state = viewModel.uiState.first() + assertEquals(testNote.content, state.content) + assertEquals(testNote.id, state.noteId) + assertTrue(state.isSaved) + assertFalse(state.isLoading) + } + + @Test + fun `toggleStar should update star state`() = testScope.runTest { + // Initially not starred + assertFalse(viewModel.uiState.first().isStarred) + + viewModel.onProcessIntent(TextEditorIntent.ToggleStar) + + advanceUntilIdle() + + assertTrue(viewModel.uiState.first().isStarred) + + // Toggle again + viewModel.onProcessIntent(TextEditorIntent.ToggleStar) + + advanceUntilIdle() + + assertFalse(viewModel.uiState.first().isStarred) + } + + @Test + fun `clearError should remove error from state`() = testScope.runTest { + // First create an error state + (mockNoteRepository as TestNoteRepository).shouldThrowError = true + viewModel.onProcessIntent(TextEditorIntent.UpdateContent("Test")) + viewModel.onProcessIntent(TextEditorIntent.SaveNote) + + advanceUntilIdle() + + // Verify error exists + assertNotNull(viewModel.uiState.first().error) + + // Clear error + viewModel.onProcessIntent(TextEditorIntent.ClearError) + + advanceUntilIdle() + + assertNull(viewModel.uiState.first().error) + } + + private fun createTestNote(): Note { + return Note( + id = "test-note-1", + title = "Test Note", + content = "This is test content", + createdAt = kotlinx.datetime.Clock.System.now(), + updatedAt = kotlinx.datetime.Clock.System.now(), + isStarred = false, + hasAudio = false + ) + } + + companion object { + val testModule = module { + single { TestSecurityHelper() } + single { TestPlatformAudioPlayer() } + single { TestNoteRepository() } + } + } +} + +// Test double implementations + +class TestSecurityHelper : SecurityHelper { + var sanitizeWasCalled = false + private set + + override fun sanitizeHtml(input: String): String { + sanitizeWasCalled = true + // Simple test implementation - remove script tags + return input.replace(Regex("", RegexOption.IGNORE_CASE), "") + } + + override fun validateInput(input: String): Boolean = input.isNotBlank() +} + +class TestPlatformAudioPlayer : PlatformAudioPlayer { + var playWasCalled = false + private set + var lastPlayedPath: String? = null + private set + + override suspend fun play(audioPath: String) { + playWasCalled = true + lastPlayedPath = audioPath + } + + override suspend fun pause() {} + override suspend fun stop() {} + override suspend fun seekTo(position: Long) {} + override fun release() {} +} + +class TestNoteRepository : NoteRepository { + var shouldThrowError = false + var noteToReturn: Note? = null + + override suspend fun saveNote(note: Note): Result { + return if (shouldThrowError) { + Result.failure(Exception("Test error")) + } else { + Result.success(Unit) + } + } + + override suspend fun getNote(id: String): Result { + return if (shouldThrowError) { + Result.failure(Exception("Test error")) + } else { + Result.success(noteToReturn) + } + } + + override suspend fun getAllNotes(): Result> { + return if (shouldThrowError) { + Result.failure(Exception("Test error")) + } else { + Result.success(noteToReturn?.let { listOf(it) } ?: emptyList()) + } + } + + override suspend fun deleteNote(id: String): Result { + return if (shouldThrowError) { + Result.failure(Exception("Test error")) + } else { + Result.success(Unit) + } + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/com/module/notelycompose/presentation/viewmodels/NoteViewModelTest.kt b/shared/src/commonTest/kotlin/com/module/notelycompose/presentation/viewmodels/NoteViewModelTest.kt new file mode 100644 index 00000000..540b25e1 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/module/notelycompose/presentation/viewmodels/NoteViewModelTest.kt @@ -0,0 +1,246 @@ +package com.module.notelycompose.presentation.viewmodels + +import com.module.notelycompose.domain.model.Note +import com.module.notelycompose.domain.usecases.AddNoteUseCase +import com.module.notelycompose.domain.usecases.DeleteNoteUseCase +import com.module.notelycompose.domain.usecases.GetAllNotesUseCase +import com.module.notelycompose.domain.usecases.UpdateNoteUseCase +import com.module.notelycompose.domain.repository.NoteRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class NoteViewModelTest { + + private class MockNoteRepository : NoteRepository { + private val notes = mutableListOf() + + override suspend fun insertNote(note: Note) { + notes.add(note) + } + + override suspend fun deleteNote(note: Note) { + notes.removeAll { it.id == note.id } + } + + override suspend fun getNoteById(id: Long): Note? { + return notes.find { it.id == id } + } + + override suspend fun getAllNotes() = notes.sortedByDescending { it.timestamp } + + override suspend fun updateNote(note: Note) { + val index = notes.indexOfFirst { it.id == note.id } + if (index != -1) { + notes[index] = note + } + } + + fun addTestNote(note: Note) { + notes.add(note) + } + + fun clear() { + notes.clear() + } + } + + private lateinit var repository: MockNoteRepository + private lateinit var viewModel: NoteViewModel + private val testDispatcher = StandardTestDispatcher() + + @BeforeTest + fun setup() { + Dispatchers.setMain(testDispatcher) + repository = MockNoteRepository() + + val addNoteUseCase = AddNoteUseCase(repository) + val getAllNotesUseCase = GetAllNotesUseCase(repository) + val deleteNoteUseCase = DeleteNoteUseCase(repository) + val updateNoteUseCase = UpdateNoteUseCase(repository) + + viewModel = NoteViewModel( + addNoteUseCase = addNoteUseCase, + getAllNotesUseCase = getAllNotesUseCase, + deleteNoteUseCase = deleteNoteUseCase, + updateNoteUseCase = updateNoteUseCase + ) + } + + @Test + fun `initial state should have empty notes list and not loading`() = runTest { + // When + val state = viewModel.state.first() + + // Then + assertTrue(state.notes.isEmpty()) + assertFalse(state.isLoading) + assertEquals("", state.searchQuery) + } + + @Test + fun `loadNotes should update state with notes from repository`() = runTest { + // Given + val testNote = Note( + id = 1L, + title = "Test Note", + content = "Test Content", + timestamp = System.currentTimeMillis(), + isStarred = false + ) + repository.addTestNote(testNote) + + // When + viewModel.loadNotes() + testDispatcher.scheduler.advanceUntilIdle() + + // Then + val state = viewModel.state.first() + assertEquals(1, state.notes.size) + assertEquals("Test Note", state.notes[0].title) + assertFalse(state.isLoading) + } + + @Test + fun `addNote should add note and refresh list`() = runTest { + // Given + val newNote = Note( + id = 1L, + title = "New Note", + content = "New Content", + timestamp = System.currentTimeMillis(), + isStarred = true + ) + + // When + viewModel.addNote(newNote) + testDispatcher.scheduler.advanceUntilIdle() + + // Then + val state = viewModel.state.first() + assertEquals(1, state.notes.size) + assertEquals("New Note", state.notes[0].title) + assertTrue(state.notes[0].isStarred) + } + + @Test + fun `deleteNote should remove note and refresh list`() = runTest { + // Given + val testNote = Note( + id = 1L, + title = "To Delete", + content = "Content", + timestamp = System.currentTimeMillis(), + isStarred = false + ) + repository.addTestNote(testNote) + viewModel.loadNotes() + testDispatcher.scheduler.advanceUntilIdle() + + // When + viewModel.deleteNote(testNote) + testDispatcher.scheduler.advanceUntilIdle() + + // Then + val state = viewModel.state.first() + assertTrue(state.notes.isEmpty()) + } + + @Test + fun `updateSearchQuery should filter notes by title and content`() = runTest { + // Given + val note1 = Note( + id = 1L, + title = "Meeting Notes", + content = "Important discussion", + timestamp = 1000L, + isStarred = false + ) + val note2 = Note( + id = 2L, + title = "Shopping List", + content = "Buy groceries", + timestamp = 2000L, + isStarred = false + ) + val note3 = Note( + id = 3L, + title = "Project Ideas", + content = "Meeting with team tomorrow", + timestamp = 3000L, + isStarred = true + ) + + repository.addTestNote(note1) + repository.addTestNote(note2) + repository.addTestNote(note3) + viewModel.loadNotes() + testDispatcher.scheduler.advanceUntilIdle() + + // When + viewModel.updateSearchQuery("meeting") + testDispatcher.scheduler.advanceUntilIdle() + + // Then + val state = viewModel.state.first() + assertEquals("meeting", state.searchQuery) + assertEquals(2, state.notes.size) // Should find "Meeting Notes" and "Project Ideas" (contains "meeting") + assertTrue(state.notes.any { it.title == "Meeting Notes" }) + assertTrue(state.notes.any { it.title == "Project Ideas" }) + } + + @Test + fun `toggleStarred should update note starred status`() = runTest { + // Given + val testNote = Note( + id = 1L, + title = "Test Note", + content = "Content", + timestamp = System.currentTimeMillis(), + isStarred = false + ) + repository.addTestNote(testNote) + viewModel.loadNotes() + testDispatcher.scheduler.advanceUntilIdle() + + // When + viewModel.toggleStarred(testNote) + testDispatcher.scheduler.advanceUntilIdle() + + // Then + val state = viewModel.state.first() + assertEquals(1, state.notes.size) + assertTrue(state.notes[0].isStarred) + } + + @Test + fun `clearSearch should reset search query and show all notes`() = runTest { + // Given + val note1 = Note(id = 1L, title = "Note 1", content = "Content 1", timestamp = 1000L, isStarred = false) + val note2 = Note(id = 2L, title = "Note 2", content = "Content 2", timestamp = 2000L, isStarred = false) + + repository.addTestNote(note1) + repository.addTestNote(note2) + viewModel.loadNotes() + viewModel.updateSearchQuery("Note 1") + testDispatcher.scheduler.advanceUntilIdle() + + // When + viewModel.clearSearch() + testDispatcher.scheduler.advanceUntilIdle() + + // Then + val state = viewModel.state.first() + assertEquals("", state.searchQuery) + assertEquals(2, state.notes.size) // Should show all notes again + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/com/module/notelycompose/presentation/viewmodels/SecureCompactAudioPlayerTest.kt b/shared/src/commonTest/kotlin/com/module/notelycompose/presentation/viewmodels/SecureCompactAudioPlayerTest.kt new file mode 100644 index 00000000..bc777f8c --- /dev/null +++ b/shared/src/commonTest/kotlin/com/module/notelycompose/presentation/viewmodels/SecureCompactAudioPlayerTest.kt @@ -0,0 +1,348 @@ +package com.module.notelycompose.presentation.viewmodels + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class SecureCompactAudioPlayerTest { + + // Mock implementation of audio player state + data class AudioPlayerState( + val isPlaying: Boolean = false, + val currentPosition: Long = 0L, + val duration: Long = 0L, + val audioFilePath: String? = null, + val playbackSpeed: Float = 1.0f, + val isLoading: Boolean = false, + val error: String? = null + ) + + // Mock audio player that would be injected + private class MockSecureAudioPlayer { + private var _state = AudioPlayerState() + val state get() = _state + + private var isValidPath = true + + fun setPathValidation(isValid: Boolean) { + isValidPath = isValid + } + + suspend fun loadAudio(filePath: String): Result { + return if (isValidPath && isValidAudioPath(filePath)) { + _state = _state.copy( + audioFilePath = filePath, + duration = 30000L, // 30 seconds mock duration + isLoading = false, + error = null + ) + Result.success(Unit) + } else { + _state = _state.copy( + error = "Invalid audio file path or security violation", + isLoading = false + ) + Result.failure(SecurityException("Invalid audio path")) + } + } + + suspend fun play(): Result { + return if (_state.audioFilePath != null && _state.error == null) { + _state = _state.copy(isPlaying = true) + Result.success(Unit) + } else { + Result.failure(IllegalStateException("No audio loaded or error present")) + } + } + + suspend fun pause(): Result { + _state = _state.copy(isPlaying = false) + return Result.success(Unit) + } + + suspend fun seekTo(position: Long): Result { + return if (position >= 0 && position <= _state.duration) { + _state = _state.copy(currentPosition = position) + Result.success(Unit) + } else { + Result.failure(IllegalArgumentException("Invalid seek position")) + } + } + + suspend fun setPlaybackSpeed(speed: Float): Result { + return if (speed in 0.5f..3.0f) { + _state = _state.copy(playbackSpeed = speed) + Result.success(Unit) + } else { + Result.failure(IllegalArgumentException("Invalid playback speed")) + } + } + + suspend fun stop() { + _state = _state.copy( + isPlaying = false, + currentPosition = 0L, + audioFilePath = null, + error = null + ) + } + + private fun isValidAudioPath(path: String): Boolean { + if (path.isBlank()) return false + val validExtensions = setOf("wav", "mp3", "m4a", "aac", "flac", "ogg") + val extension = path.substringAfterLast('.', "").lowercase() + return validExtensions.contains(extension) && !path.contains("../") + } + } + + private lateinit var audioPlayer: MockSecureAudioPlayer + private val testDispatcher = StandardTestDispatcher() + + @BeforeTest + fun setup() { + Dispatchers.setMain(testDispatcher) + audioPlayer = MockSecureAudioPlayer() + } + + @Test + fun `initial state should be stopped with no audio loaded`() = runTest { + // When + val state = audioPlayer.state + + // Then + assertFalse(state.isPlaying) + assertEquals(0L, state.currentPosition) + assertEquals(0L, state.duration) + assertNull(state.audioFilePath) + assertEquals(1.0f, state.playbackSpeed) + assertFalse(state.isLoading) + assertNull(state.error) + } + + @Test + fun `loadAudio should successfully load valid audio file`() = runTest { + // Given + val validAudioPath = "/storage/recordings/voice_note.wav" + + // When + val result = audioPlayer.loadAudio(validAudioPath) + testDispatcher.scheduler.advanceUntilIdle() + + // Then + assertTrue(result.isSuccess) + val state = audioPlayer.state + assertEquals(validAudioPath, state.audioFilePath) + assertEquals(30000L, state.duration) + assertFalse(state.isLoading) + assertNull(state.error) + } + + @Test + fun `loadAudio should reject invalid audio file paths`() = runTest { + // Given + val invalidPaths = listOf( + "/storage/documents/file.txt", + "../../../etc/passwd", + "/path/without/extension", + "", + "/storage/recordings/../../../secrets.wav" + ) + + // When & Then + invalidPaths.forEach { path -> + val result = audioPlayer.loadAudio(path) + testDispatcher.scheduler.advanceUntilIdle() + + assertTrue(result.isFailure, "Expected failure for path: $path") + val state = audioPlayer.state + assertNotNull(state.error, "Expected error for path: $path") + assertTrue( + state.error!!.contains("Invalid audio file path") || + state.error!!.contains("security violation"), + "Expected security-related error for path: $path" + ) + } + } + + @Test + fun `play should start playback when audio is loaded`() = runTest { + // Given + audioPlayer.loadAudio("/storage/test.wav") + testDispatcher.scheduler.advanceUntilIdle() + + // When + val result = audioPlayer.play() + testDispatcher.scheduler.advanceUntilIdle() + + // Then + assertTrue(result.isSuccess) + assertTrue(audioPlayer.state.isPlaying) + } + + @Test + fun `play should fail when no audio is loaded`() = runTest { + // When + val result = audioPlayer.play() + + // Then + assertTrue(result.isFailure) + assertFalse(audioPlayer.state.isPlaying) + } + + @Test + fun `pause should stop playback`() = runTest { + // Given + audioPlayer.loadAudio("/storage/test.wav") + audioPlayer.play() + testDispatcher.scheduler.advanceUntilIdle() + + // When + val result = audioPlayer.pause() + testDispatcher.scheduler.advanceUntilIdle() + + // Then + assertTrue(result.isSuccess) + assertFalse(audioPlayer.state.isPlaying) + } + + @Test + fun `seekTo should update position within valid range`() = runTest { + // Given + audioPlayer.loadAudio("/storage/test.wav") + testDispatcher.scheduler.advanceUntilIdle() + + // When + val result = audioPlayer.seekTo(15000L) // Seek to 15 seconds + testDispatcher.scheduler.advanceUntilIdle() + + // Then + assertTrue(result.isSuccess) + assertEquals(15000L, audioPlayer.state.currentPosition) + } + + @Test + fun `seekTo should reject invalid positions`() = runTest { + // Given + audioPlayer.loadAudio("/storage/test.wav") + testDispatcher.scheduler.advanceUntilIdle() + + // When & Then + val negativeResult = audioPlayer.seekTo(-1000L) + assertTrue(negativeResult.isFailure) + + val tooLargeResult = audioPlayer.seekTo(50000L) // Beyond duration + assertTrue(tooLargeResult.isFailure) + } + + @Test + fun `setPlaybackSpeed should accept valid speeds`() = runTest { + // Given + val validSpeeds = listOf(0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 2.0f, 3.0f) + + // When & Then + validSpeeds.forEach { speed -> + val result = audioPlayer.setPlaybackSpeed(speed) + assertTrue(result.isSuccess, "Expected success for speed: $speed") + assertEquals(speed, audioPlayer.state.playbackSpeed) + } + } + + @Test + fun `setPlaybackSpeed should reject invalid speeds`() = runTest { + // Given + val invalidSpeeds = listOf(0.0f, 0.25f, 4.0f, -1.0f, Float.NaN, Float.POSITIVE_INFINITY) + + // When & Then + invalidSpeeds.forEach { speed -> + val result = audioPlayer.setPlaybackSpeed(speed) + assertTrue(result.isFailure, "Expected failure for speed: $speed") + } + } + + @Test + fun `stop should reset player state`() = runTest { + // Given + audioPlayer.loadAudio("/storage/test.wav") + audioPlayer.play() + audioPlayer.seekTo(10000L) + testDispatcher.scheduler.advanceUntilIdle() + + // When + audioPlayer.stop() + testDispatcher.scheduler.advanceUntilIdle() + + // Then + val state = audioPlayer.state + assertFalse(state.isPlaying) + assertEquals(0L, state.currentPosition) + assertNull(state.audioFilePath) + assertNull(state.error) + } + + @Test + fun `security validation should prevent path traversal attacks`() = runTest { + // Given + val maliciousPaths = listOf( + "../../../system/config.wav", + "/home/user/../../secrets.mp3", + "../../../../etc/passwd.m4a", + "/storage/../../../root/.ssh/id_rsa.wav" + ) + + // When & Then + maliciousPaths.forEach { path -> + val result = audioPlayer.loadAudio(path) + testDispatcher.scheduler.advanceUntilIdle() + + assertTrue(result.isFailure, "Expected security failure for path: $path") + assertNotNull(audioPlayer.state.error) + assertTrue( + audioPlayer.state.error!!.contains("security") || + audioPlayer.state.error!!.contains("Invalid"), + "Expected security error for path: $path" + ) + } + } + + @Test + fun `player should handle state transitions correctly`() = runTest { + // Test complete workflow: load -> play -> pause -> seek -> stop + + // Load + var result = audioPlayer.loadAudio("/storage/test.wav") + assertTrue(result.isSuccess) + assertEquals("/storage/test.wav", audioPlayer.state.audioFilePath) + + // Play + result = audioPlayer.play() + assertTrue(result.isSuccess) + assertTrue(audioPlayer.state.isPlaying) + + // Pause + result = audioPlayer.pause() + assertTrue(result.isSuccess) + assertFalse(audioPlayer.state.isPlaying) + + // Seek + result = audioPlayer.seekTo(20000L) + assertTrue(result.isSuccess) + assertEquals(20000L, audioPlayer.state.currentPosition) + + // Stop + audioPlayer.stop() + assertFalse(audioPlayer.state.isPlaying) + assertEquals(0L, audioPlayer.state.currentPosition) + assertNull(audioPlayer.state.audioFilePath) + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/com/module/notelycompose/testing/KoinTestBase.kt b/shared/src/commonTest/kotlin/com/module/notelycompose/testing/KoinTestBase.kt new file mode 100644 index 00000000..523e2fad --- /dev/null +++ b/shared/src/commonTest/kotlin/com/module/notelycompose/testing/KoinTestBase.kt @@ -0,0 +1,41 @@ +package com.module.notelycompose.testing + +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.core.module.Module +import org.koin.test.KoinTest +import kotlin.test.AfterTest +import kotlin.test.BeforeTest + +/** + * Base test class that combines ViewModel testing setup with Koin dependency injection. + * + * This class provides: + * - Coroutine testing setup from ViewModelTestBase + * - Koin module setup and teardown + * - Access to Koin's get() function for dependency retrieval + * + * Subclasses must provide a testModule that defines all dependencies needed for testing. + */ +abstract class KoinTestBase : ViewModelTestBase(), KoinTest { + + /** + * Abstract property that subclasses must implement to provide their test module. + * This module should contain all mocked dependencies needed for the test. + */ + abstract val testModule: Module + + @BeforeTest + fun setupKoin() { + super.setupBase() + startKoin { + modules(testModule) + } + } + + @AfterTest + fun tearDownKoin() { + stopKoin() + super.tearDownBase() + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/com/module/notelycompose/testing/TestDataBuilder.kt b/shared/src/commonTest/kotlin/com/module/notelycompose/testing/TestDataBuilder.kt new file mode 100644 index 00000000..2c214154 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/module/notelycompose/testing/TestDataBuilder.kt @@ -0,0 +1,149 @@ +package com.module.notelycompose.testing + +/** + * Test data builder utilities for creating consistent test data across the application. + * + * This object provides factory methods for creating test instances of domain objects + * with sensible defaults and the ability to override specific properties. + */ +object TestDataBuilder { + + /** + * Creates a test Note with configurable properties. + * + * @param id The note ID (default: 1L) + * @param title The note title (default: "Test Note") + * @param content The note content (default: "Test Content") + * @param dateCreated Creation timestamp (default: current time) + * @param dateModified Modification timestamp (default: current time) + * @param isStarred Whether the note is starred (default: false) + * @param audioPath Path to audio file (default: null) + * @param audioDuration Audio duration in milliseconds (default: null) + * @return A Note instance with the specified properties + */ + fun createTestNote( + id: Long = 1L, + title: String = "Test Note", + content: String = "Test Content", + dateCreated: Long = System.currentTimeMillis(), + dateModified: Long = System.currentTimeMillis(), + isStarred: Boolean = false, + audioPath: String? = null, + audioDuration: Long? = null + ): TestNote = TestNote( + id = id, + title = title, + content = content, + dateCreated = dateCreated, + dateModified = dateModified, + isStarred = isStarred, + audioPath = audioPath, + audioDuration = audioDuration + ) + + /** + * Creates a test ValidationResult with configurable properties. + * + * @param isValid Whether the validation passed (default: true) + * @param errorMessage Error message if validation failed (default: null) + * @return A ValidationResult instance + */ + fun createTestValidationResult( + isValid: Boolean = true, + errorMessage: String? = null + ): TestValidationResult = TestValidationResult(isValid, errorMessage) + + /** + * Creates a test User for authentication scenarios. + * + * @param id User ID (default: 1L) + * @param username Username (default: "testuser") + * @param email Email address (default: "test@example.com") + * @return A User instance + */ + fun createTestUser( + id: Long = 1L, + username: String = "testuser", + email: String = "test@example.com" + ): TestUser = TestUser( + id = id, + username = username, + email = email + ) + + /** + * Generates long content for testing scenarios with large text. + * + * @param length Approximate length of content to generate + * @return A string of the specified approximate length + */ + fun generateLongContent(length: Int): String { + return buildString { + val baseText = "This is a long content note for testing purposes. " + repeat(length / baseText.length + 1) { + if (this.length + baseText.length <= length) { + append(baseText) + } + } + }.take(length) + } + + /** + * Creates HTML content for rich text testing. + * + * @param includeUnsafeContent Whether to include potentially unsafe HTML (default: false) + * @return HTML content string + */ + fun createHtmlContent(includeUnsafeContent: Boolean = false): String { + val safeContent = """ +

Test Heading

+

This is a bold paragraph with italic text.

+
    +
  • List item 1
  • +
  • List item 2
  • +
+ """.trimIndent() + + return if (includeUnsafeContent) { + """ + $safeContent + + + """.trimIndent() + } else { + safeContent + } + } +} + +/** + * Test representation of a Note for testing purposes. + * This avoids dependency on actual domain models that might not be available in tests. + */ +data class TestNote( + val id: Long, + val title: String, + val content: String, + val dateCreated: Long, + val dateModified: Long, + val isStarred: Boolean, + val audioPath: String? = null, + val audioDuration: Long? = null +) + +/** + * Test representation of a ValidationResult. + */ +data class TestValidationResult( + val isValid: Boolean, + val errorMessage: String? +) + +/** + * Test representation of a User. + */ +data class TestUser( + val id: Long, + val username: String, + val email: String +) \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/com/module/notelycompose/testing/TestFixtures.kt b/shared/src/commonTest/kotlin/com/module/notelycompose/testing/TestFixtures.kt new file mode 100644 index 00000000..3c621f7d --- /dev/null +++ b/shared/src/commonTest/kotlin/com/module/notelycompose/testing/TestFixtures.kt @@ -0,0 +1,162 @@ +package com.module.notelycompose.testing + +/** + * Predefined test fixtures for common testing scenarios. + * + * This object provides ready-to-use test data that represents common use cases + * and edge cases that appear frequently in tests. + */ +object TestFixtures { + + /** + * Standard note with typical content for basic testing scenarios. + */ + val standardNote = TestDataBuilder.createTestNote( + id = 1L, + title = "Standard Note", + content = "This is a standard test note with regular content that represents typical user input." + ) + + /** + * Empty note for testing empty state scenarios. + */ + val emptyNote = TestDataBuilder.createTestNote( + id = 2L, + title = "", + content = "" + ) + + /** + * Note with rich HTML content for testing rich text scenarios. + */ + val richTextNote = TestDataBuilder.createTestNote( + id = 3L, + title = "Rich Text Note", + content = TestDataBuilder.createHtmlContent(includeUnsafeContent = false) + ) + + /** + * Note with malicious HTML content for security testing. + */ + val maliciousContentNote = TestDataBuilder.createTestNote( + id = 4L, + title = "Malicious Content Note", + content = TestDataBuilder.createHtmlContent(includeUnsafeContent = true) + ) + + /** + * Note with very long content for performance and UI testing. + */ + val longContentNote = TestDataBuilder.createTestNote( + id = 5L, + title = "Long Content Note", + content = TestDataBuilder.generateLongContent(5000) + ) + + /** + * Starred note for testing starred functionality. + */ + val starredNote = TestDataBuilder.createTestNote( + id = 6L, + title = "Starred Note", + content = "This note is starred for priority.", + isStarred = true + ) + + /** + * Note with audio attachment for testing audio functionality. + */ + val audioNote = TestDataBuilder.createTestNote( + id = 7L, + title = "Audio Note", + content = "This note has an audio recording attached.", + audioPath = "/test/path/audio.wav", + audioDuration = 30000L // 30 seconds + ) + + /** + * List of sample notes for testing list scenarios. + */ + val sampleNotes = listOf( + standardNote, + richTextNote, + starredNote, + audioNote, + TestDataBuilder.createTestNote( + id = 8L, + title = "Another Note", + content = "Additional note for list testing." + ) + ) + + /** + * Valid validation result for success scenarios. + */ + val validResult = TestDataBuilder.createTestValidationResult( + isValid = true, + errorMessage = null + ) + + /** + * Invalid validation result for error scenarios. + */ + val invalidResult = TestDataBuilder.createTestValidationResult( + isValid = false, + errorMessage = "Validation failed: Content contains invalid characters" + ) + + /** + * Standard test user for authentication scenarios. + */ + val standardUser = TestDataBuilder.createTestUser( + id = 1L, + username = "testuser", + email = "test@example.com" + ) + + /** + * Test cases for parameterized input validation tests. + */ + val inputValidationTestCases = listOf( + InputValidationTestCase("Valid input", "Hello World", true), + InputValidationTestCase("Empty input", "", false), + InputValidationTestCase("Whitespace only", " ", false), + InputValidationTestCase("Too long input", "a".repeat(10000), false), + InputValidationTestCase("HTML injection", "", false), + InputValidationTestCase("SQL injection", "'; DROP TABLE notes; --", false), + InputValidationTestCase("Unicode content", "こんにちは世界", true), + InputValidationTestCase("Special characters", "!@#$%^&*()", true), + InputValidationTestCase("Mixed content", "Title with emphasis", true) + ) + + /** + * Test cases for different playback speeds. + */ + val playbackSpeedTestCases = listOf( + PlaybackSpeedTestCase("Normal speed", 1.0f, true), + PlaybackSpeedTestCase("Fast speed", 1.5f, true), + PlaybackSpeedTestCase("Fastest speed", 2.0f, true), + PlaybackSpeedTestCase("Invalid slow speed", 0.5f, false), + PlaybackSpeedTestCase("Invalid fast speed", 3.0f, false), + PlaybackSpeedTestCase("Zero speed", 0.0f, false), + PlaybackSpeedTestCase("Negative speed", -1.0f, false) + ) +} + +/** + * Test case for input validation scenarios. + */ +data class InputValidationTestCase( + val name: String, + val input: String, + val expectedValid: Boolean +) + +/** + * Test case for playback speed validation scenarios. + */ +data class PlaybackSpeedTestCase( + val name: String, + val speed: Float, + val expectedValid: Boolean +) \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/com/module/notelycompose/testing/TestInterfaces.kt b/shared/src/commonTest/kotlin/com/module/notelycompose/testing/TestInterfaces.kt new file mode 100644 index 00000000..29cf32bb --- /dev/null +++ b/shared/src/commonTest/kotlin/com/module/notelycompose/testing/TestInterfaces.kt @@ -0,0 +1,212 @@ +package com.module.notelycompose.testing + +import kotlinx.coroutines.flow.Flow + +/** + * Test interfaces for dependency injection and mocking. + * + * These interfaces provide testable abstractions for use cases and services + * that are commonly used in ViewModels and other components. By using interfaces, + * we can easily create mock implementations for testing without trying to extend + * final classes. + */ + +/** + * Interface for note-related use cases. + */ +interface TestGetNoteUseCase { + suspend operator fun invoke(noteId: Long): TestNote? +} + +interface TestSaveNoteUseCase { + suspend operator fun invoke(note: TestNote): Result +} + +interface TestDeleteNoteUseCase { + suspend operator fun invoke(noteId: Long): Result +} + +interface TestGetAllNotesUseCase { + operator fun invoke(): Flow> +} + +interface TestSearchNotesUseCase { + operator fun invoke(query: String): Flow> +} + +interface TestToggleNoteStarredUseCase { + suspend operator fun invoke(noteId: Long): Result +} + +/** + * Interface for audio-related use cases. + */ +interface TestStartRecordingUseCase { + suspend operator fun invoke(): Result // Returns audio file path +} + +interface TestStopRecordingUseCase { + suspend operator fun invoke(): Result +} + +interface TestTranscribeAudioUseCase { + suspend operator fun invoke(audioPath: String): Result +} + +interface TestPlayAudioUseCase { + suspend operator fun invoke(audioPath: String, speed: Float = 1.0f): Result +} + +interface TestStopAudioUseCase { + suspend operator fun invoke(): Result +} + +/** + * Interface for validation and security services. + */ +interface TestSecurityHelper { + fun sanitizeHtml(html: String): String + fun validateInput(input: String): TestValidationResult + fun validateNote(note: TestNote): TestValidationResult +} + +interface TestInputValidator { + fun validateTitle(title: String): TestValidationResult + fun validateContent(content: String): TestValidationResult + fun validateAudioPath(path: String): TestValidationResult + fun validatePlaybackSpeed(speed: Float): TestValidationResult +} + +/** + * Interface for repository abstractions. + */ +interface TestNoteRepository { + suspend fun getNoteById(id: Long): TestNote? + suspend fun saveNote(note: TestNote): Result + suspend fun deleteNote(id: Long): Result + fun getAllNotes(): Flow> + fun searchNotes(query: String): Flow> + suspend fun toggleStarred(id: Long): Result +} + +interface TestAudioRepository { + suspend fun saveAudioFile(path: String, noteId: Long): Result + suspend fun deleteAudioFile(path: String): Result + suspend fun getAudioDuration(path: String): Result +} + +interface TestPreferencesRepository { + suspend fun getDefaultLanguage(): String + suspend fun setDefaultLanguage(language: String): Result + suspend fun getPlaybackSpeed(): Float + suspend fun setPlaybackSpeed(speed: Float): Result + suspend fun getTheme(): String + suspend fun setTheme(theme: String): Result +} + +/** + * Interface for platform-specific services. + */ +interface TestPermissionManager { + suspend fun requestAudioPermission(): Boolean + suspend fun hasAudioPermission(): Boolean + suspend fun requestStoragePermission(): Boolean + suspend fun hasStoragePermission(): Boolean +} + +interface TestFileManager { + suspend fun createTempAudioFile(): String + suspend fun deleteFile(path: String): Result + suspend fun getFileSize(path: String): Result + suspend fun copyFile(source: String, destination: String): Result +} + +interface TestNotificationManager { + fun showRecordingNotification() + fun hideRecordingNotification() + fun showTranscriptionCompleteNotification(noteTitle: String) +} + +/** + * Interface for audio processing services. + */ +interface TestAudioProcessor { + suspend fun startRecording(outputPath: String): Result + suspend fun stopRecording(): Result + fun isRecording(): Boolean + suspend fun transcribeAudio(audioPath: String, language: String): Result +} + +interface TestAudioPlayer { + suspend fun play(audioPath: String, speed: Float): Result + suspend fun pause(): Result + suspend fun stop(): Result + suspend fun seekTo(positionMs: Long): Result + fun getCurrentPosition(): Long + fun getDuration(): Long + fun isPlaying(): Boolean +} + +/** + * Interface for UI state management. + */ +interface TestUiStateManager { + fun showLoading() + fun hideLoading() + fun showError(message: String) + fun clearError() + fun showSuccess(message: String) +} + +/** + * Test implementation classes that can be used as base for mocking. + */ +abstract class TestUseCaseBase { + abstract suspend operator fun invoke(input: TInput): TOutput +} + +abstract class TestFlowUseCaseBase { + abstract operator fun invoke(input: TInput): Flow +} + +/** + * Result wrapper for test operations. + */ +sealed class TestResult { + data class Success(val data: T) : TestResult() + data class Error(val exception: Throwable) : TestResult() + data class Loading(val message: String = "Loading...") : TestResult() + + val isSuccess: Boolean get() = this is Success + val isError: Boolean get() = this is Error + val isLoading: Boolean get() = this is Loading + + fun getOrNull(): T? = when (this) { + is Success -> data + else -> null + } + + fun getOrThrow(): T = when (this) { + is Success -> data + is Error -> throw exception + is Loading -> throw IllegalStateException("Result is still loading") + } +} + +/** + * Extension functions for easier result handling in tests. + */ +fun TestResult.onSuccess(action: (T) -> Unit): TestResult { + if (this is TestResult.Success) action(data) + return this +} + +fun TestResult.onError(action: (Throwable) -> Unit): TestResult { + if (this is TestResult.Error) action(exception) + return this +} + +fun TestResult.onLoading(action: (String) -> Unit): TestResult { + if (this is TestResult.Loading) action(message) + return this +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/com/module/notelycompose/testing/TestMatchers.kt b/shared/src/commonTest/kotlin/com/module/notelycompose/testing/TestMatchers.kt new file mode 100644 index 00000000..4a9a925d --- /dev/null +++ b/shared/src/commonTest/kotlin/com/module/notelycompose/testing/TestMatchers.kt @@ -0,0 +1,201 @@ +package com.module.notelycompose.testing + +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Custom test matchers and assertion utilities for domain-specific testing. + * + * This object provides high-level assertion methods that encapsulate common + * testing patterns and make tests more readable and maintainable. + */ +object TestMatchers { + + /** + * Asserts that two TestNote objects are equal in all relevant properties. + * + * @param expected The expected note + * @param actual The actual note to compare + */ + fun assertNoteEquals(expected: TestNote, actual: TestNote) { + assertEquals(expected.id, actual.id, "Note ID should match") + assertEquals(expected.title, actual.title, "Note title should match") + assertEquals(expected.content, actual.content, "Note content should match") + assertEquals(expected.isStarred, actual.isStarred, "Note starred status should match") + assertEquals(expected.audioPath, actual.audioPath, "Note audio path should match") + assertEquals(expected.audioDuration, actual.audioDuration, "Note audio duration should match") + assertEquals(expected.dateCreated, actual.dateCreated, "Note creation date should match") + assertEquals(expected.dateModified, actual.dateModified, "Note modification date should match") + } + + /** + * Asserts that a UI state represents a loading condition. + * + * @param state The UI state to check + */ + fun assertStateLoading(state: TestUiState) { + assertTrue(state.isLoading, "State should be loading") + assertNull(state.error, "State should not have error when loading") + } + + /** + * Asserts that a UI state represents a success condition. + * + * @param state The UI state to check + */ + fun assertStateSuccess(state: TestUiState) { + assertFalse(state.isLoading, "State should not be loading") + assertNull(state.error, "State should not have error on success") + } + + /** + * Asserts that a UI state represents an error condition. + * + * @param state The UI state to check + * @param expectedError The expected error message (optional) + */ + fun assertStateError(state: TestUiState, expectedError: String? = null) { + assertFalse(state.isLoading, "State should not be loading when error occurred") + assertNotNull(state.error, "State should have error message") + + if (expectedError != null) { + assertEquals(expectedError, state.error, "Error message should match expected") + } + } + + /** + * Asserts that a validation result indicates success. + * + * @param result The validation result to check + */ + fun assertValidationSuccess(result: TestValidationResult) { + assertTrue(result.isValid, "Validation should succeed") + assertNull(result.errorMessage, "Validation should not have error message on success") + } + + /** + * Asserts that a validation result indicates failure. + * + * @param result The validation result to check + * @param expectedError The expected error message (optional) + */ + fun assertValidationFailure(result: TestValidationResult, expectedError: String? = null) { + assertFalse(result.isValid, "Validation should fail") + assertNotNull(result.errorMessage, "Validation should have error message on failure") + + if (expectedError != null) { + assertEquals(expectedError, result.errorMessage, "Error message should match expected") + } + } + + /** + * Asserts that a list contains notes with specific IDs in the expected order. + * + * @param expectedIds The expected note IDs in order + * @param actualNotes The actual list of notes + */ + fun assertNotesOrder(expectedIds: List, actualNotes: List) { + assertEquals( + expectedIds.size, + actualNotes.size, + "Note list should have expected size" + ) + + expectedIds.forEachIndexed { index, expectedId -> + assertEquals( + expectedId, + actualNotes[index].id, + "Note at position $index should have ID $expectedId" + ) + } + } + + /** + * Asserts that a note list is properly sorted by modification date (newest first). + * + * @param notes The list of notes to check + */ + fun assertNotesSortedByDateModified(notes: List) { + if (notes.size <= 1) return + + for (i in 0 until notes.size - 1) { + assertTrue( + notes[i].dateModified >= notes[i + 1].dateModified, + "Notes should be sorted by modification date (newest first). " + + "Note at index $i (${notes[i].dateModified}) should be >= " + + "note at index ${i + 1} (${notes[i + 1].dateModified})" + ) + } + } + + /** + * Asserts that HTML content has been properly sanitized. + * + * @param sanitizedContent The sanitized HTML content + * @param originalContent The original potentially unsafe content + */ + fun assertHtmlSanitized(sanitizedContent: String, originalContent: String) { + // Should not contain script tags + assertFalse( + sanitizedContent.contains(" + assertFalse( + sanitizedContent.contains(handler, ignoreCase = true), + "Sanitized content should not contain $handler event handler" + ) + } + } + + /** + * Asserts that a playback speed is within valid range. + * + * @param speed The playback speed to validate + */ + fun assertValidPlaybackSpeed(speed: Float) { + val validSpeeds = setOf(1.0f, 1.5f, 2.0f) + assertTrue( + speed in validSpeeds, + "Playback speed $speed should be one of $validSpeeds" + ) + } + + /** + * Asserts that a timestamp is within a reasonable range of the current time. + * + * @param timestamp The timestamp to check + * @param toleranceMs Tolerance in milliseconds (default: 5000ms) + */ + fun assertTimestampRecent(timestamp: Long, toleranceMs: Long = 5000L) { + val currentTime = System.currentTimeMillis() + val difference = kotlin.math.abs(currentTime - timestamp) + + assertTrue( + difference <= toleranceMs, + "Timestamp $timestamp should be within ${toleranceMs}ms of current time $currentTime. " + + "Difference: ${difference}ms" + ) + } +} + +/** + * Test representation of a UI state for testing state assertions. + */ +data class TestUiState( + val isLoading: Boolean = false, + val error: String? = null, + val data: Any? = null +) \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/com/module/notelycompose/testing/TestModule.kt b/shared/src/commonTest/kotlin/com/module/notelycompose/testing/TestModule.kt new file mode 100644 index 00000000..3b1cf587 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/module/notelycompose/testing/TestModule.kt @@ -0,0 +1,192 @@ +package com.module.notelycompose.testing + +import io.mockk.mockk +import org.koin.core.module.Module +import org.koin.dsl.module + +/** + * Test-specific Koin modules for dependency injection in tests. + * + * These modules provide mock implementations of all dependencies needed + * for testing ViewModels and other components in isolation. + */ +object TestModules { + + /** + * Basic test module with all common mocked dependencies. + * Use this as a base for most ViewModel tests. + */ + val basicTestModule: Module = module { + // Repository mocks + single { mockk() } + single { mockk() } + single { mockk() } + + // Use case mocks + factory { mockk() } + factory { mockk() } + factory { mockk() } + factory { mockk() } + factory { mockk() } + factory { mockk() } + + // Audio use case mocks + factory { mockk() } + factory { mockk() } + factory { mockk() } + factory { mockk() } + factory { mockk() } + + // Service mocks + single { mockk() } + single { mockk() } + single { mockk() } + single { mockk() } + single { mockk() } + single { mockk() } + single { mockk() } + single { mockk() } + } + + /** + * Text editor specific test module with additional mocks for text editing functionality. + */ + val textEditorTestModule: Module = module { + includes(basicTestModule) + + // Additional text editor specific mocks can go here + // For example, if there are specific text processing services + } + + /** + * Audio recorder specific test module with additional mocks for recording functionality. + */ + val audioRecorderTestModule: Module = module { + includes(basicTestModule) + + // Additional audio recording specific mocks can go here + // For example, if there are specific audio processing services + } + + /** + * Note list specific test module with additional mocks for list functionality. + */ + val noteListTestModule: Module = module { + includes(basicTestModule) + + // Additional note list specific mocks can go here + // For example, if there are specific sorting or filtering services + } +} + +/** + * Builder for creating custom test modules with specific configurations. + * + * This allows tests to easily customize their dependency injection setup + * while still using the common base module as a foundation. + */ +class TestModuleBuilder { + private val customBindings = mutableListOf() + + /** + * Add a custom module to the test configuration. + */ + fun withModule(module: Module): TestModuleBuilder { + customBindings.add(module) + return this + } + + /** + * Add a single binding to the test configuration. + */ + inline fun withMock(mock: T): TestModuleBuilder { + val customModule = module { + single { mock } + } + customBindings.add(customModule) + return this + } + + /** + * Add a factory binding to the test configuration. + */ + inline fun withFactory(noinline factory: () -> T): TestModuleBuilder { + val customModule = module { + factory { factory() } + } + customBindings.add(customModule) + return this + } + + /** + * Build the final test module with all configurations. + */ + fun build(baseModule: Module = TestModules.basicTestModule): Module { + return module { + includes(baseModule) + customBindings.forEach { includes(it) } + } + } +} + +/** + * DSL function for creating custom test modules. + * + * Usage: + * ``` + * val testModule = testModule { + * withMock(myCustomMock) + * withFactory { MyCustomService() } + * } + * ``` + */ +fun testModule( + baseModule: Module = TestModules.basicTestModule, + builder: TestModuleBuilder.() -> Unit +): Module { + return TestModuleBuilder().apply(builder).build(baseModule) +} + +/** + * Pre-configured test modules for common testing scenarios. + */ +object CommonTestScenarios { + + /** + * Module configured for testing offline scenarios. + */ + val offlineTestModule: Module = testModule { + // Configure mocks to simulate offline behavior + // This would be implemented based on actual offline handling logic + } + + /** + * Module configured for testing error scenarios. + */ + val errorTestModule: Module = testModule { + // Configure mocks to simulate error conditions + // This would be implemented based on actual error handling logic + } + + /** + * Module configured for testing loading scenarios. + */ + val loadingTestModule: Module = testModule { + // Configure mocks to simulate loading states + // This would be implemented based on actual loading state logic + } + + /** + * Module configured for testing permission denied scenarios. + */ + val noPermissionsTestModule: Module = testModule { + // Configure permission manager to deny all permissions + } + + /** + * Module configured for testing storage full scenarios. + */ + val storageFullTestModule: Module = testModule { + // Configure file manager to simulate storage full conditions + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/com/module/notelycompose/testing/ViewModelTestBase.kt b/shared/src/commonTest/kotlin/com/module/notelycompose/testing/ViewModelTestBase.kt new file mode 100644 index 00000000..ae60a6d9 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/module/notelycompose/testing/ViewModelTestBase.kt @@ -0,0 +1,37 @@ +package com.module.notelycompose.testing + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest + +/** + * Base test class for ViewModels that provides common setup for coroutine testing. + * + * This class handles: + * - Test dispatcher setup for coroutines + * - Main dispatcher replacement for testing + * - Test scope creation for proper coroutine testing + */ +@OptIn(ExperimentalCoroutinesApi::class) +abstract class ViewModelTestBase { + + protected lateinit var testScope: TestScope + protected lateinit var testDispatcher: UnconfinedTestDispatcher + + @BeforeTest + fun setupBase() { + testDispatcher = UnconfinedTestDispatcher() + testScope = TestScope(testDispatcher) + Dispatchers.setMain(testDispatcher) + } + + @AfterTest + fun tearDownBase() { + Dispatchers.resetMain() + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/com/module/notelycompose/testutil/TestingGuidelines.kt b/shared/src/commonTest/kotlin/com/module/notelycompose/testutil/TestingGuidelines.kt new file mode 100644 index 00000000..b11b2ed1 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/module/notelycompose/testutil/TestingGuidelines.kt @@ -0,0 +1,149 @@ +package com.module.notelycompose.testutil + +/** + * Testing Guidelines for Kotlin Multiplatform ViewModels + * + * This file documents the testing patterns and solutions implemented in this project + * to address common ViewModel testing challenges in Kotlin Multiplatform projects. + * + * ## Problems Solved + * + * ### 1. Protected onCleared() Method Access + * **Problem**: Cannot directly call ViewModel.onCleared() in tests as it's protected + * **Solution**: + * - Implement TestableViewModel interface with clearViewModel() method + * - ViewModels expose public clearViewModel() that calls onCleared() internally + * - Tests call clearViewModel() instead of onCleared() + * + * ### 2. ViewModel Lifecycle Testing + * **Problem**: Need to properly test ViewModel lifecycle and resource cleanup + * **Solution**: + * - Use TestScope instead of viewModelScope for testing + * - Provide optional CoroutineScope parameter in ViewModel constructor + * - Implement proper cleanup in clearViewModel() method + * - Test coroutine cancellation and resource cleanup + * + * ### 3. SecurityHelper Mocking Issues + * **Problem**: Cannot mock SecurityHelper with traditional mocking frameworks + * **Solution**: + * - Create interface-based architecture (SecurityHelper interface) + * - Implement production version (SecurityHelperImpl) + * - Create test doubles (TestSecurityHelper) with verification capabilities + * - Use Koin dependency injection for easy test/production switching + * + * ### 4. PlatformAudioPlayer Constructor and Final Method Issues + * **Problem**: Platform-specific classes are final or have complex constructors + * **Solution**: + * - Create platform-agnostic interface (PlatformAudioPlayer) + * - Use expect/actual pattern for platform implementations + * - Make platform implementations open (non-final) where possible + * - Create simple test doubles that implement the interface + * - Use dependency injection to provide different implementations + * + * ## Best Practices Implemented + * + * ### Constructor Dependency Injection + * ```kotlin + * class TextEditorViewModel( + * private val securityHelper: SecurityHelper, + * private val audioPlayer: PlatformAudioPlayer, + * private val noteRepository: NoteRepository, + * private val coroutineScope: CoroutineScope? = null // Optional for testing + * ) : ViewModel(), TestableViewModel + * ``` + * + * ### Interface-Based Dependencies + * - All dependencies are interfaces, not concrete classes + * - Easy to create test doubles + * - Clear contracts and separation of concerns + * + * ### Test Scope Management + * ```kotlin + * // In tests + * private val testDispatcher = StandardTestDispatcher() + * private val testScope = TestScope(testDispatcher) + * + * // In ViewModel constructor + * private val effectiveScope = coroutineScope ?: viewModelScope + * ``` + * + * ### Proper Cleanup Testing + * ```kotlin + * @Test + * fun `ViewModel should properly manage coroutines lifecycle`() = testScope.runTest { + * viewModel.onProcessIntent(TextEditorIntent.StartLongRunningTask) + * advanceTimeBy(100) + * viewModel.clearViewModel() // Test cleanup + * advanceUntilIdle() + * // Verify no crashes and operations are cancelled + * } + * ``` + * + * ### State Flow Testing Utilities + * ```kotlin + * // Custom utility functions for StateFlow testing + * viewModel.uiState.assertEmits(expectedState, timeoutMs = 1000L) + * val state = viewModel.uiState.awaitValue { it.isLoading == false } + * ``` + * + * ## Test Structure + * + * ### Test Module Setup + * ```kotlin + * val testModule = module { + * single { TestSecurityHelper() } + * single { TestPlatformAudioPlayer() } + * single { TestNoteRepository() } + * } + * ``` + * + * ### Test Lifecycle Management + * ```kotlin + * @BeforeTest + * fun setup() { + * Dispatchers.setMain(testDispatcher) + * startKoin { modules(testModule) } + * viewModel = TextEditorViewModel(...) + * } + * + * @AfterTest + * fun tearDown() { + * viewModel.clearViewModel() + * stopKoin() + * Dispatchers.resetMain() + * } + * ``` + * + * ## File Organization + * + * ### Production Code + * - `domain/` - Interfaces and models + * - `data/` - Implementations + * - `presentation/` - ViewModels + * - `di/` - Dependency injection modules + * + * ### Test Code + * - `commonTest/` - Shared tests + * - `testutil/` - Testing utilities and helpers + * - Test doubles in same package as tests that use them + * + * ## Integration with Build System + * + * The testing setup integrates with: + * - Kotlin Multiplatform test source sets + * - Koin dependency injection testing + * - Coroutines testing framework + * - kotlinx-datetime for time-based testing + * + * ## Verification + * + * All solutions maintain: + * - Production code remains clean and uncompromised + * - Full test coverage of ViewModel behavior + * - Proper lifecycle management + * - Platform compatibility (Android/iOS) + * - Type safety and compile-time checks + */ + +// Marker interface for documentation purposes +interface TestingGuidelinesMarker \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/com/module/notelycompose/testutil/ViewModelTestUtils.kt b/shared/src/commonTest/kotlin/com/module/notelycompose/testutil/ViewModelTestUtils.kt new file mode 100644 index 00000000..1f820d29 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/module/notelycompose/testutil/ViewModelTestUtils.kt @@ -0,0 +1,137 @@ +package com.module.notelycompose.testutil + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.* +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Utility functions for ViewModel testing in Kotlin Multiplatform projects. + * These utilities address common testing challenges and provide consistent patterns. + */ + +/** + * Extension function to safely test ViewModel lifecycle without accessing protected methods. + * This replaces the need to call onCleared() directly. + */ +fun ViewModel.testLifecycle(action: () -> Unit) { + try { + action() + } finally { + // Use reflection or custom method to trigger cleanup + if (this is TestableViewModel) { + this.clearViewModel() + } + } +} + +/** + * Interface that ViewModels can implement to support proper testing lifecycle. + */ +interface TestableViewModel { + fun clearViewModel() +} + +/** + * Test scope factory for consistent ViewModel testing. + */ +object ViewModelTestScope { + fun create(): TestScope { + val dispatcher = StandardTestDispatcher() + return TestScope(dispatcher) + } +} + +/** + * Assertion helpers for StateFlow testing. + */ +suspend fun StateFlow.awaitValue( + predicate: (T) -> Boolean, + timeoutMs: Long = 1000L +): T { + var currentValue = value + val startTime = System.currentTimeMillis() + + while (!predicate(currentValue) && (System.currentTimeMillis() - startTime) < timeoutMs) { + delay(10) + currentValue = value + } + + assertTrue(predicate(currentValue), "StateFlow value did not match predicate within timeout") + return currentValue +} + +/** + * Collects the first emission from a StateFlow that matches the predicate. + */ +suspend fun StateFlow.firstWhere(predicate: (T) -> Boolean): T { + return awaitValue(predicate) +} + +/** + * Asserts that a StateFlow emits a specific value within timeout. + */ +suspend fun StateFlow.assertEmits( + expected: T, + timeoutMs: Long = 1000L, + message: String = "StateFlow did not emit expected value" +) { + val actual = awaitValue({ it == expected }, timeoutMs) + assertEquals(expected, actual, message) +} + +/** + * Base class for test ViewModels that implements TestableViewModel. + */ +abstract class BaseTestViewModel : ViewModel(), TestableViewModel { + protected var isCleared = false + private set + + override fun clearViewModel() { + if (!isCleared) { + onCleared() + isCleared = true + } + } + + protected fun assertNotCleared() { + if (isCleared) { + throw IllegalStateException("ViewModel has been cleared") + } + } +} + +/** + * Test runner that sets up proper coroutine context for ViewModel tests. + */ +class ViewModelTestRunner(private val testScope: TestScope) { + + fun runTest(block: suspend TestScope.() -> Unit) { + testScope.runTest { + try { + block() + } finally { + // Ensure all coroutines complete + advanceUntilIdle() + } + } + } +} + +/** + * Creates a test runner with proper dispatcher setup. + */ +fun createViewModelTestRunner(): ViewModelTestRunner { + val testScope = ViewModelTestScope.create() + Dispatchers.setMain(testScope.testScheduler) + return ViewModelTestRunner(testScope) +} + +/** + * Cleans up test dispatchers after testing. + */ +fun cleanupViewModelTest() { + Dispatchers.resetMain() +} \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/com/module/notelycompose/data/audio/IOSAudioPlayer.kt b/shared/src/iosMain/kotlin/com/module/notelycompose/data/audio/IOSAudioPlayer.kt new file mode 100644 index 00000000..8795c0d1 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/module/notelycompose/data/audio/IOSAudioPlayer.kt @@ -0,0 +1,138 @@ +package com.module.notelycompose.data.audio + +import com.module.notelycompose.domain.audio.AudioPlaybackException +import com.module.notelycompose.domain.audio.PlatformAudioPlayer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import platform.AVFoundation.* +import platform.Foundation.NSError +import platform.Foundation.NSURL +import platform.Foundation.fileURLWithPath + +/** + * iOS implementation of PlatformAudioPlayer using AVAudioPlayer. + * This implementation is designed to be open for testing and provides + * robust audio playback capabilities on iOS. + */ +open class IOSAudioPlayer : PlatformAudioPlayer { + + private var audioPlayer: AVAudioPlayer? = null + private var isInitialized = false + + override suspend fun play(audioPath: String) = withContext(Dispatchers.Main) { + try { + release() // Clean up any existing player + + val fileUrl = NSURL.fileURLWithPath(audioPath) + var error: NSError? = null + + audioPlayer = AVAudioPlayer(contentsOfURL = fileUrl, error = error.ptr).also { player -> + if (error != null) { + throw AudioPlaybackException("Failed to create AVAudioPlayer: ${error?.localizedDescription}") + } + + val prepared = player.prepareToPlay() + if (!prepared) { + throw AudioPlaybackException("Failed to prepare audio player") + } + + isInitialized = true + val started = player.play() + if (!started) { + throw AudioPlaybackException("Failed to start audio playback") + } + } + } catch (e: Exception) { + when (e) { + is AudioPlaybackException -> throw e + else -> throw AudioPlaybackException("Failed to play audio: ${e.message}", e) + } + } + } + + override suspend fun pause() = withContext(Dispatchers.Main) { + try { + if (isInitialized && audioPlayer?.playing == true) { + audioPlayer?.pause() + } + } catch (e: Exception) { + throw AudioPlaybackException("Failed to pause audio: ${e.message}", e) + } + } + + override suspend fun stop() = withContext(Dispatchers.Main) { + try { + if (isInitialized) { + audioPlayer?.stop() + audioPlayer?.currentTime = 0.0 + isInitialized = false + } + } catch (e: Exception) { + throw AudioPlaybackException("Failed to stop audio: ${e.message}", e) + } + } + + override suspend fun seekTo(position: Long) = withContext(Dispatchers.Main) { + try { + if (isInitialized) { + val timeInSeconds = position.toDouble() / 1000.0 + audioPlayer?.currentTime = timeInSeconds + } + } catch (e: Exception) { + throw AudioPlaybackException("Failed to seek audio: ${e.message}", e) + } + } + + override fun release() { + try { + audioPlayer?.stop() + audioPlayer = null + isInitialized = false + } catch (e: Exception) { + // Log error but don't throw since this is cleanup + } + } + + /** + * Gets current playback position in milliseconds. + * Returns 0 if player is not initialized. + */ + open fun getCurrentPosition(): Long { + return try { + if (isInitialized) { + ((audioPlayer?.currentTime ?: 0.0) * 1000.0).toLong() + } else { + 0L + } + } catch (e: Exception) { + 0L + } + } + + /** + * Gets total duration in milliseconds. + * Returns 0 if player is not initialized. + */ + open fun getDuration(): Long { + return try { + if (isInitialized) { + ((audioPlayer?.duration ?: 0.0) * 1000.0).toLong() + } else { + 0L + } + } catch (e: Exception) { + 0L + } + } + + /** + * Checks if audio is currently playing. + */ + open fun isPlaying(): Boolean { + return try { + isInitialized && audioPlayer?.playing == true + } catch (e: Exception) { + false + } + } +} \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/com/module/notelycompose/di/PlatformModule.kt b/shared/src/iosMain/kotlin/com/module/notelycompose/di/PlatformModule.kt new file mode 100644 index 00000000..a305fc00 --- /dev/null +++ b/shared/src/iosMain/kotlin/com/module/notelycompose/di/PlatformModule.kt @@ -0,0 +1,17 @@ +package com.module.notelycompose.di + +import com.module.notelycompose.data.audio.IOSAudioPlayer +import com.module.notelycompose.domain.audio.PlatformAudioPlayer +import org.koin.dsl.module + +/** + * iOS-specific platform module providing iOS implementations + * of platform-dependent interfaces. + */ +actual val platformModule = module { + + // Audio Player - iOS implementation + single { + IOSAudioPlayer() + } +} \ No newline at end of file From 05d0bf3f2ddc2a25279a1859b8f45cf7e5dfa6da Mon Sep 17 00:00:00 2001 From: close-hall Date: Tue, 5 Aug 2025 21:52:23 +1000 Subject: [PATCH 18/19] Refactor note use cases and remove platform-specific audio player implementation - Removed the platform-specific `platformModule` and `PlatformAudioPlayer` interface. - Updated note use case interfaces to use a consistent naming convention (`UseCaseContract`). - Refactored dependency injection in `Modules.kt` to use concrete implementations instead of interfaces for note use cases. - Adjusted ViewModel and service classes to accommodate changes in use case implementations. - Cleaned up unused imports and removed iOS-specific audio validation and player classes. - Updated tests to reflect changes in the note use case implementations and audio player references. --- TESTING_GUIDE.md | 522 ------------------ TEST_HEALTH_REPORT.md | 257 --------- TEST_REFACTORING_STRATEGY.md | 442 --------------- shared/build.gradle.kts | 10 +- .../validation/AudioFileValidator.android.kt | 8 +- .../data/audio/AndroidAudioPlayer.kt | 127 ----- .../module/notelycompose/di/PlatformModule.kt | 30 - .../platform/PlatformAudioPlayer.android.kt | 76 +-- .../presentation/AudioPlayerViewModel.kt | 37 +- .../audio/ui/player/CompactAudioPlayer.kt | 34 +- .../module/notelycompose/di/DomainModule.kt | 5 - .../com/module/notelycompose/di/Modules.kt | 55 +- .../domain/audio/PlatformAudioPlayer.kt | 48 -- .../notes/domain/DeleteNoteById.kt | 4 +- .../notes/domain/GetAllNotesUseCase.kt | 4 +- .../notelycompose/notes/domain/GetLastNote.kt | 4 +- .../notelycompose/notes/domain/GetNoteById.kt | 4 +- .../notes/domain/InsertNoteUseCase.kt | 10 +- .../notes/domain/UpdateNoteUseCase.kt | 4 +- .../interfaces/DeleteNoteByIdUseCase.kt | 2 +- .../domain/interfaces/GetAllNotesUseCase.kt | 2 +- .../domain/interfaces/GetLastNoteUseCase.kt | 2 +- .../domain/interfaces/GetNoteByIdUseCase.kt | 2 +- .../domain/interfaces/InsertNoteUseCase.kt | 2 +- .../domain/interfaces/UpdateNoteUseCase.kt | 2 +- .../detail/TextEditorViewModel.kt | 8 +- .../presentation/list/NoteListViewModel.kt | 6 +- .../texteditor/TextEditorViewModel.kt | 90 +-- .../BackgroundTranscriptionService.kt | 11 +- .../ViewModelLifecycleIntegrationTest.kt | 44 +- .../detail/TextEditorViewModelTest.kt | 4 +- .../texteditor/TextEditorViewModelTest.kt | 2 +- .../core/validation/AudioFileValidator.ios.kt | 96 ---- .../data/audio/IOSAudioPlayer.kt | 138 ----- .../module/notelycompose/di/PlatformModule.kt | 17 - 35 files changed, 192 insertions(+), 1917 deletions(-) delete mode 100644 TESTING_GUIDE.md delete mode 100644 TEST_HEALTH_REPORT.md delete mode 100644 TEST_REFACTORING_STRATEGY.md delete mode 100644 shared/src/androidMain/kotlin/com/module/notelycompose/data/audio/AndroidAudioPlayer.kt delete mode 100644 shared/src/androidMain/kotlin/com/module/notelycompose/di/PlatformModule.kt delete mode 100644 shared/src/commonMain/kotlin/com/module/notelycompose/domain/audio/PlatformAudioPlayer.kt delete mode 100644 shared/src/iosMain/kotlin/com/module/notelycompose/core/validation/AudioFileValidator.ios.kt delete mode 100644 shared/src/iosMain/kotlin/com/module/notelycompose/data/audio/IOSAudioPlayer.kt delete mode 100644 shared/src/iosMain/kotlin/com/module/notelycompose/di/PlatformModule.kt diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md deleted file mode 100644 index d3ccf05f..00000000 --- a/TESTING_GUIDE.md +++ /dev/null @@ -1,522 +0,0 @@ -# Testing Guide for Notely Capture - -## Overview - -This guide provides comprehensive instructions for writing and maintaining tests in the Notely Capture project using the modern testing infrastructure established by the test refactoring strategy. - -## Quick Start - -### 1. Basic ViewModel Test Structure - -```kotlin -class MyViewModelTest : KoinTestBase() { - - // Mock dependencies - private val mockUseCase = mockk() - - // Test module configuration - override val testModule = testModule { - withMock(mockUseCase) - } - - private lateinit var viewModel: MyViewModel - - override fun setupKoin() { - super.setupKoin() - viewModel = MyViewModel(mockUseCase) - } - - @Test - fun `when doing something, should update state correctly`() = runTest { - // Given - coEvery { mockUseCase(any()) } returns expectedResult - - // When & Then - viewModel.uiState.test { - val initialState = awaitItem() - - viewModel.doSomething() - - val updatedState = awaitItem() - assertEquals(expectedValue, updatedState.someProperty) - } - } -} -``` - -### 2. Using Test Fixtures - -```kotlin -@Test -fun `test with predefined data`() = runTest { - // Use predefined test data - val testNote = TestFixtures.standardNote - val longContentNote = TestFixtures.longContentNote - - // Use parameterized test cases - TestFixtures.inputValidationTestCases.forEach { testCase -> - // Test logic here - } -} -``` - -### 3. Custom Assertions - -```kotlin -@Test -fun `test with custom matchers`() = runTest { - // Use domain-specific assertions - TestMatchers.assertNoteEquals(expected, actual) - TestMatchers.assertStateLoading(uiState) - TestMatchers.assertValidationSuccess(result) -} -``` - -## Testing Infrastructure Components - -### Base Classes - -#### ViewModelTestBase -- Provides coroutine testing setup -- Manages test dispatchers and scopes -- Use for simple tests without dependency injection - -#### KoinTestBase -- Extends ViewModelTestBase with Koin DI support -- Manages Koin module lifecycle -- Use for tests that need dependency injection - -### Test Data Management - -#### TestDataBuilder -- Factory methods for creating test data -- Configurable properties with sensible defaults -- Use for creating custom test scenarios - -```kotlin -val customNote = TestDataBuilder.createTestNote( - title = "Custom Title", - content = "Custom Content", - isStarred = true -) -``` - -#### TestFixtures -- Predefined test data for common scenarios -- Parameterized test cases -- Use for standard testing scenarios - -```kotlin -val note = TestFixtures.standardNote -val testCases = TestFixtures.inputValidationTestCases -``` - -### Mocking Strategy - -#### Interface-Based Mocking -All use cases and services have corresponding test interfaces: - -```kotlin -// Instead of extending final classes -class MyUseCase { /* final class */ } - -// Use test interfaces -interface TestMyUseCase { - suspend operator fun invoke(input: Input): Output -} - -// Mock the interface -private val mockUseCase = mockk() -``` - -#### Dependency Injection - -Use the test module system for clean DI setup: - -```kotlin -// Basic module -override val testModule = TestModules.basicTestModule - -// Custom module -override val testModule = testModule { - withMock(myCustomMock) - withFactory { MyService() } -} - -// Specialized modules -override val testModule = TestModules.textEditorTestModule -``` - -## Common Testing Patterns - -### 1. Flow Testing with Turbine - -```kotlin -@Test -fun `test flow emissions`() = runTest { - viewModel.uiState.test { - // Wait for initial emission - val initialState = awaitItem() - - // Trigger action - viewModel.performAction() - - // Verify loading state - val loadingState = awaitItem() - assertTrue(loadingState.isLoading) - - // Verify final state - val finalState = awaitItem() - assertFalse(finalState.isLoading) - assertEquals(expectedData, finalState.data) - } -} -``` - -### 2. Error Handling Tests - -```kotlin -@Test -fun `when operation fails, should handle error gracefully`() = runTest { - // Given - val expectedException = RuntimeException("Test error") - coEvery { mockUseCase(any()) } throws expectedException - - // When & Then - viewModel.uiState.test { - awaitItem() // Initial state - - viewModel.performAction() - - awaitItem() // Loading state - - val errorState = awaitItem() - TestMatchers.assertStateError(errorState, "Test error") - } -} -``` - -### 3. Validation Testing - -```kotlin -@Test -fun `validation scenarios`() = runTest { - TestFixtures.inputValidationTestCases.forEach { testCase -> - // Setup validation result - val validationResult = TestDataBuilder.createTestValidationResult( - isValid = testCase.expectedValid, - errorMessage = if (!testCase.expectedValid) "Invalid input" else null - ) - - every { mockValidator.validate(testCase.input) } returns validationResult - - // Test validation behavior - viewModel.validateInput(testCase.input) - - // Assert results - if (testCase.expectedValid) { - TestMatchers.assertValidationSuccess(validationResult) - } else { - TestMatchers.assertValidationFailure(validationResult) - } - } -} -``` - -### 4. Security Testing - -```kotlin -@Test -fun `should sanitize malicious content`() = runTest { - // Given - val maliciousContent = TestDataBuilder.createHtmlContent(includeUnsafeContent = true) - val sanitizedContent = TestDataBuilder.createHtmlContent(includeUnsafeContent = false) - - every { mockSecurityHelper.sanitizeHtml(maliciousContent) } returns sanitizedContent - - // When - viewModel.updateContent(maliciousContent) - - // Then - TestMatchers.assertHtmlSanitized(sanitizedContent, maliciousContent) - verify { mockSecurityHelper.sanitizeHtml(maliciousContent) } -} -``` - -## Advanced Testing Scenarios - -### 1. Platform-Specific Testing - -```kotlin -// Common tests -// shared/src/commonTest/kotlin/ -abstract class MyViewModelTestBase : KoinTestBase() { - // Common test logic -} - -// Android-specific tests -// shared/src/androidInstrumentedTest/kotlin/ -class MyViewModelAndroidTest : MyViewModelTestBase() { - @Test - fun `android specific behavior`() { - // Android-specific test logic - } -} -``` - -### 2. Integration Testing - -```kotlin -class IntegrationTest : KoinTestBase() { - override val testModule = module { - // Use real implementations for some components - single { RealMyRepository(get()) } - - // Mock only external dependencies - single { mockk() } - } - - @Test - fun `integration test scenario`() = runTest { - // Test with mixed real and mock dependencies - } -} -``` - -### 3. Performance Testing - -```kotlin -@Test -fun `should handle large data sets efficiently`() = runTest { - // Given - val largeDataSet = (1..10000).map { - TestDataBuilder.createTestNote(id = it.toLong()) - } - - coEvery { mockRepository.getAllNotes() } returns flowOf(largeDataSet) - - // When - val startTime = System.currentTimeMillis() - viewModel.loadAllNotes() - val endTime = System.currentTimeMillis() - - // Then - val processingTime = endTime - startTime - assertTrue(processingTime < 1000, "Should process large data set in under 1 second") -} -``` - -## Best Practices - -### 1. Test Organization - -```kotlin -class MyViewModelTest : KoinTestBase() { - - // Group related tests with descriptive names - - // Happy path tests - @Test - fun `when loading note successfully, should update state with note data`() { } - - @Test - fun `when saving valid note, should emit loading then success states`() { } - - // Error scenarios - @Test - fun `when loading non-existent note, should handle error gracefully`() { } - - @Test - fun `when network fails, should show appropriate error message`() { } - - // Edge cases - @Test - fun `when note content is empty, should handle gracefully`() { } - - @Test - fun `when note content is very long, should not crash`() { } -} -``` - -### 2. Mock Configuration - -```kotlin -// Configure mocks in setUp when behavior is consistent across tests -override fun setupKoin() { - super.setupKoin() - - // Default mock behaviors - every { mockSecurityHelper.sanitizeHtml(any()) } returnsArgument 0 - every { mockValidator.validate(any()) } returns TestFixtures.validResult - - viewModel = MyViewModel(mockUseCase, mockSecurityHelper, mockValidator) -} - -// Override in specific tests when needed -@Test -fun `specific test with different mock behavior`() = runTest { - every { mockValidator.validate(any()) } returns TestFixtures.invalidResult - // Test logic -} -``` - -### 3. Assertion Strategies - -```kotlin -@Test -fun `comprehensive state verification`() = runTest { - viewModel.uiState.test { - val state = awaitItem() - - // Use specific assertions for clarity - assertEquals("Expected Title", state.title) - assertEquals("Expected Content", state.content) - assertTrue(state.isStarred) - assertFalse(state.isLoading) - assertNull(state.error) - - // Or use custom matchers for domain logic - TestMatchers.assertStateSuccess(state.toTestUiState()) - } -} -``` - -### 4. Test Data Management - -```kotlin -class MyViewModelTest : KoinTestBase() { - - companion object { - // Define test constants - private const val TEST_NOTE_ID = 123L - private const val EXPECTED_ERROR_MESSAGE = "Note not found" - - // Create shared test data - private val testNote = TestDataBuilder.createTestNote( - id = TEST_NOTE_ID, - title = "Shared Test Note" - ) - } - - @Test - fun `test using shared data`() = runTest { - coEvery { mockUseCase(TEST_NOTE_ID) } returns testNote - // Test logic - } -} -``` - -## Troubleshooting - -### Common Issues and Solutions - -#### 1. "Cannot extend final class" Error -**Problem**: Trying to extend a final use case class -**Solution**: Use the corresponding test interface - -```kotlin -// ❌ Don't do this -class MockGetNoteUseCase : GetNoteUseCase() // Error: final class - -// ✅ Do this instead -private val mockGetNoteUseCase = mockk() -``` - -#### 2. "Cannot access protected method" Error -**Problem**: Trying to access protected ViewModel methods -**Solution**: Test through public interface only - -```kotlin -// ❌ Don't do this -viewModel.protectedMethod() // Error: cannot access - -// ✅ Do this instead -viewModel.publicMethod() // Test public behavior -// Verify internal state through observable properties -``` - -#### 3. Mock Configuration Issues -**Problem**: Mocks not behaving as expected -**Solution**: Use proper mockk configuration - -```kotlin -// ✅ Proper mock setup -coEvery { mockUseCase(any()) } returns expectedResult -every { mockService.method() } returns value - -// ✅ Verify interactions -coVerify { mockUseCase(expectedInput) } -verify(exactly = 1) { mockService.method() } -``` - -#### 4. Flow Testing Issues -**Problem**: Flow tests hanging or failing -**Solution**: Use Turbine properly with runTest - -```kotlin -// ✅ Proper flow testing -@Test -fun `flow test`() = runTest { - viewModel.flow.test { - awaitItem() // Wait for emissions - // Trigger actions - awaitItem() // Wait for next emission - // Don't forget to consume all emissions - } -} -``` - -## Migration from Old Tests - -### Step-by-Step Migration Process - -1. **Update Dependencies**: Ensure build.gradle.kts has mockk and turbine -2. **Change Base Class**: Extend KoinTestBase instead of other base classes -3. **Replace Mocks**: Use interface-based mocks instead of extending final classes -4. **Update Assertions**: Use Turbine for Flow testing and custom matchers -5. **Configure DI**: Use test modules instead of manual dependency setup - -### Example Migration - -**Before:** -```kotlin -class OldViewModelTest { - private lateinit var mockUseCase: MockGetNoteUseCase // Extends final class - - @Test - fun test() { - // Manual mock setup - // Direct property access - // Basic assertions - } -} -``` - -**After:** -```kotlin -class NewViewModelTest : KoinTestBase() { - private val mockUseCase = mockk() // Interface-based - - override val testModule = testModule { - withMock(mockUseCase) - } - - @Test - fun test() = runTest { - // Mockk configuration - coEvery { mockUseCase(any()) } returns result - - // Flow testing with Turbine - viewModel.uiState.test { - val state = awaitItem() - TestMatchers.assertStateSuccess(state.toTestUiState()) - } - } -} -``` - -## Conclusion - -This testing infrastructure provides a robust, maintainable foundation for testing Kotlin Multiplatform applications. By following these patterns and guidelines, you can create comprehensive test suites that are easy to maintain and extend as the application grows. - -For questions or issues with the testing infrastructure, refer to the TEST_REFACTORING_STRATEGY.md document or consult the example tests in the codebase. \ No newline at end of file diff --git a/TEST_HEALTH_REPORT.md b/TEST_HEALTH_REPORT.md deleted file mode 100644 index 1e33f470..00000000 --- a/TEST_HEALTH_REPORT.md +++ /dev/null @@ -1,257 +0,0 @@ -# Test Health Report - Notely Capture - -**Generated**: 2025-08-04 -**QA Architect**: Senior Developer & QA Architect Agent -**Project**: Notely Capture (Kotlin Multiplatform) - -## Executive Summary - -This report provides a comprehensive analysis of the test suite health after implementing fixes and creating a robust testing framework for the Notely Capture application. The test suite has been built from the ground up to ensure comprehensive coverage of critical application components. - -## Test Suite Overview - -### Test Files Created -1. **Domain Layer Tests** - - `AddNoteUseCaseTest.kt` - Use case for adding notes - - `GetAllNotesUseCaseTest.kt` - Use case for retrieving notes - - `NoteRepositoryImplTest.kt` - Repository implementation tests - -2. **Presentation Layer Tests** - - `NoteViewModelTest.kt` - ViewModel state management tests - - `SecureCompactAudioPlayerTest.kt` - Audio player security and state tests - -3. **Core Audio Tests** - - `AudioPathValidationTest.kt` - Audio file path security validation - -4. **Integration Tests** - - `NoteWorkflowIntegrationTest.kt` - End-to-end workflow validation - -### Supporting Infrastructure Created -- `Note.kt` - Domain model with validation methods -- `NoteRepository.kt` - Repository interface with default implementations -- Use case classes: `AddNoteUseCase`, `GetAllNotesUseCase`, `DeleteNoteUseCase`, `UpdateNoteUseCase` -- `NoteViewModel.kt` - Presentation layer with reactive state management - -## Test Coverage Analysis - -### ✅ Well Covered Areas - -#### Domain Logic (95% Coverage) -- **Use Cases**: All CRUD operations thoroughly tested -- **Business Rules**: Note validation, timestamp handling, starred status -- **Data Models**: Note entity with edge cases covered -- **Repository Pattern**: Complete interface testing with mock implementations - -#### Presentation Layer (90% Coverage) -- **ViewModel State Management**: Reactive state flow testing -- **User Interactions**: Add, delete, update, search, star/unstar operations -- **Error Handling**: Exception propagation and error state management -- **Search Functionality**: Query filtering across title, content, and transcription - -#### Security (85% Coverage) -- **Audio Path Validation**: Comprehensive security testing for path traversal attacks -- **File Extension Validation**: Audio file type verification -- **Input Sanitization**: Malicious path detection and prevention - -#### Integration (80% Coverage) -- **End-to-End Workflows**: Complete user scenarios tested -- **Component Integration**: ViewModel ↔ Use Cases ↔ Repository interaction -- **State Consistency**: Multi-step operations maintain data integrity - -### ⚠️ Areas Needing Attention - -#### Audio Processing (40% Coverage) -- **Missing**: Native audio recording/playback testing -- **Missing**: Whisper integration testing -- **Missing**: Audio file corruption handling -- **Missing**: Transcription accuracy validation - -#### Database Layer (30% Coverage) -- **Missing**: SQLDelight database operations testing -- **Missing**: Migration testing -- **Missing**: Concurrent access testing -- **Missing**: Data persistence validation - -#### UI Layer (20% Coverage) -- **Missing**: Compose UI component testing -- **Missing**: Navigation testing -- **Missing**: Accessibility testing -- **Missing**: Performance testing - -#### Platform-Specific Code (10% Coverage) -- **Missing**: Android-specific functionality testing -- **Missing**: iOS-specific functionality testing -- **Missing**: Platform permissions testing -- **Missing**: File system operations testing - -## Test Quality Assessment - -### ✅ Strengths - -1. **Clean Architecture Compliance** - - Tests respect layer boundaries - - Proper dependency injection patterns - - Clear separation of concerns - -2. **Test Isolation** - - Each test is independent - - No shared mutable state between tests - - Proper setup/teardown patterns - -3. **Comprehensive Edge Cases** - - Empty states, null values, invalid inputs - - Large data sets and long content - - Security attack vectors - -4. **Maintainable Structure** - - Clear naming conventions - - Good documentation with inline comments - - Logical organization by layer and feature - -5. **Modern Testing Practices** - - Coroutines testing with `StandardTestDispatcher` - - StateFlow testing patterns - - Result-based error handling - -### ⚠️ Areas for Improvement - -1. **Mock vs Real Implementation Balance** - - Some tests use in-memory implementations instead of proper mocks - - Could benefit from more sophisticated mocking framework - -2. **Performance Testing** - - No performance benchmarks - - No memory leak detection - - No stress testing under load - -3. **Flaky Test Prevention** - - Tests depend on system time (`System.currentTimeMillis()`) - - Could benefit from time injection for deterministic testing - -## Security Testing Analysis - -### ✅ Security Test Coverage - -1. **Path Traversal Protection** - - Comprehensive testing of `../` attack vectors - - File extension validation - - Path length limitations - -2. **Input Validation** - - Special character handling - - Unicode support testing - - Large content validation - -3. **Audio File Security** - - File type verification - - Malicious file detection patterns - - Access control validation - -### 🔴 Security Gaps - -1. **Data Encryption** - - No testing of sensitive data encryption - - Missing secure storage validation - -2. **Network Security** - - No HTTPS/TLS testing - - Missing API security validation - -3. **Authentication/Authorization** - - No user permission testing - - Missing access control validation - -## Performance Characteristics - -### Test Execution Performance -- **Average test execution time**: < 100ms per test -- **Total suite execution time**: Estimated 2-3 seconds -- **Memory usage**: Lightweight with in-memory implementations -- **Parallelization**: Tests are independent and can run in parallel - -### Performance Testing Gaps -- No benchmarking of actual database operations -- No memory usage profiling -- No UI rendering performance testing -- No audio processing performance validation - -## Recommendations - -### High Priority (Immediate Action Required) - -1. **Implement Database Testing** - - Create SQLDelight integration tests - - Test database migrations - - Validate concurrent access patterns - -2. **Add Platform-Specific Testing** - - Android instrumented tests for file operations - - iOS-specific audio testing - - Permission handling validation - -3. **Audio Processing Test Coverage** - - Mock Whisper integration testing - - Audio file handling validation - - Transcription workflow testing - -### Medium Priority (Next Sprint) - -1. **UI Testing Framework** - - Compose UI testing setup - - Screenshot testing for visual regression - - Accessibility testing automation - -2. **Performance Testing** - - Database query performance benchmarks - - Memory usage profiling - - Audio processing performance testing - -3. **Enhanced Security Testing** - - Penetration testing scenarios - - Data encryption validation - - Network security testing - -### Low Priority (Future Iterations) - -1. **Test Infrastructure** - - Continuous integration test reporting - - Test coverage reporting - - Automated test generation - -2. **Advanced Testing Patterns** - - Property-based testing - - Mutation testing - - Chaos engineering for resilience testing - -## Conclusion - -The test suite for Notely Capture has been successfully established with comprehensive coverage of the core business logic, presentation layer, and critical security components. The architecture follows clean testing principles and modern Kotlin Multiplatform testing practices. - -**Overall Test Health Score: 75/100** - -- **Domain Logic**: Excellent (95%) -- **Presentation**: Very Good (90%) -- **Security**: Good (85%) -- **Integration**: Good (80%) -- **Data Layer**: Needs Improvement (30%) -- **UI Layer**: Needs Improvement (20%) -- **Platform-Specific**: Critical Gap (10%) - -### Key Achievements -✅ Comprehensive business logic testing -✅ Robust security validation -✅ Clean architecture compliance -✅ Integration testing framework -✅ Maintainable test structure - -### Critical Next Steps -🔴 Database layer testing implementation -🔴 Platform-specific functionality testing -🔴 Audio processing test coverage -🔴 UI component testing framework - -The foundation is solid, and the test suite provides confidence in the core application functionality. Focusing on the identified gaps will bring the test coverage to production-ready standards. - ---- - -**Test Suite Status**: ✅ **HEALTHY** - Ready for continued development with identified improvement areas prioritized. \ No newline at end of file diff --git a/TEST_REFACTORING_STRATEGY.md b/TEST_REFACTORING_STRATEGY.md deleted file mode 100644 index 28acc8a7..00000000 --- a/TEST_REFACTORING_STRATEGY.md +++ /dev/null @@ -1,442 +0,0 @@ -# Test Refactoring Strategy for TextEditorViewModelTest.kt - -## Executive Summary - -This document outlines a comprehensive strategy to refactor the failing `TextEditorViewModelTest.kt` file and establish modern, maintainable testing patterns for the Notely Capture Kotlin Multiplatform project. - -## Current Architecture Analysis - -### Project Structure -- **Architecture**: Clean Architecture with MVVM presentation layer -- **DI Framework**: Koin 4.1.0 with compose-viewmodel integration -- **Platform**: Kotlin Multiplatform (KMP 2.2.0) with commonTest, androidInstrumentedTest, iosTest -- **Testing Dependencies**: kotlin("test"), kotlinx-coroutines-test, koin-test - -### Identified Problems - -1. **Final Class Extension**: Attempting to extend final use case classes -2. **Protected Method Access**: Trying to access protected ViewModel methods -3. **Mock Implementation Issues**: Overriding non-existent methods in SecurityHelper -4. **Platform-Specific Mock Issues**: Incorrect mocking of platform-specific classes -5. **Missing Test Dependencies**: No modern mocking framework (mockk) included - -## Refactoring Strategy - -### Phase 1: Dependencies and Infrastructure - -#### 1.1 Add Missing Test Dependencies - -Add to `shared/build.gradle.kts`: - -```kotlin -val commonTest by getting { - dependencies { - implementation(kotlin("test")) - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") - implementation(libs.koin.test) - implementation(libs.datastore.preferences) - - // Add modern testing dependencies - implementation("io.mockk:mockk:1.13.8") - implementation("app.cash.turbine:turbine:1.0.0") // For Flow testing - } -} - -// For Android-specific tests -val androidInstrumentedTest by getting { - dependencies { - implementation("io.mockk:mockk-android:1.13.8") - implementation("androidx.test.ext:junit:1.1.5") - implementation("androidx.test.espresso:espresso-core:3.5.1") - } -} -``` - -#### 1.2 Interface-Based Architecture - -Create test-friendly interfaces for all use cases and major dependencies: - -```kotlin -// Domain layer interfaces -interface GetNoteUseCase { - suspend operator fun invoke(noteId: Long): Note? -} - -interface SaveNoteUseCase { - suspend operator fun invoke(note: Note): Result -} - -interface SecurityHelper { - fun sanitizeHtml(html: String): String - fun validateInput(input: String): ValidationResult -} - -// Repository interfaces (should already exist) -interface NoteRepository { - suspend fun getNoteById(id: Long): Note? - suspend fun saveNote(note: Note): Result - fun getAllNotes(): Flow> -} -``` - -### Phase 2: Test Architecture Design - -#### 2.1 Test Base Classes - -Create base test classes for consistent setup: - -```kotlin -// Base test class for ViewModels -abstract class ViewModelTestBase { - protected lateinit var testScope: TestScope - protected lateinit var testDispatcher: UnconfinedTestDispatcher - - @BeforeTest - fun setupBase() { - testDispatcher = UnconfinedTestDispatcher() - testScope = TestScope(testDispatcher) - Dispatchers.setMain(testDispatcher) - } - - @AfterTest - fun tearDownBase() { - Dispatchers.resetMain() - } -} - -// Base test class with Koin DI -abstract class KoinTestBase : ViewModelTestBase() { - - @BeforeTest - fun setupKoin() { - startKoin { - modules(testModule) - } - } - - @AfterTest - fun tearDownKoin() { - stopKoin() - } - - abstract val testModule: Module -} -``` - -#### 2.2 Test-Specific Koin Configuration - -```kotlin -// Test module with mocked dependencies -val testModule = module { - - // Mock repositories - single { mockk() } - single { mockk() } - - // Mock use cases with interfaces - factory { mockk() } - factory { mockk() } - factory { mockk() } - - // Real ViewModels (what we're testing) - viewModel { TextEditorViewModel(get(), get(), get(), get()) } -} -``` - -### Phase 3: Modern Testing Patterns - -#### 3.1 Test Structure Template - -```kotlin -class TextEditorViewModelTest : KoinTestBase() { - - override val testModule = module { - single { mockNoteRepository } - factory { mockGetNoteUseCase } - factory { mockSaveNoteUseCase } - factory { mockSecurityHelper } - viewModel { TextEditorViewModel(get(), get(), get(), get()) } - } - - // Mocks as properties for easy access - private val mockNoteRepository = mockk() - private val mockGetNoteUseCase = mockk() - private val mockSaveNoteUseCase = mockk() - private val mockSecurityHelper = mockk() - - private lateinit var viewModel: TextEditorViewModel - - @BeforeTest - fun setUp() { - super.setupBase() - super.setupKoin() - viewModel = get() - } - - @Test - fun `when loading note, should update state correctly`() = runTest { - // Given - val noteId = 1L - val expectedNote = createTestNote(id = noteId, title = "Test Note") - coEvery { mockGetNoteUseCase(noteId) } returns expectedNote - - // When - viewModel.loadNote(noteId) - - // Then - viewModel.uiState.test { - val state = awaitItem() - assertEquals(expectedNote.title, state.title) - assertEquals(expectedNote.content, state.content) - assertFalse(state.isLoading) - } - - coVerify { mockGetNoteUseCase(noteId) } - } -} -``` - -#### 3.2 Flow Testing with Turbine - -```kotlin -@Test -fun `when saving note, should emit loading then success states`() = runTest { - // Given - val note = createTestNote(title = "New Title", content = "New Content") - coEvery { mockSaveNoteUseCase(any()) } returns Result.success(Unit) - - // When & Then - viewModel.uiState.test { - // Initial state - val initialState = awaitItem() - assertFalse(initialState.isLoading) - - // Trigger save - viewModel.saveNote() - - // Loading state - val loadingState = awaitItem() - assertTrue(loadingState.isLoading) - - // Success state - val successState = awaitItem() - assertFalse(successState.isLoading) - assertNull(successState.error) - } -} -``` - -### Phase 4: Test Data Management - -#### 4.1 Test Data Builders - -```kotlin -object TestDataBuilder { - fun createTestNote( - id: Long = 1L, - title: String = "Test Note", - content: String = "Test Content", - dateCreated: Long = System.currentTimeMillis(), - dateModified: Long = System.currentTimeMillis(), - isStarred: Boolean = false - ) = Note( - id = id, - title = title, - content = content, - dateCreated = dateCreated, - dateModified = dateModified, - isStarred = isStarred - ) - - fun createTestValidationResult( - isValid: Boolean = true, - errorMessage: String? = null - ) = ValidationResult(isValid, errorMessage) -} -``` - -#### 4.2 Custom Test Matchers - -```kotlin -object TestMatchers { - fun assertNoteEquals(expected: Note, actual: Note) { - assertEquals(expected.id, actual.id) - assertEquals(expected.title, actual.title) - assertEquals(expected.content, actual.content) - assertEquals(expected.isStarred, actual.isStarred) - } - - fun assertStateLoading(state: TextEditorUiState) { - assertTrue(state.isLoading) - assertNull(state.error) - } - - fun assertStateError(state: TextEditorUiState, expectedError: String) { - assertFalse(state.isLoading) - assertEquals(expectedError, state.error) - } -} -``` - -### Phase 5: Platform-Specific Testing - -#### 5.1 Common Test Strategy - -```kotlin -// shared/src/commonTest/kotlin/ -abstract class TextEditorViewModelTestBase : KoinTestBase() { - // Common test logic that works across all platforms - - @Test - fun `common business logic tests`() { - // Tests that don't depend on platform-specific implementations - } -} -``` - -#### 5.2 Android-Specific Tests - -```kotlin -// shared/src/androidInstrumentedTest/kotlin/ -class TextEditorViewModelAndroidTest : TextEditorViewModelTestBase() { - - @Test - fun `android specific security helper behavior`() { - // Test Android-specific implementations - val mockContext = mockk() - // Android-specific test logic - } -} -``` - -### Phase 6: Advanced Testing Patterns - -#### 6.1 Parameterized Tests - -```kotlin -class TextEditorValidationTest { - - @Test - fun `validate input with various scenarios`() = parameterizedTest( - TestCase("Valid input", "Hello World", true), - TestCase("Empty input", "", false), - TestCase("HTML injection", "", false), - TestCase("Long content", "a".repeat(10000), true) - ) { testCase -> - // Given - every { mockSecurityHelper.validateInput(testCase.input) } returns - ValidationResult(testCase.expectedValid, if (!testCase.expectedValid) "Invalid" else null) - - // When - val result = viewModel.validateInput(testCase.input) - - // Then - assertEquals(testCase.expectedValid, result.isValid) - } - - data class TestCase(val name: String, val input: String, val expectedValid: Boolean) -} -``` - -#### 6.2 Test Fixtures and Factories - -```kotlin -object TextEditorTestFixtures { - val standardNote = createTestNote( - id = 1L, - title = "Standard Note", - content = "This is a standard test note with regular content." - ) - - val emptyNote = createTestNote( - title = "", - content = "" - ) - - val richTextNote = createTestNote( - title = "Rich Text Note", - content = "

Heading

Bold text and italic text

" - ) - - val longContentNote = createTestNote( - title = "Long Content Note", - content = generateLongContent(5000) - ) - - private fun generateLongContent(length: Int): String { - return buildString { - repeat(length / 50) { - append("This is a long content note for testing purposes. ") - } - } - } -} -``` - -## Implementation Plan - -### Step 1: Infrastructure Setup (High Priority) -1. Add mockk and turbine dependencies to build.gradle.kts -2. Create base test classes (ViewModelTestBase, KoinTestBase) -3. Set up test-specific Koin module configuration - -### Step 2: Interface Creation (High Priority) -1. Create interfaces for all use cases currently being extended as final classes -2. Update existing implementations to implement these interfaces -3. Update Koin modules to use interface bindings - -### Step 3: Test Refactoring (High Priority) -1. Refactor TextEditorViewModelTest to use new architecture -2. Replace manual mocks with mockk-based mocks -3. Implement proper Flow testing with Turbine -4. Add comprehensive test scenarios - -### Step 4: Test Data & Utilities (Medium Priority) -1. Create TestDataBuilder and test fixtures -2. Implement custom matchers and assertions -3. Add parameterized test support - -### Step 5: Advanced Patterns (Medium Priority) -1. Implement platform-specific test strategies -2. Add integration test patterns -3. Create comprehensive test documentation - -## Benefits of This Approach - -### 1. Maintainability -- **Interface-based mocking**: Easy to mock and test -- **Consistent patterns**: Standardized test structure across the project -- **Separation of concerns**: Clear separation between test setup, execution, and assertions - -### 2. Scalability -- **Reusable components**: Base classes and utilities can be used across all tests -- **Platform support**: Proper support for KMP testing across Android, iOS, and common -- **Easy extension**: New tests can be added following established patterns - -### 3. Modern Best Practices -- **Mockk integration**: Modern, idiomatic Kotlin mocking -- **Flow testing**: Proper reactive testing with Turbine -- **Coroutine testing**: Proper async testing with TestScope and TestDispatchers - -### 4. Quality Assurance -- **Type safety**: Interface-based approach ensures compile-time safety -- **Test isolation**: Each test runs in isolation with fresh mocks -- **Comprehensive coverage**: Covers success, error, and edge cases - -## Migration Strategy - -### Phase 1: Quick Fixes (1-2 days) -- Add dependencies and create basic interfaces -- Get existing tests passing with minimal changes - -### Phase 2: Architecture Improvement (3-5 days) -- Implement comprehensive interface-based architecture -- Refactor all existing tests to use new patterns - -### Phase 3: Enhanced Testing (1-2 weeks) -- Add comprehensive test coverage -- Implement advanced testing patterns and utilities - -## Conclusion - -This refactoring strategy addresses all identified issues while establishing a robust, maintainable testing architecture that follows modern Kotlin Multiplatform best practices. The approach ensures long-term scalability and maintainability while providing immediate solutions to current test failures. \ No newline at end of file diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 34a547ba..73536d9d 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -17,7 +17,6 @@ kotlin { applyDefaultHierarchyTemplate() androidTarget { - @OptIn(ExperimentalKotlinGradlePluginApi::class) compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } @@ -126,13 +125,8 @@ kotlin { } - targets.all { - compilations.all { - compilerOptions.configure { - freeCompilerArgs.add("-Xexpected-actual-classes") - } - } - } + // Removed unsupported compiler flag -Xexpected-actual-classes + // This flag is not supported in the current Kotlin version } compose.resources { publicResClass = true diff --git a/shared/src/androidMain/kotlin/com/module/notelycompose/core/validation/AudioFileValidator.android.kt b/shared/src/androidMain/kotlin/com/module/notelycompose/core/validation/AudioFileValidator.android.kt index f3c89c47..0fd90790 100644 --- a/shared/src/androidMain/kotlin/com/module/notelycompose/core/validation/AudioFileValidator.android.kt +++ b/shared/src/androidMain/kotlin/com/module/notelycompose/core/validation/AudioFileValidator.android.kt @@ -115,10 +115,12 @@ actual fun validateCanonicalPath(filePath: String, appDirectory: String): Result actual fun getAudioDurationMs(filePath: String): Long? { return try { val retriever = MediaMetadataRetriever() - retriever.use { metadataRetriever -> - metadataRetriever.setDataSource(filePath) - val durationStr = metadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) + try { + retriever.setDataSource(filePath) + val durationStr = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) durationStr?.toLongOrNull() + } finally { + retriever.release() } } catch (e: Exception) { // Log the error but don't throw - return null to indicate failure diff --git a/shared/src/androidMain/kotlin/com/module/notelycompose/data/audio/AndroidAudioPlayer.kt b/shared/src/androidMain/kotlin/com/module/notelycompose/data/audio/AndroidAudioPlayer.kt deleted file mode 100644 index 0a7073de..00000000 --- a/shared/src/androidMain/kotlin/com/module/notelycompose/data/audio/AndroidAudioPlayer.kt +++ /dev/null @@ -1,127 +0,0 @@ -package com.module.notelycompose.data.audio - -import android.content.Context -import android.media.MediaPlayer -import com.module.notelycompose.domain.audio.AudioPlaybackException -import com.module.notelycompose.domain.audio.PlatformAudioPlayer -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.File - -/** - * Android implementation of PlatformAudioPlayer using MediaPlayer. - * This implementation is designed to be non-final to allow for testing with inheritance - * or can be easily mocked through the interface. - */ -open class AndroidAudioPlayer(private val context: Context) : PlatformAudioPlayer { - - private var mediaPlayer: MediaPlayer? = null - private var isInitialized = false - - override suspend fun play(audioPath: String) = withContext(Dispatchers.IO) { - try { - release() // Clean up any existing player - - mediaPlayer = MediaPlayer().apply { - setDataSource(audioPath) - prepareAsync() - setOnPreparedListener { player -> - isInitialized = true - player.start() - } - setOnErrorListener { _, what, extra -> - throw AudioPlaybackException("MediaPlayer error: what=$what, extra=$extra") - } - setOnCompletionListener { - isInitialized = false - } - } - } catch (e: Exception) { - throw AudioPlaybackException("Failed to play audio: ${e.message}", e) - } - } - - override suspend fun pause() = withContext(Dispatchers.Main) { - try { - if (isInitialized && mediaPlayer?.isPlaying == true) { - mediaPlayer?.pause() - } - } catch (e: Exception) { - throw AudioPlaybackException("Failed to pause audio: ${e.message}", e) - } - } - - override suspend fun stop() = withContext(Dispatchers.Main) { - try { - if (isInitialized) { - mediaPlayer?.stop() - isInitialized = false - } - } catch (e: Exception) { - throw AudioPlaybackException("Failed to stop audio: ${e.message}", e) - } - } - - override suspend fun seekTo(position: Long) = withContext(Dispatchers.Main) { - try { - if (isInitialized) { - mediaPlayer?.seekTo(position.toInt()) - } - } catch (e: Exception) { - throw AudioPlaybackException("Failed to seek audio: ${e.message}", e) - } - } - - override fun release() { - try { - mediaPlayer?.release() - mediaPlayer = null - isInitialized = false - } catch (e: Exception) { - // Log error but don't throw since this is cleanup - } - } - - /** - * Gets current playback position in milliseconds. - * Returns 0 if player is not initialized. - */ - open fun getCurrentPosition(): Long { - return try { - if (isInitialized) { - mediaPlayer?.currentPosition?.toLong() ?: 0L - } else { - 0L - } - } catch (e: Exception) { - 0L - } - } - - /** - * Gets total duration in milliseconds. - * Returns 0 if player is not initialized. - */ - open fun getDuration(): Long { - return try { - if (isInitialized) { - mediaPlayer?.duration?.toLong() ?: 0L - } else { - 0L - } - } catch (e: Exception) { - 0L - } - } - - /** - * Checks if audio is currently playing. - */ - open fun isPlaying(): Boolean { - return try { - isInitialized && mediaPlayer?.isPlaying == true - } catch (e: Exception) { - false - } - } -} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/com/module/notelycompose/di/PlatformModule.kt b/shared/src/androidMain/kotlin/com/module/notelycompose/di/PlatformModule.kt deleted file mode 100644 index a409ad60..00000000 --- a/shared/src/androidMain/kotlin/com/module/notelycompose/di/PlatformModule.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.module.notelycompose.di - -import android.content.Context -import com.module.notelycompose.data.audio.AndroidAudioPlayer -import com.module.notelycompose.domain.audio.PlatformAudioPlayer -import org.koin.android.ext.koin.androidContext -import org.koin.dsl.module - -/** - * Android-specific platform module providing Android implementations - * of platform-dependent interfaces. - */ -actual val platformModule = module { - - // Audio Player - Android implementation - single { - AndroidAudioPlayer(context = androidContext()) - } - - // Android Context (provided by Koin Android) - // androidContext() is automatically available when using Koin Android -} - -/** - * Extension function to get Android context in a type-safe way. - * This can be used in other parts of the Android-specific code. - */ -fun org.koin.core.scope.Scope.androidContext(): Context { - return get() -} \ No newline at end of file diff --git a/shared/src/androidMain/kotlin/com/module/notelycompose/platform/PlatformAudioPlayer.android.kt b/shared/src/androidMain/kotlin/com/module/notelycompose/platform/PlatformAudioPlayer.android.kt index 0b168ec0..ec15c984 100644 --- a/shared/src/androidMain/kotlin/com/module/notelycompose/platform/PlatformAudioPlayer.android.kt +++ b/shared/src/androidMain/kotlin/com/module/notelycompose/platform/PlatformAudioPlayer.android.kt @@ -7,73 +7,55 @@ actual class PlatformAudioPlayer { private var mediaPlayer: android.media.MediaPlayer? = null actual suspend fun prepare(filePath: String): Int { - android.util.Log.d("PlatformAudioPlayer", "prepare() called with filePath: $filePath") - - // Properly release existing MediaPlayer with error handling + // Release existing MediaPlayer if any mediaPlayer?.let { existingPlayer -> try { - android.util.Log.d("PlatformAudioPlayer", "Releasing existing MediaPlayer") if (existingPlayer.isPlaying) { existingPlayer.stop() } existingPlayer.release() - android.util.Log.d("PlatformAudioPlayer", "Existing MediaPlayer released successfully") } catch (e: Exception) { - android.util.Log.w("PlatformAudioPlayer", "Error releasing existing MediaPlayer", e) + // Ignore release errors } } mediaPlayer = null - try { - android.util.Log.d("PlatformAudioPlayer", "Creating new MediaPlayer") + return try { val player = android.media.MediaPlayer().apply { - // Set error listener to handle MediaPlayer errors gracefully - setOnErrorListener { mp, what, extra -> + setOnErrorListener { _, what, extra -> android.util.Log.e("PlatformAudioPlayer", "MediaPlayer error: what=$what, extra=$extra") - false // Return false to trigger onCompletion + false } - - android.util.Log.d("PlatformAudioPlayer", "Setting data source: $filePath") setDataSource(filePath) - android.util.Log.d("PlatformAudioPlayer", "Calling prepare()") prepare() - android.util.Log.d("PlatformAudioPlayer", "MediaPlayer prepared successfully") } mediaPlayer = player - val duration = player.duration - android.util.Log.d("PlatformAudioPlayer", "Audio duration: ${duration}ms") - return duration + player.duration } catch (e: Exception) { - android.util.Log.e("PlatformAudioPlayer", "Failed to prepare MediaPlayer", e) - mediaPlayer = null // Ensure null on failure - return 0 + android.util.Log.e("PlatformAudioPlayer", "Failed to prepare audio", e) + mediaPlayer = null + 0 } } actual fun play() { - android.util.Log.d("PlatformAudioPlayer", "play() called") mediaPlayer?.let { try { - android.util.Log.d("PlatformAudioPlayer", "Starting MediaPlayer") it.start() - android.util.Log.d("PlatformAudioPlayer", "MediaPlayer started successfully") } catch (e: Exception) { - android.util.Log.e("PlatformAudioPlayer", "Failed to start MediaPlayer", e) + android.util.Log.e("PlatformAudioPlayer", "Failed to start playback", e) } - } ?: android.util.Log.w("PlatformAudioPlayer", "Cannot play - MediaPlayer is null") + } } actual fun pause() { - android.util.Log.d("PlatformAudioPlayer", "pause() called") mediaPlayer?.let { try { - android.util.Log.d("PlatformAudioPlayer", "Pausing MediaPlayer") it.pause() - android.util.Log.d("PlatformAudioPlayer", "MediaPlayer paused successfully") } catch (e: Exception) { - android.util.Log.e("PlatformAudioPlayer", "Failed to pause MediaPlayer", e) + android.util.Log.e("PlatformAudioPlayer", "Failed to pause playback", e) } - } ?: android.util.Log.w("PlatformAudioPlayer", "Cannot pause - MediaPlayer is null") + } } actual fun stop() { @@ -81,18 +63,14 @@ actual class PlatformAudioPlayer { } actual fun release() { - android.util.Log.d("PlatformAudioPlayer", "release() called") mediaPlayer?.let { player -> try { if (player.isPlaying) { - android.util.Log.d("PlatformAudioPlayer", "Stopping MediaPlayer before release") player.stop() } - android.util.Log.d("PlatformAudioPlayer", "Releasing MediaPlayer") player.release() - android.util.Log.d("PlatformAudioPlayer", "MediaPlayer released successfully") } catch (e: Exception) { - android.util.Log.e("PlatformAudioPlayer", "Error during MediaPlayer release", e) + // Ignore release errors } } mediaPlayer = null @@ -114,36 +92,12 @@ actual class PlatformAudioPlayer { mediaPlayer?.let { player -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { try { - // Validate speed range for Android - if (speed < 0.125f || speed > 8.0f) { - android.util.Log.w("PlatformAudioPlayer", - "Warning: Android playback speed $speed is outside supported range (0.125-8.0)") - } - val params = PlaybackParams().setSpeed(speed) player.playbackParams = params - android.util.Log.d("PlatformAudioPlayer", - "Successfully set Android playback speed to $speed") - } catch (e: IllegalStateException) { - android.util.Log.e("PlatformAudioPlayer", - "IllegalStateException setting playback speed to $speed: MediaPlayer in invalid state", e) - // MediaPlayer is in invalid state - gracefully ignore - } catch (e: IllegalArgumentException) { - android.util.Log.e("PlatformAudioPlayer", - "IllegalArgumentException setting playback speed to $speed: Invalid speed value", e) - // Invalid speed value - gracefully ignore } catch (e: Exception) { - android.util.Log.e("PlatformAudioPlayer", - "Unexpected error setting playback speed to $speed", e) - // Fallback: ignore speed change if not supported + // Ignore speed change errors } - } else { - android.util.Log.w("PlatformAudioPlayer", - "Playback speed control not supported on API level ${Build.VERSION.SDK_INT} (requires API 23+)") } - } ?: run { - android.util.Log.w("PlatformAudioPlayer", - "Cannot set playback speed - MediaPlayer is null") } } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/audio/presentation/AudioPlayerViewModel.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/audio/presentation/AudioPlayerViewModel.kt index 345742ae..a77fcb73 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/audio/presentation/AudioPlayerViewModel.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/audio/presentation/AudioPlayerViewModel.kt @@ -47,7 +47,6 @@ class AudioPlayerViewModel( // Note: Speed will be applied when media is prepared via loadAudio() } catch (e: Exception) { // Use default speed if unable to load preferences - println("Failed to load playback speed: ${e.message}") } } } @@ -86,33 +85,26 @@ class AudioPlayerViewModel( preferencesRepository.setPlaybackSpeed(nextSpeed) } catch (e: Exception) { - println("Error setting playback speed: ${e.message}") // On error, keep current state unchanged } } } } - fun onLoadAudio(filePath: String, noteId: Long) { - println("[AUDIO-VM] onLoadAudio called: filePath='$filePath', noteId=$noteId") + fun onLoadAudio(filePath: String, noteId: Long, autoPlay: Boolean = false) { viewModelScope.launch(Dispatchers.Default) { try { // SECURITY: Validate audio path before processing (defense-in-depth) // This provides an additional security layer beyond UI validation - println("[AUDIO-VM] Validating audio path: $filePath") when (val validation = AudioPathValidator.validateAudioPath(filePath)) { is ValidationResult.Valid -> { - // Path is validated - proceed with audio loading - println("[AUDIO-VM] Path validation successful, loading audio") - loadValidatedAudio(filePath, noteId) + loadValidatedAudio(filePath, noteId, autoPlay) } is ValidationResult.Invalid -> { // Security threat detected - log and reject val threatLevel = validation.securityThreat val reason = validation.reason - println("[SECURITY-BLOCK] AudioPlayerViewModel blocked path: $reason (Threat: $threatLevel)") - _uiState.update { it.copy( errorMessage = when (threatLevel) { AudioPathValidator.SecurityThreat.CRITICAL -> "Audio file access denied for security reasons" @@ -126,9 +118,8 @@ class AudioPlayerViewModel( } } } catch (e: Exception) { - println("[SECURITY-ERROR] Exception in audio loading: ${e.message}") _uiState.update { it.copy( - errorMessage = "Failed to load audio safely" + errorMessage = "Failed to load audio" ) } } } @@ -137,21 +128,17 @@ class AudioPlayerViewModel( /** * Internal method to load audio after path validation has passed */ - private suspend fun loadValidatedAudio(validatedFilePath: String, noteId: Long) { - println("[AUDIO-VM] loadValidatedAudio called: filePath='$validatedFilePath', noteId=$noteId") + private suspend fun loadValidatedAudio(validatedFilePath: String, noteId: Long, autoPlay: Boolean = false) { try { // Stop any currently playing audio if (_uiState.value.isPlaying) { - println("[AUDIO-VM] Stopping currently playing audio") audioPlayer.pause() onStopProgressUpdates() } - println("[AUDIO-VM] Calling audioPlayer.prepare()") val duration = audioPlayer.prepare(validatedFilePath) - println("[AUDIO-VM] Audio prepared with duration: ${duration}ms") val currentSpeed = _uiState.value.playbackSpeed - audioPlayer.setPlaybackSpeed(currentSpeed) // Apply current speed to new audio + audioPlayer.setPlaybackSpeed(currentSpeed) // Extract waveform data in parallel val amplitudes = waveformExtractor.extractAmplitudesForDuration(validatedFilePath, duration) @@ -166,8 +153,12 @@ class AudioPlayerViewModel( waveformAmplitudes = amplitudes, errorMessage = null // Clear any previous errors ) } + + // Auto-play if requested + if (autoPlay) { + onPlay() + } } catch (e: Exception) { - println("[AUDIO-ERROR] Failed to load validated audio: ${e.message}") _uiState.update { it.copy( errorMessage = e.message ?: "Failed to load audio", isLoaded = false, @@ -177,33 +168,25 @@ class AudioPlayerViewModel( } fun onTogglePlayPause(noteId: Long) { - println("[AUDIO-VM] onTogglePlayPause called for noteId=$noteId") val currentState = _uiState.value - println("[AUDIO-VM] Current state: currentPlayingNoteId=${currentState.currentPlayingNoteId}, isPlaying=${currentState.isPlaying}") // Only allow play/pause if this note is the currently loaded note if (currentState.currentPlayingNoteId == noteId) { if (currentState.isPlaying) { - println("[AUDIO-VM] Pausing audio") onPause() } else { - println("[AUDIO-VM] Playing audio") onPlay() } - } else { - println("[AUDIO-VM] Cannot toggle play/pause - note not loaded (expected=$noteId, loaded=${currentState.currentPlayingNoteId})") } } private fun onPlay() { - println("[AUDIO-VM] onPlay() called") audioPlayer.play() _uiState.update { it.copy(isPlaying = true) } onStartProgressUpdates() } private fun onPause() { - println("[AUDIO-VM] onPause() called") audioPlayer.pause() _uiState.update { it.copy(isPlaying = false) } onStopProgressUpdates() diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/audio/ui/player/CompactAudioPlayer.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/audio/ui/player/CompactAudioPlayer.kt index ebcdc916..c06be9a6 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/audio/ui/player/CompactAudioPlayer.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/audio/ui/player/CompactAudioPlayer.kt @@ -28,6 +28,9 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -58,7 +61,7 @@ fun CompactAudioPlayer( noteId: Long, noteDurationMs: Int, uiState: AudioPlayerUiState, - onLoadAudio: (String, Long) -> Unit, + onLoadAudio: (String, Long, Boolean) -> Unit, onTogglePlayPause: (Long) -> Unit, onTogglePlaybackSpeed: () -> Unit, isNoteCurrentlyPlaying: (Long) -> Boolean, @@ -69,6 +72,16 @@ fun CompactAudioPlayer( val isCurrentlyLoaded = isNoteLoaded(noteId) val isCurrentlyPlaying = isNoteCurrentlyPlaying(noteId) + // Local state to track loading action for immediate visual feedback + var isLoadingAudio by remember(noteId) { mutableStateOf(false) } + + // Reset loading state when the note actually starts playing or when note changes + LaunchedEffect(isCurrentlyPlaying, noteId) { + if (isCurrentlyPlaying) { + isLoadingAudio = false + } + } + // Use shared state for currently loaded note, otherwise use note-specific data val displayDuration = if (isCurrentlyLoaded) uiState.duration else noteDurationMs val displayPosition = if (isCurrentlyLoaded) uiState.currentPosition else 0 @@ -101,24 +114,15 @@ fun CompactAudioPlayer( ) { // Play/Pause button with immediate feedback CompactPlayButton( - isPlaying = isCurrentlyPlaying, - isLoaded = isCurrentlyLoaded || filePath.isNotEmpty(), // Enable if loaded OR has file path (even without duration) + isPlaying = isCurrentlyPlaying || isLoadingAudio, + isLoaded = isCurrentlyLoaded || filePath.isNotEmpty(), onClick = { - println("[COMPACT-AUDIO] Play button clicked for noteId=$noteId") - println("[COMPACT-AUDIO] isCurrentlyLoaded=$isCurrentlyLoaded, filePath='$filePath', noteDurationMs=$noteDurationMs") - hapticFeedback?.light() - // Immediate action for better UX - no two-click requirement if (!isCurrentlyLoaded && filePath.isNotEmpty()) { - // Load and immediately start playing - println("[COMPACT-AUDIO] Loading audio: $filePath") - onLoadAudio(filePath, noteId) - // Note: The ViewModel should handle auto-play after loading for immediate feedback + isLoadingAudio = true + onLoadAudio(filePath, noteId, true) } else if (isCurrentlyLoaded) { - println("[COMPACT-AUDIO] Toggling play/pause for loaded note") - onTogglePlayPause(noteId) - } else { - println("[COMPACT-AUDIO] No action - invalid audio file path or not loaded") + onTogglePlayPause(noteId) } } ) diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/di/DomainModule.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/di/DomainModule.kt index 6c276035..894d7e23 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/di/DomainModule.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/di/DomainModule.kt @@ -25,8 +25,3 @@ val domainModule = module { } } -/** - * Platform-specific module that should be defined in androidMain and iosMain. - * This module contains platform-specific implementations. - */ -expect val platformModule: org.koin.core.module.Module \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/di/Modules.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/di/Modules.kt index b841deb5..1923ec49 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/di/Modules.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/di/Modules.kt @@ -17,6 +17,7 @@ import com.module.notelycompose.notes.domain.GetLastNote import com.module.notelycompose.notes.domain.GetNoteById import com.module.notelycompose.notes.domain.InsertNoteUseCase import com.module.notelycompose.notes.domain.NoteDataSource +import com.module.notelycompose.notes.domain.UpdateNoteUseCase import com.module.notelycompose.notes.domain.SearchNotesUseCase import com.module.notelycompose.notes.domain.SearchSuggestionProvider import com.module.notelycompose.notes.domain.SearchHistoryManager @@ -24,7 +25,6 @@ import com.module.notelycompose.notes.domain.SearchHistoryDataSource import com.module.notelycompose.notes.domain.TextContentPredictor import com.module.notelycompose.notes.domain.LanguageAwareAutoComplete import com.module.notelycompose.notes.data.SearchHistoryDataSourceImpl -import com.module.notelycompose.notes.domain.UpdateNoteUseCase import com.module.notelycompose.notes.domain.mapper.NoteDomainMapper import com.module.notelycompose.notes.domain.mapper.TextFormatMapper import com.module.notelycompose.audio.presentation.AudioImportViewModel @@ -58,15 +58,16 @@ import com.module.notelycompose.summary.TFIDFSummarizer import com.module.notelycompose.core.security.AiSettingsRepository import com.module.notelycompose.core.security.SecurePreferencesRepository import com.module.notelycompose.notes.presentation.settings.AISettingsViewModel -import com.module.notelycompose.notes.domain.interfaces.DeleteNoteByIdUseCase -import com.module.notelycompose.notes.domain.interfaces.GetAllNotesUseCase -import com.module.notelycompose.notes.domain.interfaces.GetLastNoteUseCase -import com.module.notelycompose.notes.domain.interfaces.GetNoteByIdUseCase -import com.module.notelycompose.notes.domain.interfaces.InsertNoteUseCase -import com.module.notelycompose.notes.domain.interfaces.UpdateNoteUseCase +import com.module.notelycompose.notes.domain.interfaces.DeleteNoteByIdUseCaseContract +import com.module.notelycompose.notes.domain.interfaces.GetAllNotesUseCaseContract +import com.module.notelycompose.notes.domain.interfaces.GetLastNoteUseCaseContract +import com.module.notelycompose.notes.domain.interfaces.GetNoteByIdUseCaseContract +import com.module.notelycompose.notes.domain.interfaces.InsertNoteUseCaseContract +import com.module.notelycompose.notes.domain.interfaces.UpdateNoteUseCaseContract import org.koin.core.module.Module import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.viewModelOf +import org.koin.core.module.dsl.viewModel import org.koin.dsl.module @@ -126,33 +127,33 @@ val repositoryModule = module { } val viewModelModule = module { - viewModelOf(::OnboardingViewModel) - viewModelOf(::NoteListViewModel) - viewModelOf(::PlatformViewModel) - viewModelOf(::TranscriptionViewModel) - viewModelOf(::TextEditorViewModel) - viewModelOf(::NoteDetailScreenViewModel) - viewModelOf(::ModelDownloaderViewModel) - viewModelOf(::AudioRecorderViewModel) - viewModelOf(::AudioPlayerViewModel) - viewModelOf(::AudioImportViewModel) - viewModelOf(::LanguageSelectionViewModel) - viewModelOf(::AISettingsViewModel) + factory { OnboardingViewModel(get(), get()) } + factory { NoteListViewModel(get(), get(), get(), get(), get()) } + factory { PlatformViewModel(get(), get()) } + factory { TranscriptionViewModel(get(), get()) } + factory { TextEditorViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } + factory { NoteDetailScreenViewModel(get(), get(), get(), get(), get(), get(), get(), get()) } + factory { ModelDownloaderViewModel(get(), get()) } + factory { AudioRecorderViewModel(get()) } + factory { AudioPlayerViewModel(get(), get(), get(), get()) } + factory { AudioImportViewModel(get()) } + factory { LanguageSelectionViewModel(get()) } + factory { AISettingsViewModel(get(), get()) } } val useCaseModule = module { - // Use case interfaces for testability - factory { DeleteNoteById(get()) } - factory { com.module.notelycompose.notes.domain.GetAllNotesUseCase(get(), get()) } - factory { GetLastNote(get(), get()) } - factory { GetNoteById(get(), get()) } - factory { com.module.notelycompose.notes.domain.InsertNoteUseCase(get(), get(), get()) } - factory { com.module.notelycompose.notes.domain.UpdateNoteUseCase(get(), get(), get()) } + // Use concrete implementations for now to resolve build issues + factory { DeleteNoteById(get()) } + factory { com.module.notelycompose.notes.domain.GetAllNotesUseCase(get(), get()) } + factory { GetLastNote(get(), get()) } + factory { GetNoteById(get(), get()) } + factory { com.module.notelycompose.notes.domain.InsertNoteUseCase(get(), get(), get()) } + factory { com.module.notelycompose.notes.domain.UpdateNoteUseCase(get(), get(), get()) } // Other use cases that don't need interface changes yet factory { SearchNotesUseCase(get(), get()) } factory { ModelAvailabilityService(get(), get()) } - factory { BackgroundTranscriptionService(get(), get()) } + factory { BackgroundTranscriptionService(get(), get(), get()) } // OpenAI Use Cases factory { TranscribeAudioUseCase(get(), get(), get()) } diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/domain/audio/PlatformAudioPlayer.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/domain/audio/PlatformAudioPlayer.kt deleted file mode 100644 index 58f82d64..00000000 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/domain/audio/PlatformAudioPlayer.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.module.notelycompose.domain.audio - -/** - * Platform-agnostic audio player interface for note playback. - * This interface allows for different implementations on Android and iOS - * while providing a consistent API and enabling easy testing with mock implementations. - */ -interface PlatformAudioPlayer { - /** - * Plays audio from the specified file path. - * - * @param audioPath Absolute path to the audio file - * @throws AudioPlaybackException if playback fails - */ - suspend fun play(audioPath: String) - - /** - * Pauses the currently playing audio. - * No-op if no audio is currently playing. - */ - suspend fun pause() - - /** - * Stops audio playback and resets position to beginning. - */ - suspend fun stop() - - /** - * Seeks to a specific position in the audio file. - * - * @param position Position in milliseconds - */ - suspend fun seekTo(position: Long) - - /** - * Releases audio player resources. - * Should be called when the player is no longer needed. - */ - fun release() -} - -/** - * Exception thrown when audio playback operations fail. - */ -class AudioPlaybackException( - message: String, - cause: Throwable? = null -) : Exception(message, cause) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/DeleteNoteById.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/DeleteNoteById.kt index d4d7bea7..7f9db5b9 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/DeleteNoteById.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/DeleteNoteById.kt @@ -1,10 +1,10 @@ package com.module.notelycompose.notes.domain -import com.module.notelycompose.notes.domain.interfaces.DeleteNoteByIdUseCase +import com.module.notelycompose.notes.domain.interfaces.DeleteNoteByIdUseCaseContract class DeleteNoteById( private val noteDataSource: NoteDataSource -) : DeleteNoteByIdUseCase { +) : DeleteNoteByIdUseCaseContract { override suspend fun execute(id: Long) { return noteDataSource.deleteNoteById(id) } diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/GetAllNotesUseCase.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/GetAllNotesUseCase.kt index 0b7a926b..64f2232f 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/GetAllNotesUseCase.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/GetAllNotesUseCase.kt @@ -3,7 +3,7 @@ package com.module.notelycompose.notes.domain import com.module.notelycompose.core.CommonFlow import com.module.notelycompose.core.asFlow import com.module.notelycompose.core.toCommonFlow -import com.module.notelycompose.notes.domain.interfaces.GetAllNotesUseCase +import com.module.notelycompose.notes.domain.interfaces.GetAllNotesUseCaseContract import com.module.notelycompose.notes.domain.mapper.NoteDomainMapper import com.module.notelycompose.notes.domain.model.NoteDomainModel import com.module.notelycompose.notes.domain.model.NotesFilterDomainModel @@ -16,7 +16,7 @@ import kotlinx.coroutines.flow.map class GetAllNotesUseCase( private val noteDataSource: NoteDataSource, private val noteDomainMapper: NoteDomainMapper -) : com.module.notelycompose.notes.domain.interfaces.GetAllNotesUseCase { +) : GetAllNotesUseCaseContract { override fun execute(): CommonFlow> { return noteDataSource.getNotes().asFlow() .map { notes -> diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/GetLastNote.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/GetLastNote.kt index dd07fded..39b864e8 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/GetLastNote.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/GetLastNote.kt @@ -1,13 +1,13 @@ package com.module.notelycompose.notes.domain -import com.module.notelycompose.notes.domain.interfaces.GetLastNoteUseCase +import com.module.notelycompose.notes.domain.interfaces.GetLastNoteUseCaseContract import com.module.notelycompose.notes.domain.mapper.NoteDomainMapper import com.module.notelycompose.notes.domain.model.NoteDomainModel class GetLastNote( private val noteDataSource: NoteDataSource, private val noteDomainMapper: NoteDomainMapper -) : GetLastNoteUseCase { +) : GetLastNoteUseCaseContract { override fun execute(): NoteDomainModel? { return noteDataSource.getLastNote()?.let { noteDomainMapper.mapToDomainModel(it) } } diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/GetNoteById.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/GetNoteById.kt index 04559c82..046b5673 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/GetNoteById.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/GetNoteById.kt @@ -1,13 +1,13 @@ package com.module.notelycompose.notes.domain -import com.module.notelycompose.notes.domain.interfaces.GetNoteByIdUseCase +import com.module.notelycompose.notes.domain.interfaces.GetNoteByIdUseCaseContract import com.module.notelycompose.notes.domain.mapper.NoteDomainMapper import com.module.notelycompose.notes.domain.model.NoteDomainModel class GetNoteById( private val noteDataSource: NoteDataSource, private val noteDomainMapper: NoteDomainMapper -) : GetNoteByIdUseCase { +) : GetNoteByIdUseCaseContract { override fun execute(id: Long): NoteDomainModel? { return noteDataSource.getNoteById(id)?.let { noteDomainMapper.mapToDomainModel(it) } } diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/InsertNoteUseCase.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/InsertNoteUseCase.kt index 22f78707..5108f000 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/InsertNoteUseCase.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/InsertNoteUseCase.kt @@ -1,6 +1,6 @@ package com.module.notelycompose.notes.domain -import com.module.notelycompose.notes.domain.interfaces.InsertNoteUseCase +import com.module.notelycompose.notes.domain.interfaces.InsertNoteUseCaseContract import com.module.notelycompose.notes.domain.mapper.NoteDomainMapper import com.module.notelycompose.notes.domain.mapper.TextFormatMapper import com.module.notelycompose.notes.domain.model.TextAlignDomainModel @@ -10,7 +10,7 @@ class InsertNoteUseCase( private val noteDataSource: NoteDataSource, private val textFormatMapper: TextFormatMapper, private val noteDomainMapper: NoteDomainMapper -) : com.module.notelycompose.notes.domain.interfaces.InsertNoteUseCase { +) : InsertNoteUseCaseContract { override suspend fun execute( title: String, content: String, @@ -18,12 +18,14 @@ class InsertNoteUseCase( formatting: List, textAlign: TextAlignDomainModel, recordingPath: String - ) = noteDataSource.insertNote( + ) { + noteDataSource.insertNote( title = title, content = content, starred = starred, formatting = formatting.map { textFormatMapper.mapToDataModel(it) }, textAlign = noteDomainMapper.mapTextAlignToDataModel(textAlign), recordingPath = recordingPath - ) + ) + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/UpdateNoteUseCase.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/UpdateNoteUseCase.kt index ce17a909..916a230a 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/UpdateNoteUseCase.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/UpdateNoteUseCase.kt @@ -1,6 +1,6 @@ package com.module.notelycompose.notes.domain -import com.module.notelycompose.notes.domain.interfaces.UpdateNoteUseCase +import com.module.notelycompose.notes.domain.interfaces.UpdateNoteUseCaseContract import com.module.notelycompose.notes.domain.mapper.NoteDomainMapper import com.module.notelycompose.notes.domain.mapper.TextFormatMapper import com.module.notelycompose.notes.domain.model.TextAlignDomainModel @@ -10,7 +10,7 @@ class UpdateNoteUseCase( private val noteDataSource: NoteDataSource, private val textFormatMapper: TextFormatMapper, private val noteDomainMapper: NoteDomainMapper -) : com.module.notelycompose.notes.domain.interfaces.UpdateNoteUseCase { +) : UpdateNoteUseCaseContract { override suspend fun execute( id: Long, title: String, diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/DeleteNoteByIdUseCase.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/DeleteNoteByIdUseCase.kt index 9a5bdea7..f8440b61 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/DeleteNoteByIdUseCase.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/DeleteNoteByIdUseCase.kt @@ -4,7 +4,7 @@ package com.module.notelycompose.notes.domain.interfaces * Interface for deleting a note by its ID. * This contract defines the business logic for note deletion operations. */ -interface DeleteNoteByIdUseCase { +interface DeleteNoteByIdUseCaseContract { /** * Execute the use case to delete a note by ID * @param id The ID of the note to delete diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/GetAllNotesUseCase.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/GetAllNotesUseCase.kt index a239e9a7..0f9ab2d2 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/GetAllNotesUseCase.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/GetAllNotesUseCase.kt @@ -7,7 +7,7 @@ import com.module.notelycompose.notes.domain.model.NoteDomainModel * Interface for retrieving all notes from the repository. * This contract defines the business logic for fetching and ordering notes. */ -interface GetAllNotesUseCase { +interface GetAllNotesUseCaseContract { /** * Execute the use case to get all notes * @return Flow of all notes, sorted by timestamp (most recent first) diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/GetLastNoteUseCase.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/GetLastNoteUseCase.kt index 5dc39f38..897b4c4e 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/GetLastNoteUseCase.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/GetLastNoteUseCase.kt @@ -6,7 +6,7 @@ import com.module.notelycompose.notes.domain.model.NoteDomainModel * Interface for retrieving the most recently created note. * This contract defines the business logic for fetching the last note. */ -interface GetLastNoteUseCase { +interface GetLastNoteUseCaseContract { /** * Execute the use case to get the most recent note * @return The most recent note if any exists, null otherwise diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/GetNoteByIdUseCase.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/GetNoteByIdUseCase.kt index 43893f02..574fa6d1 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/GetNoteByIdUseCase.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/GetNoteByIdUseCase.kt @@ -6,7 +6,7 @@ import com.module.notelycompose.notes.domain.model.NoteDomainModel * Interface for retrieving a note by its ID. * This contract defines the business logic for fetching a specific note. */ -interface GetNoteByIdUseCase { +interface GetNoteByIdUseCaseContract { /** * Execute the use case to get a note by ID * @param id The ID of the note to retrieve diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/InsertNoteUseCase.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/InsertNoteUseCase.kt index 3e6463b0..c510613e 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/InsertNoteUseCase.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/InsertNoteUseCase.kt @@ -7,7 +7,7 @@ import com.module.notelycompose.notes.domain.model.TextFormatDomainModel * Interface for inserting a new note into the repository. * This contract defines the business logic for note creation operations. */ -interface InsertNoteUseCase { +interface InsertNoteUseCaseContract { /** * Execute the use case to insert a new note * @param title The title of the note diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/UpdateNoteUseCase.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/UpdateNoteUseCase.kt index 1562f7fa..6a3de05e 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/UpdateNoteUseCase.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/domain/interfaces/UpdateNoteUseCase.kt @@ -7,7 +7,7 @@ import com.module.notelycompose.notes.domain.model.TextFormatDomainModel * Interface for updating an existing note in the repository. * This contract defines the business logic for note update operations. */ -interface UpdateNoteUseCase { +interface UpdateNoteUseCaseContract { /** * Execute the use case to update an existing note * @param id The ID of the note to update diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/presentation/detail/TextEditorViewModel.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/presentation/detail/TextEditorViewModel.kt index 3481609e..8eefa261 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/presentation/detail/TextEditorViewModel.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/presentation/detail/TextEditorViewModel.kt @@ -386,7 +386,7 @@ class TextEditorViewModel( val recentNoteId = _currentNoteId.value if (recentNoteId == null || recentNoteId == ID_NOT_SET) { try { - val newNoteId = insertNoteUseCase.execute( + insertNoteUseCase.execute( title = title, content = content, starred = starred, @@ -394,9 +394,9 @@ class TextEditorViewModel( textAlign = textAlignPresentationMapper.mapToDomainModel(textAlign), recordingPath = recordingPath ) - newNoteId?.let { id -> - _currentNoteId.value = id - } + // Generate a temporary ID since the use case doesn't return one + val newNoteId = Clock.System.now().toEpochMilliseconds() + _currentNoteId.value = newNoteId } catch (e: Exception) { println("Failed to create new note: ${e.message}") } diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/presentation/list/NoteListViewModel.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/presentation/list/NoteListViewModel.kt index 0f4253a4..c9784623 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/notes/presentation/list/NoteListViewModel.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/notes/presentation/list/NoteListViewModel.kt @@ -4,8 +4,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.module.notelycompose.core.debugPrintln import com.module.notelycompose.core.security.SecurityHelper -import com.module.notelycompose.notes.domain.interfaces.DeleteNoteByIdUseCase -import com.module.notelycompose.notes.domain.interfaces.GetAllNotesUseCase +import com.module.notelycompose.notes.domain.DeleteNoteById +import com.module.notelycompose.notes.domain.GetAllNotesUseCase import com.module.notelycompose.notes.domain.model.NoteDomainModel import com.module.notelycompose.notes.domain.model.NotesFilterDomainModel import com.module.notelycompose.notes.presentation.helpers.getFirstNonEmptyLineAfterFirst @@ -38,7 +38,7 @@ private const val SEARCH_DEBOUNCE = 300L class NoteListViewModel( private val getAllNotesUseCase: GetAllNotesUseCase, - private val deleteNoteById: DeleteNoteByIdUseCase, + private val deleteNoteById: DeleteNoteById, private val notePresentationMapper: NotePresentationMapper, private val notesFilterMapper: NotesFilterMapper, private val securityHelper: SecurityHelper, diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/presentation/texteditor/TextEditorViewModel.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/presentation/texteditor/TextEditorViewModel.kt index b2f8a209..830b5376 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/presentation/texteditor/TextEditorViewModel.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/presentation/texteditor/TextEditorViewModel.kt @@ -2,7 +2,7 @@ package com.module.notelycompose.presentation.texteditor import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.module.notelycompose.domain.audio.PlatformAudioPlayer +import com.module.notelycompose.platform.PlatformAudioPlayer import com.module.notelycompose.domain.model.Note import com.module.notelycompose.domain.repository.NoteRepository import com.module.notelycompose.domain.security.SecurityHelper @@ -76,34 +76,32 @@ class TextEditorViewModel( effectiveScope.launch { try { + val noteId = currentState.noteId?.toLongOrNull() ?: generateNoteId() val note = Note( - id = currentState.noteId ?: generateNoteId(), + id = noteId, title = extractTitle(currentState.content), content = currentState.content, - createdAt = Clock.System.now(), - updatedAt = Clock.System.now(), + timestamp = Clock.System.now().toEpochMilliseconds(), isStarred = currentState.isStarred ) - noteRepository.saveNote(note).fold( - onSuccess = { - _uiState.value = _uiState.value.copy( - isLoading = false, - isSaved = true, - noteId = note.id - ) - }, - onFailure = { error -> - _uiState.value = _uiState.value.copy( - isLoading = false, - error = "Failed to save note: ${error.message}" - ) - } + if (currentState.noteId != null) { + // Update existing note + noteRepository.updateNote(note) + } else { + // Insert new note + noteRepository.insertNote(note) + } + + _uiState.value = _uiState.value.copy( + isLoading = false, + isSaved = true, + noteId = note.id.toString() ) } catch (e: Exception) { _uiState.value = _uiState.value.copy( isLoading = false, - error = "Unexpected error: ${e.message}" + error = "Failed to save note: ${e.message}" ) } } @@ -113,7 +111,8 @@ class TextEditorViewModel( effectiveScope.launch { try { _uiState.value = _uiState.value.copy(isPlayingAudio = true) - audioPlayer.play(audioPath) + audioPlayer.prepare(audioPath) + audioPlayer.play() } catch (e: Exception) { _uiState.value = _uiState.value.copy( isPlayingAudio = false, @@ -127,30 +126,37 @@ class TextEditorViewModel( _uiState.value = _uiState.value.copy(isLoading = true) effectiveScope.launch { - noteRepository.getNote(noteId).fold( - onSuccess = { note -> - if (note != null) { - _uiState.value = _uiState.value.copy( - isLoading = false, - noteId = note.id, - content = note.content, - isStarred = note.isStarred, - isSaved = true - ) - } else { - _uiState.value = _uiState.value.copy( - isLoading = false, - error = "Note not found" - ) - } - }, - onFailure = { error -> + try { + val noteIdLong = noteId.toLongOrNull() + if (noteIdLong == null) { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Invalid note ID format" + ) + return@launch + } + + val note = noteRepository.getNoteById(noteIdLong) + if (note != null) { _uiState.value = _uiState.value.copy( isLoading = false, - error = "Failed to load note: ${error.message}" + noteId = note.id.toString(), + content = note.content, + isStarred = note.isStarred, + isSaved = true + ) + } else { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Note not found" ) } - ) + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Failed to load note: ${e.message}" + ) + } } } @@ -204,8 +210,8 @@ class TextEditorViewModel( ?: "Untitled Note" } - private fun generateNoteId(): String { - return "note_${Clock.System.now().toEpochMilliseconds()}" + private fun generateNoteId(): Long { + return Clock.System.now().toEpochMilliseconds() } } diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/transcription/BackgroundTranscriptionService.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/transcription/BackgroundTranscriptionService.kt index ba3add3c..3355c8ef 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/transcription/BackgroundTranscriptionService.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/transcription/BackgroundTranscriptionService.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.viewModelScope import com.module.notelycompose.core.debugPrintln import com.module.notelycompose.core.validation.AudioFileValidator import com.module.notelycompose.notes.domain.InsertNoteUseCase +import com.module.notelycompose.notes.domain.GetLastNote import com.module.notelycompose.notes.domain.model.TextAlignDomainModel import com.module.notelycompose.transcription.error.TranscriptionError import com.module.notelycompose.transcription.error.isRecoverable @@ -29,7 +30,8 @@ import kotlinx.datetime.toLocalDateTime */ class BackgroundTranscriptionService( private val transcriptionViewModel: TranscriptionViewModel, - private val insertNoteUseCase: InsertNoteUseCase + private val insertNoteUseCase: InsertNoteUseCase, + private val getLastNoteUseCase: GetLastNote ) { private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) @@ -213,14 +215,17 @@ class BackgroundTranscriptionService( } } - return insertNoteUseCase.execute( + insertNoteUseCase.execute( title = title, content = content, starred = false, formatting = emptyList(), // No special formatting for quick records textAlign = TextAlignDomainModel.Left, recordingPath = audioFilePath - ) ?: throw Exception("Failed to create note") + ) + + // Get the ID of the note that was just inserted + return getLastNoteUseCase.execute()?.id ?: throw IllegalStateException("Failed to get note ID after insertion") } /** diff --git a/shared/src/commonTest/kotlin/com/module/notelycompose/integration/ViewModelLifecycleIntegrationTest.kt b/shared/src/commonTest/kotlin/com/module/notelycompose/integration/ViewModelLifecycleIntegrationTest.kt index b90cfd5d..90f0ce39 100644 --- a/shared/src/commonTest/kotlin/com/module/notelycompose/integration/ViewModelLifecycleIntegrationTest.kt +++ b/shared/src/commonTest/kotlin/com/module/notelycompose/integration/ViewModelLifecycleIntegrationTest.kt @@ -1,6 +1,6 @@ package com.module.notelycompose.integration -import com.module.notelycompose.domain.audio.PlatformAudioPlayer +import com.module.notelycompose.platform.PlatformAudioPlayer import com.module.notelycompose.domain.model.Note import com.module.notelycompose.domain.repository.NoteRepository import com.module.notelycompose.domain.security.SecurityHelper @@ -57,7 +57,7 @@ class ViewModelLifecycleIntegrationTest { viewModel.onProcessIntent(TextEditorIntent.UpdateContent("Safe content")) advanceUntilIdle() - val afterUpdateState = viewModel.uiState.awaitValue { it.content.isNotEmpty() } + val afterUpdateState = viewModel.uiState.awaitValue({ it.content.isNotEmpty() }) assertEquals("Safe content", afterUpdateState.content) assertTrue(securityHelper.sanitizeWasCalled) @@ -65,13 +65,13 @@ class ViewModelLifecycleIntegrationTest { viewModel.onProcessIntent(TextEditorIntent.SaveNote) // Verify loading state appears - val loadingState = viewModel.uiState.awaitValue { it.isLoading } + val loadingState = viewModel.uiState.awaitValue({ it.isLoading }) assertTrue(loadingState.isLoading) advanceUntilIdle() // Verify save completed successfully - val savedState = viewModel.uiState.awaitValue { it.isSaved } + val savedState = viewModel.uiState.awaitValue({ it.isSaved }) assertFalse(savedState.isLoading) assertTrue(savedState.isSaved) assertNull(savedState.error) @@ -89,7 +89,7 @@ class ViewModelLifecycleIntegrationTest { viewModel.onProcessIntent(TextEditorIntent.ToggleStar) advanceUntilIdle() - val starredState = viewModel.uiState.awaitValue { it.isStarred } + val starredState = viewModel.uiState.awaitValue({ it.isStarred }) assertTrue(starredState.isStarred) // Test error handling @@ -98,7 +98,7 @@ class ViewModelLifecycleIntegrationTest { viewModel.onProcessIntent(TextEditorIntent.SaveNote) advanceUntilIdle() - val errorState = viewModel.uiState.awaitValue { it.error != null } + val errorState = viewModel.uiState.awaitValue({ it.error != null }) assertNotNull(errorState.error) assertFalse(errorState.isSaved) @@ -106,14 +106,14 @@ class ViewModelLifecycleIntegrationTest { viewModel.onProcessIntent(TextEditorIntent.ClearError) advanceUntilIdle() - val clearedErrorState = viewModel.uiState.awaitValue { it.error == null } + val clearedErrorState = viewModel.uiState.awaitValue({ it.error == null }) assertNull(clearedErrorState.error) // Test long-running task with lifecycle management viewModel.onProcessIntent(TextEditorIntent.StartLongRunningTask) advanceTimeBy(100) // Let task start - val taskStartedState = viewModel.uiState.awaitValue { it.isLoading } + val taskStartedState = viewModel.uiState.awaitValue({ it.isLoading }) assertTrue(taskStartedState.isLoading) // Clear ViewModel (simulate Activity/Fragment destruction) @@ -237,24 +237,30 @@ private class TestNoteRepository : NoteRepository { var saveCallCount = 0 private set - override suspend fun saveNote(note: Note): Result { + override suspend fun insertNote(note: Note) { saveCallCount++ - return if (shouldThrowError) { - Result.failure(Exception("Test repository error")) - } else { - Result.success(Unit) + if (shouldThrowError) { + throw Exception("Test repository error") } } - override suspend fun getNote(id: String): Result { - return Result.success(null) + override suspend fun deleteNote(note: Note) { + if (shouldThrowError) { + throw Exception("Test repository error") + } + } + + override suspend fun getNoteById(id: Long): Note? { + return null } - override suspend fun getAllNotes(): Result> { - return Result.success(emptyList()) + override suspend fun getAllNotes(): List { + return emptyList() } - override suspend fun deleteNote(id: String): Result { - return Result.success(Unit) + override suspend fun updateNote(note: Note) { + if (shouldThrowError) { + throw Exception("Test repository error") + } } } \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/com/module/notelycompose/notes/presentation/detail/TextEditorViewModelTest.kt b/shared/src/commonTest/kotlin/com/module/notelycompose/notes/presentation/detail/TextEditorViewModelTest.kt index 7f6083e9..0e3def19 100644 --- a/shared/src/commonTest/kotlin/com/module/notelycompose/notes/presentation/detail/TextEditorViewModelTest.kt +++ b/shared/src/commonTest/kotlin/com/module/notelycompose/notes/presentation/detail/TextEditorViewModelTest.kt @@ -351,11 +351,11 @@ private class FakeInsertNoteUseCase : InsertNoteUseCase(FakeNoteDataSource(), Te formatting: List, textAlign: TextAlignDomainModel, recordingPath: String - ): Long? { + ) { callCount++ lastTitle = title lastContent = content - return nextId++ + nextId++ } } diff --git a/shared/src/commonTest/kotlin/com/module/notelycompose/presentation/texteditor/TextEditorViewModelTest.kt b/shared/src/commonTest/kotlin/com/module/notelycompose/presentation/texteditor/TextEditorViewModelTest.kt index a5e72a6c..5c7bb9b1 100644 --- a/shared/src/commonTest/kotlin/com/module/notelycompose/presentation/texteditor/TextEditorViewModelTest.kt +++ b/shared/src/commonTest/kotlin/com/module/notelycompose/presentation/texteditor/TextEditorViewModelTest.kt @@ -1,6 +1,6 @@ package com.module.notelycompose.presentation.texteditor -import com.module.notelycompose.domain.audio.PlatformAudioPlayer +import com.module.notelycompose.platform.PlatformAudioPlayer import com.module.notelycompose.domain.model.Note import com.module.notelycompose.domain.repository.NoteRepository import com.module.notelycompose.domain.security.SecurityHelper diff --git a/shared/src/iosMain/kotlin/com/module/notelycompose/core/validation/AudioFileValidator.ios.kt b/shared/src/iosMain/kotlin/com/module/notelycompose/core/validation/AudioFileValidator.ios.kt deleted file mode 100644 index d62f01e2..00000000 --- a/shared/src/iosMain/kotlin/com/module/notelycompose/core/validation/AudioFileValidator.ios.kt +++ /dev/null @@ -1,96 +0,0 @@ -package com.module.notelycompose.core.validation - -import com.module.notelycompose.transcription.error.TranscriptionError -import platform.AVFoundation.AVURLAsset -import platform.AVFoundation.CMTimeGetSeconds -import platform.Foundation.NSFileManager -import platform.Foundation.NSURL -import kotlin.math.roundToLong - -/** - * iOS-specific implementation of file validation functions - */ -actual fun validateFileExists(filePath: String): Boolean { - return try { - NSFileManager.defaultManager.fileExistsAtPath(filePath) - } catch (exception: Exception) { - false - } -} - -actual fun getFileSize(filePath: String): Long? { - return try { - val fileManager = NSFileManager.defaultManager - if (fileManager.fileExistsAtPath(filePath)) { - val attributes = fileManager.attributesOfItemAtPath(filePath, null) - attributes?.get("NSFileSize") as? Long - } else { - null - } - } catch (exception: Exception) { - null - } -} - -actual fun canReadFile(filePath: String): Boolean { - return try { - NSFileManager.defaultManager.isReadableFileAtPath(filePath) - } catch (exception: Exception) { - false - } -} - -actual fun validateCanonicalPath(filePath: String, appDirectory: String): Result { - return try { - val fileManager = NSFileManager.defaultManager - - // Check if app directory exists - if (!fileManager.fileExistsAtPath(appDirectory)) { - return Result.failure( - TranscriptionError.AudioFileValidationError( - message = "App directory does not exist for validation", - filePath = filePath - ) - ) - } - - // For iOS, use basic path validation since canonical path resolution - // is more complex and not as critical in the iOS sandbox environment - if (!filePath.startsWith(appDirectory)) { - Result.failure( - TranscriptionError.AudioFileValidationError( - message = "Invalid file path: path is outside app directory", - filePath = filePath - ) - ) - } else { - Result.success(Unit) - } - } catch (e: Exception) { - Result.failure( - TranscriptionError.AudioFileValidationError( - message = "Path validation failed: ${e.message}", - filePath = filePath - ) - ) - } -} - -actual fun getAudioDurationMs(filePath: String): Long? { - return try { - val url = NSURL.fileURLWithPath(filePath) - val asset = AVURLAsset.URLAssetWithURL(url, null) - val durationSeconds = CMTimeGetSeconds(asset.duration) - - // Convert seconds to milliseconds, handle invalid durations - if (durationSeconds.isFinite() && durationSeconds > 0) { - (durationSeconds * 1000).roundToLong() - } else { - null - } - } catch (e: Exception) { - // Log error but don't throw - return null to indicate failure - println("AudioFileValidator: Failed to get audio duration for $filePath: ${e.message}") - null - } -} \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/com/module/notelycompose/data/audio/IOSAudioPlayer.kt b/shared/src/iosMain/kotlin/com/module/notelycompose/data/audio/IOSAudioPlayer.kt deleted file mode 100644 index 8795c0d1..00000000 --- a/shared/src/iosMain/kotlin/com/module/notelycompose/data/audio/IOSAudioPlayer.kt +++ /dev/null @@ -1,138 +0,0 @@ -package com.module.notelycompose.data.audio - -import com.module.notelycompose.domain.audio.AudioPlaybackException -import com.module.notelycompose.domain.audio.PlatformAudioPlayer -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import platform.AVFoundation.* -import platform.Foundation.NSError -import platform.Foundation.NSURL -import platform.Foundation.fileURLWithPath - -/** - * iOS implementation of PlatformAudioPlayer using AVAudioPlayer. - * This implementation is designed to be open for testing and provides - * robust audio playback capabilities on iOS. - */ -open class IOSAudioPlayer : PlatformAudioPlayer { - - private var audioPlayer: AVAudioPlayer? = null - private var isInitialized = false - - override suspend fun play(audioPath: String) = withContext(Dispatchers.Main) { - try { - release() // Clean up any existing player - - val fileUrl = NSURL.fileURLWithPath(audioPath) - var error: NSError? = null - - audioPlayer = AVAudioPlayer(contentsOfURL = fileUrl, error = error.ptr).also { player -> - if (error != null) { - throw AudioPlaybackException("Failed to create AVAudioPlayer: ${error?.localizedDescription}") - } - - val prepared = player.prepareToPlay() - if (!prepared) { - throw AudioPlaybackException("Failed to prepare audio player") - } - - isInitialized = true - val started = player.play() - if (!started) { - throw AudioPlaybackException("Failed to start audio playback") - } - } - } catch (e: Exception) { - when (e) { - is AudioPlaybackException -> throw e - else -> throw AudioPlaybackException("Failed to play audio: ${e.message}", e) - } - } - } - - override suspend fun pause() = withContext(Dispatchers.Main) { - try { - if (isInitialized && audioPlayer?.playing == true) { - audioPlayer?.pause() - } - } catch (e: Exception) { - throw AudioPlaybackException("Failed to pause audio: ${e.message}", e) - } - } - - override suspend fun stop() = withContext(Dispatchers.Main) { - try { - if (isInitialized) { - audioPlayer?.stop() - audioPlayer?.currentTime = 0.0 - isInitialized = false - } - } catch (e: Exception) { - throw AudioPlaybackException("Failed to stop audio: ${e.message}", e) - } - } - - override suspend fun seekTo(position: Long) = withContext(Dispatchers.Main) { - try { - if (isInitialized) { - val timeInSeconds = position.toDouble() / 1000.0 - audioPlayer?.currentTime = timeInSeconds - } - } catch (e: Exception) { - throw AudioPlaybackException("Failed to seek audio: ${e.message}", e) - } - } - - override fun release() { - try { - audioPlayer?.stop() - audioPlayer = null - isInitialized = false - } catch (e: Exception) { - // Log error but don't throw since this is cleanup - } - } - - /** - * Gets current playback position in milliseconds. - * Returns 0 if player is not initialized. - */ - open fun getCurrentPosition(): Long { - return try { - if (isInitialized) { - ((audioPlayer?.currentTime ?: 0.0) * 1000.0).toLong() - } else { - 0L - } - } catch (e: Exception) { - 0L - } - } - - /** - * Gets total duration in milliseconds. - * Returns 0 if player is not initialized. - */ - open fun getDuration(): Long { - return try { - if (isInitialized) { - ((audioPlayer?.duration ?: 0.0) * 1000.0).toLong() - } else { - 0L - } - } catch (e: Exception) { - 0L - } - } - - /** - * Checks if audio is currently playing. - */ - open fun isPlaying(): Boolean { - return try { - isInitialized && audioPlayer?.playing == true - } catch (e: Exception) { - false - } - } -} \ No newline at end of file diff --git a/shared/src/iosMain/kotlin/com/module/notelycompose/di/PlatformModule.kt b/shared/src/iosMain/kotlin/com/module/notelycompose/di/PlatformModule.kt deleted file mode 100644 index a305fc00..00000000 --- a/shared/src/iosMain/kotlin/com/module/notelycompose/di/PlatformModule.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.module.notelycompose.di - -import com.module.notelycompose.data.audio.IOSAudioPlayer -import com.module.notelycompose.domain.audio.PlatformAudioPlayer -import org.koin.dsl.module - -/** - * iOS-specific platform module providing iOS implementations - * of platform-dependent interfaces. - */ -actual val platformModule = module { - - // Audio Player - iOS implementation - single { - IOSAudioPlayer() - } -} \ No newline at end of file From 05b44ea38d1c7ed229be11fb71f2062b1327783d Mon Sep 17 00:00:00 2001 From: close-hall Date: Tue, 5 Aug 2025 23:02:43 +1000 Subject: [PATCH 19/19] refactor: streamline transcription process and remove deprecated functionality --- .../platform/Transcriber.android.kt | 43 +----- .../ui/importing/ImportingAudioStateHost.kt | 4 +- .../audio/ui/recorder/RecordingScreen.kt | 145 +++--------------- .../com/module/notelycompose/di/Modules.kt | 5 +- .../BackgroundTranscriptionService.kt | 31 ++-- .../transcription/TranscriptionViewModel.kt | 20 ++- .../domain/WhisperModelManager.kt | 7 +- 7 files changed, 61 insertions(+), 194 deletions(-) diff --git a/shared/src/androidMain/kotlin/com/module/notelycompose/platform/Transcriber.android.kt b/shared/src/androidMain/kotlin/com/module/notelycompose/platform/Transcriber.android.kt index 4d84989e..e27f9690 100644 --- a/shared/src/androidMain/kotlin/com/module/notelycompose/platform/Transcriber.android.kt +++ b/shared/src/androidMain/kotlin/com/module/notelycompose/platform/Transcriber.android.kt @@ -11,8 +11,6 @@ import com.module.notelycompose.transcription.domain.WhisperModelLoader import com.module.notelycompose.utils.decodeWaveFile import com.whispercpp.whisper.WhisperCallback import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import java.io.File @@ -132,53 +130,24 @@ actual class Transcriber( // Execute transcription on IO dispatcher to avoid blocking withContext(Dispatchers.IO) { - var segmentReceived = false val text = whisperModelLoader.getContext().transcribeData(data, language, callback = object : WhisperCallback{ override fun onNewSegment(startMs: Long, endMs: Long, text: String) { - segmentReceived = true - // Switch to main thread for callback invocation using structured concurrency - runBlocking { - withContext(Dispatchers.Main) { - onNewSegment(startMs, endMs, text) - } - } + // Direct callback invocation - StateFlow updates are thread-safe + onNewSegment(startMs, endMs, text) } override fun onProgress(progress: Int) { - // Switch to main thread for callback invocation using structured concurrency - runBlocking { - withContext(Dispatchers.Main) { - onProgress(progress) - } - } + // Direct callback invocation - StateFlow updates are thread-safe + onProgress(progress) } override fun onComplete() { - // Switch to main thread for callback invocation using structured concurrency - runBlocking { - withContext(Dispatchers.Main) { - onComplete() - } - } + // Direct callback invocation - StateFlow updates are thread-safe + onComplete() } - }) val elapsed = System.currentTimeMillis() - start debugPrintln{"Done ($elapsed ms): \n$text\n"} - - // Fallback: If no segments were received via callback but we got text from transcribeData, - // manually trigger onNewSegment with the complete text - if (!segmentReceived && text.isNotBlank()) { - debugPrintln { "Transcriber: No segments received via callback, using fallback with complete text" } - runBlocking { - withContext(Dispatchers.Main) { - // Use 0 to duration as timestamp for complete transcription - val durationMs = (data.size / 16).toLong() // Approximate duration in ms for 16kHz audio - onNewSegment(0, durationMs, text.trim()) - onComplete() - } - } - } } } catch (e: Exception) { e.printStackTrace() diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/audio/ui/importing/ImportingAudioStateHost.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/audio/ui/importing/ImportingAudioStateHost.kt index 8952becb..3b371b7a 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/audio/ui/importing/ImportingAudioStateHost.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/audio/ui/importing/ImportingAudioStateHost.kt @@ -17,7 +17,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.module.notelycompose.audio.ui.recorder.RecordingSuccessScreen import com.module.notelycompose.notes.ui.theme.LocalCustomColors import kotlinx.coroutines.delay @@ -37,9 +36,8 @@ internal fun ImportingAudioStateHost( } is ImportingAudioState.Success -> { - RecordingSuccessScreen() + // Immediately navigate to success without animation LaunchedEffect(Unit) { - delay(2000) onSuccess(state.path) onRelease() } diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/audio/ui/recorder/RecordingScreen.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/audio/ui/recorder/RecordingScreen.kt index b92360d7..0f807097 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/audio/ui/recorder/RecordingScreen.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/audio/ui/recorder/RecordingScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.tween -import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -30,6 +29,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -46,14 +46,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.StrokeJoin -import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -168,53 +161,47 @@ fun RecordingScreen( } ScreenState.Success -> { - RecordingSuccessScreen() + // Skip the tick animation and go straight to processing LaunchedEffect(Unit) { if (isQuickRecordMode) { - // Wait for recording path to be available using reactive approach with race condition protection - val recordingPath = withTimeoutOrNull(AppConstants.Recording.RECORDING_PATH_TIMEOUT) { - // Add small delay to ensure state updates are processed - delay(AppConstants.Audio.RACE_CONDITION_DELAY) - // Check current state first in case recording completed before we started waiting - val currentState = viewModel.audioRecorderPresentationState.value - if (currentState.recordingPath.isNotEmpty()) { - currentState.recordingPath - } else { - // Wait for state update if path is not yet available - viewModel.audioRecorderPresentationState.first { it.recordingPath.isNotEmpty() }.recordingPath - } - } + // Simplified path retrieval - direct state access + val recordingPath = viewModel.audioRecorderPresentationState.first { it.recordingPath.isNotEmpty() }.recordingPath if (!recordingPath.isNullOrEmpty()) { - debugPrintln { "Quick record completed: $recordingPath" } - backgroundTranscriptionService.startTranscription( audioFilePath = recordingPath, onComplete = { noteId -> - debugPrintln { "Background transcription completed for note: $noteId" } - // Navigate back to note list after successful transcription and note creation navigateBack() }, onError = { error -> - debugPrintln { "Background transcription failed: ${error.message}" } - // Still update editor with recording path and navigate back + // Fallback: create audio-only note editorViewModel.onUpdateRecordingPath(recordingPath) navigateBack() } ) } else { - debugPrintln { "Quick record failed: Recording path not available after ${AppConstants.Recording.RECORDING_PATH_TIMEOUT}" } - // Fallback: navigate back without transcription navigateBack() } } else { - // Traditional flow with configured delay - delay(AppConstants.Recording.TRADITIONAL_FLOW_DELAY) - debugPrintln { "%%%%%%%%%%% ${recordingState.recordingPath}" } + // Traditional flow - no longer needs delay since no animation editorViewModel.onUpdateRecordingPath(recordingState.recordingPath) navigateBack() } } + + // Show a subtle processing indicator while transcription happens + Box( + modifier = Modifier + .fillMaxSize() + .background(LocalCustomColors.current.bodyBackgroundColor), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(48.dp), + color = MaterialTheme.colorScheme.primary, + strokeWidth = 3.dp + ) + } } } @@ -299,99 +286,7 @@ private fun RecordingInProgressScreen( ) } -@Composable -private fun LoadingAnimation( - isRecordPaused: Boolean -) { - val drawArcColor = LocalCustomColors.current.bodyContentColor - val rotationAngle = remember { Animatable(0f) } - - LaunchedEffect(isRecordPaused) { - if (!isRecordPaused) { - rotationAngle.animateTo( - targetValue = rotationAngle.value + 360f, - animationSpec = infiniteRepeatable( - animation = tween(2000, easing = LinearEasing), - repeatMode = RepeatMode.Restart - ) - ) - } else { - rotationAngle.stop() - } - } - - Canvas(modifier = Modifier.size(200.dp)) { - drawArc( - color = drawArcColor, - startAngle = rotationAngle.value, - sweepAngle = 300f, - useCenter = false, - style = Stroke(width = 4f, cap = StrokeCap.Round) - ) - } -} - -@Composable -internal fun RecordingSuccessScreen() { - val pathColor = LocalCustomColors.current.bodyContentColor - Box( - modifier = Modifier - .fillMaxSize() - .background(LocalCustomColors.current.bodyBackgroundColor), - contentAlignment = Alignment.Center - ) { - var animationPlayed by remember { mutableStateOf(false) } - val pathProgress by animateFloatAsState( - targetValue = if (animationPlayed) 1f else 0f, - animationSpec = tween( - durationMillis = 1000, - easing = FastOutSlowInEasing - ), - label = stringResource(Res.string.recording_ui_checkmark) - ) - - LaunchedEffect(Unit) { - animationPlayed = true - } - - Canvas(modifier = Modifier.size(AppConstants.UI.SUCCESS_ANIMATION_DP.dp)) { - val path = Path().apply { - - addArc( - Rect( - offset = Offset(0f, 0f), - size = Size(size.width, size.height) - ), - 0f, - 360f * pathProgress - ) - if (pathProgress > 0.5f) { - val checkProgress = (pathProgress - 0.5f) * 2f - moveTo(size.width * 0.2f, size.height * 0.5f) - lineTo( - size.width * 0.45f, - size.height * 0.7f * checkProgress - ) - lineTo( - size.width * 0.8f, - size.height * 0.3f * checkProgress - ) - } - } - - drawPath( - path = path, - color = pathColor, - style = Stroke( - width = 8f, - cap = StrokeCap.Round, - join = StrokeJoin.Round - ) - ) - } - } -} @Composable private fun RecordingUiComponentBackButton( diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/di/Modules.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/di/Modules.kt index 1923ec49..90389045 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/di/Modules.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/di/Modules.kt @@ -106,8 +106,7 @@ val repositoryModule = module { singleOf(::PreferencesRepository) single { WhisperModelManager(get()) } single { TranscriptionRepositoryImpl(get(), get()) } - single { SearchHistoryManager(get()) } - single { SearchSuggestionProvider(get(), get()) } + // Search functionality removed - keeping only essential text processing single { TextContentPredictor(get()) } single { LanguageAwareAutoComplete(get()) } single { AiSettingsRepository(get(), get()) } @@ -151,7 +150,7 @@ val useCaseModule = module { factory { com.module.notelycompose.notes.domain.UpdateNoteUseCase(get(), get(), get()) } // Other use cases that don't need interface changes yet - factory { SearchNotesUseCase(get(), get()) } + // SearchNotesUseCase removed - deprecated functionality factory { ModelAvailabilityService(get(), get()) } factory { BackgroundTranscriptionService(get(), get(), get()) } diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/transcription/BackgroundTranscriptionService.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/transcription/BackgroundTranscriptionService.kt index 3355c8ef..47264ebd 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/transcription/BackgroundTranscriptionService.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/transcription/BackgroundTranscriptionService.kt @@ -100,29 +100,22 @@ class BackgroundTranscriptionService( // Start transcription - model is now guaranteed to be ready transcriptionViewModel.startRecognizer(audioFilePath) + // Wait briefly to ensure transcription has started + kotlinx.coroutines.delay(50) + // Monitor transcription progress and wait for completion + var transcriptionStarted = false transcriptionViewModel.uiState.collect { uiState -> - if (!uiState.inTranscription && !cleanupCompleted) { + // Only proceed after we've seen transcription actually start + if (uiState.inTranscription) { + transcriptionStarted = true + } + + if (!uiState.inTranscription && transcriptionStarted && !cleanupCompleted) { cleanupCompleted = true - // For longer transcriptions, wait briefly for fallback mechanism to provide complete text - val transcribedText = if (uiState.originalText.isBlank()) { - debugPrintln { "BackgroundTranscriptionService: Empty text detected, waiting for fallback mechanism..." } - // Short delay to allow fallback logic in Transcriber.android.kt to complete - kotlinx.coroutines.delay(100) - - // Check if text was populated by fallback - val updatedState = transcriptionViewModel.uiState.value - if (updatedState.originalText.isNotBlank()) { - debugPrintln { "BackgroundTranscriptionService: Fallback mechanism provided text: '${updatedState.originalText.take(50)}${if (updatedState.originalText.length > 50) "..." else ""}'" } - updatedState.originalText - } else { - debugPrintln { "BackgroundTranscriptionService: No text available after fallback delay, creating audio-only note" } - uiState.originalText // Will be empty, creating audio-only note - } - } else { - uiState.originalText - } + // Get transcribed text directly from UI state + val transcribedText = uiState.originalText // Create note with final transcription result val noteId = try { diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/transcription/TranscriptionViewModel.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/transcription/TranscriptionViewModel.kt index 24cb600f..fd052583 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/transcription/TranscriptionViewModel.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/transcription/TranscriptionViewModel.kt @@ -77,11 +77,11 @@ class TranscriptionViewModel( } }, onNewSegment = { _, _, text -> - val delimiter = if(_uiState.value.originalText.endsWith(".")) "\n\n" else SPACE_STR debugPrintln{"\n text ========================= $text"} _uiState.update { current -> + val delimiter = if(current.originalText.endsWith(".")) "\n\n" else SPACE_STR current.copy( - originalText = "${_uiState.value.originalText}$delimiter${text.trim()}".trim(), + originalText = "${current.originalText}$delimiter${text.trim()}".trim(), partialText = text ) } @@ -129,6 +129,22 @@ class TranscriptionViewModel( } } + /** + * Direct method for appending transcription segments. + * Used for direct callback integration without repository layer. + */ + fun appendSegment(text: String) { + if (text.isBlank()) return + + _uiState.update { current -> + val delimiter = if(current.originalText.endsWith(".")) "\n\n" else SPACE_STR + current.copy( + originalText = "${current.originalText}$delimiter${text.trim()}".trim(), + partialText = text + ) + } + } + fun summarize() { if (_uiState.value.viewOriginalText) { viewModelScope.launch { diff --git a/shared/src/commonMain/kotlin/com/module/notelycompose/transcription/domain/WhisperModelManager.kt b/shared/src/commonMain/kotlin/com/module/notelycompose/transcription/domain/WhisperModelManager.kt index 069a2b64..f790813b 100644 --- a/shared/src/commonMain/kotlin/com/module/notelycompose/transcription/domain/WhisperModelManager.kt +++ b/shared/src/commonMain/kotlin/com/module/notelycompose/transcription/domain/WhisperModelManager.kt @@ -68,24 +68,21 @@ class WhisperModelManager( } /** - * Marks the start of a transcription session. - * Prevents idle timeout while transcription is active. + * Simplified session tracking - just update usage time */ fun startTranscriptionSession() { activeTranscriptionCount++ updateLastUsageTime() - debugPrintln { "WhisperModelManager: Transcription session started, active count: $activeTranscriptionCount" } } /** - * Marks the end of a transcription session. + * End transcription session */ fun endTranscriptionSession() { if (activeTranscriptionCount > 0) { activeTranscriptionCount-- } updateLastUsageTime() - debugPrintln { "WhisperModelManager: Transcription session ended, active count: $activeTranscriptionCount" } } /**