From e165be9f6cbe7471e6735c877342c396857b3acf Mon Sep 17 00:00:00 2001 From: Avinal Kumar Date: Thu, 21 May 2026 13:44:01 +0530 Subject: [PATCH] Add task notifications, JSON backup, and comprehensive test suite Notifications: - TaskCheckWorker (WorkManager, 15-min periodic) scans cached memos for tasks due today or overdue, fires Android notifications - TaskNotificationManager: notification channel, tap-to-open intent - Notification deduplication via SharedPreferences (notified task IDs) - Toggle in settings (on/off persisted in DataStore) - POST_NOTIFICATIONS permission requested on API 33+ JSON Backup: - BackupManager: export all memos to JSON, import from JSON - Export: saves to device via Android SAF (CreateDocument) - Import: reads JSON file, creates memos via API, refreshes cache - Backup format: version, exportedAt, memoCount, array of BackupMemo - Export/import buttons in settings with status feedback Test Suite (67 tests, all passing): - TaskParserTest (29): checkbox parsing, priorities, dates, labels, lists, stable IDs, toggle/replace, line indices, edge cases - DtoMappersTest (16): ID extraction, timestamps, visibility, properties, attachments, reactions, comment count from relations - DtoSerializationTest (7): JSON deserialization, unknown fields, relation object refs, attachment size strings - BackupManagerTest (7): export/import round-trip, version, memo count, invalid JSON handling - MemoVisibilityTest (5): fromApiString, toApiString, round-trip - ApiResultTest (3): Success, Error, NetworkError sealed class Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Avinal Kumar --- androidApp/src/main/AndroidManifest.xml | 1 + .../kotlin/com/avinal/memos/MainActivity.kt | 22 ++ composeApp/build.gradle.kts | 7 + .../memos/notifications/TaskCheckWorker.kt | 81 ++++++ .../notifications/TaskNotificationManager.kt | 54 ++++ .../memos/notifications/WorkManagerSetup.kt | 16 ++ .../memos/util/PlatformSaveFile.android.kt | 49 ++++ .../memos/ui/settings/SettingsScreen.kt | 52 ++++ .../memos/ui/settings/SettingsViewModel.kt | 36 ++- .../com/avinal/memos/util/BackupManager.kt | 56 +++++ .../com/avinal/memos/util/PlatformSaveFile.kt | 9 + .../com/avinal/memos/util/TokenStore.kt | 6 + .../kotlin/com/avinal/memos/ApiResultTest.kt | 33 +++ .../com/avinal/memos/BackupManagerTest.kt | 95 +++++++ .../kotlin/com/avinal/memos/DtoMappersTest.kt | 165 +++++++++++++ .../com/avinal/memos/DtoSerializationTest.kt | 102 ++++++++ .../com/avinal/memos/MemoVisibilityTest.kt | 35 +++ .../kotlin/com/avinal/memos/TaskParserTest.kt | 233 ++++++++++++++++++ .../avinal/memos/util/PlatformSaveFile.ios.kt | 13 + 19 files changed, 1064 insertions(+), 1 deletion(-) create mode 100644 composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskCheckWorker.kt create mode 100644 composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskNotificationManager.kt create mode 100644 composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/WorkManagerSetup.kt create mode 100644 composeApp/src/androidMain/kotlin/com/avinal/memos/util/PlatformSaveFile.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/avinal/memos/util/BackupManager.kt create mode 100644 composeApp/src/commonMain/kotlin/com/avinal/memos/util/PlatformSaveFile.kt create mode 100644 composeApp/src/commonTest/kotlin/com/avinal/memos/ApiResultTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/avinal/memos/BackupManagerTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/avinal/memos/DtoMappersTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/avinal/memos/DtoSerializationTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/avinal/memos/MemoVisibilityTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/avinal/memos/TaskParserTest.kt create mode 100644 composeApp/src/iosMain/kotlin/com/avinal/memos/util/PlatformSaveFile.ios.kt diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index c5733aa..9f1c454 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + = Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) { + ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1001) + } + } + } } diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 34f1343..e753ae7 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -17,6 +17,8 @@ kotlin { compileSdk = 36 minSdk = 26 + withHostTest {} + compilerOptions { jvmTarget.set(JvmTarget.JVM_11) } @@ -68,6 +70,11 @@ kotlin { iosMain.dependencies { implementation(libs.ktor.client.darwin) } + + commonTest.dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.core) + } } } diff --git a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskCheckWorker.kt b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskCheckWorker.kt new file mode 100644 index 0000000..b0f7bb5 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskCheckWorker.kt @@ -0,0 +1,81 @@ +package com.avinal.memos.notifications + +import android.content.Context +import androidx.room.Room +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.avinal.memos.db.MemosDatabase +import com.avinal.memos.db.entity.toDomain +import com.avinal.memos.parser.TaskParser +import kotlinx.coroutines.Dispatchers +import kotlinx.datetime.TimeZone +import kotlinx.datetime.todayIn + +class TaskCheckWorker( + private val context: Context, + params: WorkerParameters, +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + val prefs = context.getSharedPreferences("task_notifications", Context.MODE_PRIVATE) + val notificationsEnabled = prefs.getBoolean("enabled", true) + if (!notificationsEnabled) return Result.success() + + val notifiedIds = prefs.getStringSet("notified_ids", emptySet()) ?: emptySet() + val today = kotlin.time.Clock.System.todayIn(TimeZone.currentSystemDefault()) + + val db = Room.databaseBuilder( + context = context, + name = context.getDatabasePath("memos.db").absolutePath, + ) + .fallbackToDestructiveMigration(true) + .setDriver(BundledSQLiteDriver()) + .setQueryCoroutineContext(Dispatchers.IO) + .build() + + try { + val memos = db.memoDao().getAll().map { it.toDomain() } + val allTasks = memos.flatMap { memo -> TaskParser.extractTasks(memo.id, memo.content) } + + val dueTasks = allTasks.filter { task -> + !task.isCompleted && + task.dueDate != null && + task.dueDate <= today && + task.id !in notifiedIds + } + + val newNotifiedIds = notifiedIds.toMutableSet() + + dueTasks.forEach { task -> + val dueLabel = when { + task.dueDate!! < today -> "overdue" + else -> "due today" + } + val priority = task.priority?.let { " p$it" } ?: "" + TaskNotificationManager.showTaskNotification( + context = context, + notificationId = task.id.hashCode(), + taskText = task.text, + dueLabel = "$dueLabel$priority", + ) + newNotifiedIds.add(task.id) + } + + if (newNotifiedIds.size > notifiedIds.size) { + prefs.edit().putStringSet("notified_ids", newNotifiedIds).apply() + } + + // Clean up old IDs (tasks that no longer exist) + val allTaskIds = allTasks.map { it.id }.toSet() + val cleaned = newNotifiedIds.filter { it in allTaskIds }.toSet() + if (cleaned.size < newNotifiedIds.size) { + prefs.edit().putStringSet("notified_ids", cleaned).apply() + } + } finally { + db.close() + } + + return Result.success() + } +} diff --git a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskNotificationManager.kt b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskNotificationManager.kt new file mode 100644 index 0000000..17d8b1a --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/TaskNotificationManager.kt @@ -0,0 +1,54 @@ +package com.avinal.memos.notifications + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat + +object TaskNotificationManager { + + private const val CHANNEL_ID = "task_reminders" + private const val CHANNEL_NAME = "Task Reminders" + + fun createChannel(context: Context) { + val channel = NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { + description = "Reminders for tasks with due dates" + } + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.createNotificationChannel(channel) + } + + fun showTaskNotification( + context: Context, + notificationId: Int, + taskText: String, + dueLabel: String, + ) { + val openIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + } ?: Intent() + val pendingOpen = PendingIntent.getActivity( + context, notificationId, openIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_popup_reminder) + .setContentTitle(taskText) + .setContentText(dueLabel) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setAutoCancel(true) + .setContentIntent(pendingOpen) + .build() + + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.notify(notificationId, notification) + } +} diff --git a/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/WorkManagerSetup.kt b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/WorkManagerSetup.kt new file mode 100644 index 0000000..2ca147e --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/avinal/memos/notifications/WorkManagerSetup.kt @@ -0,0 +1,16 @@ +package com.avinal.memos.notifications + +import android.content.Context +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import java.util.concurrent.TimeUnit + +fun scheduleTaskChecker(context: Context) { + val request = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES).build() + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + "task_check", + ExistingPeriodicWorkPolicy.KEEP, + request, + ) +} diff --git a/composeApp/src/androidMain/kotlin/com/avinal/memos/util/PlatformSaveFile.android.kt b/composeApp/src/androidMain/kotlin/com/avinal/memos/util/PlatformSaveFile.android.kt new file mode 100644 index 0000000..632cd2f --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/avinal/memos/util/PlatformSaveFile.android.kt @@ -0,0 +1,49 @@ +package com.avinal.memos.util + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +@Composable +actual fun rememberFileSaver(onSaved: (Boolean) -> Unit): (String, String) -> Unit { + val context = LocalContext.current + var pendingContent: String? = null + + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/json"), + ) { uri: Uri? -> + if (uri != null && pendingContent != null) { + try { + context.contentResolver.openOutputStream(uri)?.use { stream -> + stream.write(pendingContent!!.toByteArray()) + } + onSaved(true) + } catch (_: Exception) { + onSaved(false) + } + } + pendingContent = null + } + + return { filename: String, content: String -> + pendingContent = content + launcher.launch(filename) + } +} + +@Composable +actual fun rememberFileLoader(onLoaded: (String) -> Unit): () -> Unit { + val context = LocalContext.current + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent(), + ) { uri: Uri? -> + if (uri == null) return@rememberLauncherForActivityResult + try { + val text = context.contentResolver.openInputStream(uri)?.use { it.bufferedReader().readText() } + if (text != null) onLoaded(text) + } catch (_: Exception) {} + } + return { launcher.launch("application/json") } +} diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsScreen.kt index 37dabc5..ead3df0 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsScreen.kt @@ -124,6 +124,58 @@ fun SettingsScreen( } } + Spacer(Modifier.height(24.dp)) + SectionHeader("notifications") + Spacer(Modifier.height(6.dp)) + + val notificationsOn by viewModel.notificationsEnabled.collectAsState() + Row( + modifier = Modifier.fillMaxWidth().clickable { viewModel.setNotificationsEnabled(!notificationsOn) }.padding(vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("task reminders", fontSize = 15.sp, color = MaterialTheme.colorScheme.onBackground) + Text( + if (notificationsOn) "on" else "off", + fontSize = 14.sp, fontWeight = FontWeight.SemiBold, + color = if (notificationsOn) accent else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text("get notified when tasks are due or overdue", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + + Spacer(Modifier.height(24.dp)) + SectionHeader("backup") + Spacer(Modifier.height(6.dp)) + + var backupStatus by remember { mutableStateOf("") } + + val saveFile = com.avinal.memos.util.rememberFileSaver { success -> + backupStatus = if (success) "backup exported" else "export failed" + } + val loadFile = com.avinal.memos.util.rememberFileLoader { json -> + viewModel.importFromJson(json) { count -> + backupStatus = if (count >= 0) "$count memos imported" else "invalid backup file" + } + } + + Text( + "export backup", + fontSize = 15.sp, color = accent, + modifier = Modifier.clickable { + viewModel.getExportJson { json -> + saveFile("memos-backup.json", json) + } + }.padding(vertical = 6.dp), + ) + Text( + "import backup", + fontSize = 15.sp, color = accent, + modifier = Modifier.clickable { loadFile() }.padding(vertical = 6.dp), + ) + if (backupStatus.isNotEmpty()) { + Text(backupStatus, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp)) + } + Spacer(Modifier.height(36.dp)) SectionHeader("about") SettingsItem("version", "1.0.0") diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsViewModel.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsViewModel.kt index b7f3601..1a9d274 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/ui/settings/SettingsViewModel.kt @@ -3,18 +3,22 @@ package com.avinal.memos.ui.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.avinal.memos.domain.AuthRepository +import com.avinal.memos.domain.Memo import com.avinal.memos.domain.MemoRepository +import com.avinal.memos.domain.MemoVisibility import com.avinal.memos.domain.User import com.avinal.memos.ui.theme.MetroTheme +import com.avinal.memos.util.BackupManager import com.avinal.memos.util.TokenStore import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch class SettingsViewModel( private val authRepository: AuthRepository, - private val tokenStore: TokenStore, + val tokenStore: TokenStore, private val memoRepository: MemoRepository, ) : ViewModel() { @@ -29,6 +33,9 @@ class SettingsViewModel( val currentAccent: StateFlow = tokenStore.accentColor .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "Cobalt") + val notificationsEnabled: StateFlow = tokenStore.notificationsEnabled + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true) + init { viewModelScope.launch { authRepository.validateToken() } } @@ -41,6 +48,33 @@ class SettingsViewModel( viewModelScope.launch { tokenStore.saveAccentColor(name) } } + fun setNotificationsEnabled(enabled: Boolean) { + viewModelScope.launch { tokenStore.saveNotificationsEnabled(enabled) } + } + + fun getExportJson(onResult: (String) -> Unit) { + viewModelScope.launch { + val memos = memoRepository.observeMemos().first() + onResult(BackupManager.exportToJson(memos)) + } + } + + fun importFromJson(json: String, onResult: (Int) -> Unit) { + viewModelScope.launch { + val backup = BackupManager.parseFromJson(json) + if (backup == null) { onResult(-1); return@launch } + + var imported = 0 + backup.memos.forEach { backupMemo -> + val visibility = MemoVisibility.fromApiString(backupMemo.visibility) + val result = memoRepository.createMemo(backupMemo.content, visibility) + if (result is com.avinal.memos.api.ApiResult.Success) imported++ + } + memoRepository.refreshMemos() + onResult(imported) + } + } + fun logout() { viewModelScope.launch { memoRepository.clearCache() diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/util/BackupManager.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/util/BackupManager.kt new file mode 100644 index 0000000..8427d02 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/util/BackupManager.kt @@ -0,0 +1,56 @@ +package com.avinal.memos.util + +import com.avinal.memos.domain.Memo +import com.avinal.memos.domain.MemoVisibility +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +private val backupJson = Json { + prettyPrint = true + ignoreUnknownKeys = true +} + +@Serializable +data class BackupData( + val version: Int = 1, + val exportedAt: String = "", + val memoCount: Int = 0, + val memos: List = emptyList(), +) + +@Serializable +data class BackupMemo( + val content: String, + val visibility: String = "PRIVATE", + val pinned: Boolean = false, + val tags: List = emptyList(), +) + +object BackupManager { + + fun exportToJson(memos: List): String { + val now = kotlin.time.Clock.System.now().toString() + val data = BackupData( + version = 1, + exportedAt = now, + memoCount = memos.size, + memos = memos.map { memo -> + BackupMemo( + content = memo.content, + visibility = memo.visibility.toApiString(), + pinned = memo.pinned, + tags = memo.tags, + ) + }, + ) + return backupJson.encodeToString(BackupData.serializer(), data) + } + + fun parseFromJson(json: String): BackupData? { + return try { + backupJson.decodeFromString(BackupData.serializer(), json) + } catch (_: Exception) { + null + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/util/PlatformSaveFile.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/util/PlatformSaveFile.kt new file mode 100644 index 0000000..59de1a2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/util/PlatformSaveFile.kt @@ -0,0 +1,9 @@ +package com.avinal.memos.util + +import androidx.compose.runtime.Composable + +@Composable +expect fun rememberFileSaver(onSaved: (Boolean) -> Unit): (String, String) -> Unit + +@Composable +expect fun rememberFileLoader(onLoaded: (String) -> Unit): () -> Unit diff --git a/composeApp/src/commonMain/kotlin/com/avinal/memos/util/TokenStore.kt b/composeApp/src/commonMain/kotlin/com/avinal/memos/util/TokenStore.kt index 003c7cf..c1657a9 100644 --- a/composeApp/src/commonMain/kotlin/com/avinal/memos/util/TokenStore.kt +++ b/composeApp/src/commonMain/kotlin/com/avinal/memos/util/TokenStore.kt @@ -13,6 +13,7 @@ class TokenStore(private val dataStore: DataStore) { val accessToken: Flow = dataStore.data.map { it[KEY_ACCESS_TOKEN] } val theme: Flow = dataStore.data.map { it[KEY_THEME] ?: "DARK" } val accentColor: Flow = dataStore.data.map { it[KEY_ACCENT] ?: "Cobalt" } + val notificationsEnabled: Flow = dataStore.data.map { (it[KEY_NOTIFICATIONS] ?: "true") == "true" } suspend fun saveCredentials(serverUrl: String, token: String) { dataStore.edit { prefs -> @@ -29,6 +30,10 @@ class TokenStore(private val dataStore: DataStore) { dataStore.edit { it[KEY_ACCENT] = name } } + suspend fun saveNotificationsEnabled(enabled: Boolean) { + dataStore.edit { it[KEY_NOTIFICATIONS] = enabled.toString() } + } + suspend fun clear() { dataStore.edit { val theme = it[KEY_THEME] @@ -44,5 +49,6 @@ class TokenStore(private val dataStore: DataStore) { private val KEY_ACCESS_TOKEN = stringPreferencesKey("access_token") private val KEY_THEME = stringPreferencesKey("app_theme") private val KEY_ACCENT = stringPreferencesKey("accent_color") + private val KEY_NOTIFICATIONS = stringPreferencesKey("notifications_enabled") } } diff --git a/composeApp/src/commonTest/kotlin/com/avinal/memos/ApiResultTest.kt b/composeApp/src/commonTest/kotlin/com/avinal/memos/ApiResultTest.kt new file mode 100644 index 0000000..57b0a4b --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/avinal/memos/ApiResultTest.kt @@ -0,0 +1,33 @@ +package com.avinal.memos + +import com.avinal.memos.api.ApiResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class ApiResultTest { + + @Test + fun successHoldsData() { + val result: ApiResult = ApiResult.Success("hello") + assertIs>(result) + assertEquals("hello", result.data) + } + + @Test + fun errorHoldsCodeAndMessage() { + val result: ApiResult = ApiResult.Error(404, "not found") + assertIs(result) + assertEquals(404, result.code) + assertEquals("not found", result.message) + } + + @Test + fun networkErrorHoldsException() { + val ex = RuntimeException("timeout") + val result: ApiResult = ApiResult.NetworkError(ex) + assertIs(result) + assertEquals("timeout", result.exception.message) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/avinal/memos/BackupManagerTest.kt b/composeApp/src/commonTest/kotlin/com/avinal/memos/BackupManagerTest.kt new file mode 100644 index 0000000..b133ded --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/avinal/memos/BackupManagerTest.kt @@ -0,0 +1,95 @@ +package com.avinal.memos + +import com.avinal.memos.domain.Attachment +import com.avinal.memos.domain.Memo +import com.avinal.memos.domain.MemoVisibility +import com.avinal.memos.domain.Reaction +import com.avinal.memos.util.BackupManager +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Instant + +class BackupManagerTest { + + private fun makeMemo( + id: String = "m1", + content: String = "hello", + visibility: MemoVisibility = MemoVisibility.PRIVATE, + pinned: Boolean = false, + tags: List = emptyList(), + ) = Memo( + id = id, uid = "", content = content, visibility = visibility, + pinned = pinned, state = "NORMAL", + createTime = Instant.DISTANT_PAST, updateTime = Instant.DISTANT_PAST, + displayTime = Instant.DISTANT_PAST, creator = "test", + hasTaskList = false, hasIncompleteTasks = false, title = "", + tags = tags, snippet = "", + ) + + @Test + fun exportProducesValidJson() { + val memos = listOf(makeMemo()) + val json = BackupManager.exportToJson(memos) + assertNotNull(json) + assertTrue(json.isNotEmpty(), "json should not be empty") + val parsed = BackupManager.parseFromJson(json) + assertNotNull(parsed, "should be parseable back") + assertEquals(1, parsed.memos.size) + assertEquals("hello", parsed.memos[0].content) + } + + @Test + fun exportImportRoundTrip() { + val original = listOf( + makeMemo(id = "1", content = "First memo", tags = listOf("work")), + makeMemo(id = "2", content = "Second memo", visibility = MemoVisibility.PUBLIC, pinned = true), + ) + val json = BackupManager.exportToJson(original) + val parsed = BackupManager.parseFromJson(json) + assertNotNull(parsed) + assertEquals(2, parsed.memos.size) + assertEquals("First memo", parsed.memos[0].content) + assertEquals("Second memo", parsed.memos[1].content) + assertEquals("PRIVATE", parsed.memos[0].visibility) + assertEquals("PUBLIC", parsed.memos[1].visibility) + assertTrue(parsed.memos[1].pinned) + assertEquals(listOf("work"), parsed.memos[0].tags) + } + + @Test + fun exportSetsVersion() { + val json = BackupManager.exportToJson(emptyList()) + val parsed = BackupManager.parseFromJson(json) + assertNotNull(parsed) + assertEquals(1, parsed.version) + } + + @Test + fun exportSetsMemoCount() { + val json = BackupManager.exportToJson(listOf(makeMemo(), makeMemo(id = "2"))) + val parsed = BackupManager.parseFromJson(json) + assertEquals(2, parsed!!.memoCount) + } + + @Test + fun parseInvalidJsonReturnsNull() { + assertNull(BackupManager.parseFromJson("not valid json")) + } + + @Test + fun parseEmptyObjectReturnsDefaults() { + val parsed = BackupManager.parseFromJson("{}") + assertNotNull(parsed) + assertEquals(0, parsed.memos.size) + } + + @Test + fun exportedAtIsNonEmpty() { + val json = BackupManager.exportToJson(emptyList()) + val parsed = BackupManager.parseFromJson(json) + assertTrue(parsed!!.exportedAt.isNotEmpty()) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/avinal/memos/DtoMappersTest.kt b/composeApp/src/commonTest/kotlin/com/avinal/memos/DtoMappersTest.kt new file mode 100644 index 0000000..ffa81c5 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/avinal/memos/DtoMappersTest.kt @@ -0,0 +1,165 @@ +package com.avinal.memos + +import com.avinal.memos.api.model.AttachmentDto +import com.avinal.memos.api.model.MemoDto +import com.avinal.memos.api.model.MemoPropertyDto +import com.avinal.memos.api.model.ReactionDto +import com.avinal.memos.api.model.RelationDto +import com.avinal.memos.api.model.RelationRefDto +import com.avinal.memos.api.model.UserDto +import com.avinal.memos.api.model.toDomain +import com.avinal.memos.domain.MemoVisibility +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DtoMappersTest { + + @Test + fun memoIdExtractedFromName() { + val dto = MemoDto(name = "memos/abc123def") + val memo = dto.toDomain() + assertEquals("abc123def", memo.id) + } + + @Test + fun creatorIdExtractedFromName() { + val dto = MemoDto(creator = "users/avinal") + val memo = dto.toDomain() + assertEquals("avinal", memo.creator) + } + + @Test + fun visibilityMapped() { + assertEquals(MemoVisibility.PUBLIC, MemoDto(visibility = "PUBLIC").toDomain().visibility) + assertEquals(MemoVisibility.PRIVATE, MemoDto(visibility = "PRIVATE").toDomain().visibility) + assertEquals(MemoVisibility.PROTECTED, MemoDto(visibility = "PROTECTED").toDomain().visibility) + } + + @Test + fun timestampsParsed() { + val dto = MemoDto( + createTime = "2026-05-13T09:10:31Z", + updateTime = "2026-05-13T09:10:31Z", + ) + val memo = dto.toDomain() + assertTrue(memo.createTime.toEpochMilliseconds() > 0) + } + + @Test + fun emptyTimestampHandled() { + val dto = MemoDto(createTime = "", updateTime = "") + val memo = dto.toDomain() + assertEquals(kotlin.time.Instant.DISTANT_PAST, memo.createTime) + } + + @Test + fun invalidTimestampHandled() { + val dto = MemoDto(createTime = "not-a-date") + val memo = dto.toDomain() + assertEquals(kotlin.time.Instant.DISTANT_PAST, memo.createTime) + } + + @Test + fun propertyFieldsMapped() { + val dto = MemoDto(property = MemoPropertyDto(hasTaskList = true, hasIncompleteTasks = true, title = "My Title")) + val memo = dto.toDomain() + assertTrue(memo.hasTaskList) + assertTrue(memo.hasIncompleteTasks) + assertEquals("My Title", memo.title) + } + + @Test + fun nullPropertyHandled() { + val dto = MemoDto(property = null) + val memo = dto.toDomain() + assertFalse(memo.hasTaskList) + assertEquals("", memo.title) + } + + @Test + fun tagsMapped() { + val dto = MemoDto(tags = listOf("work", "urgent")) + val memo = dto.toDomain() + assertEquals(listOf("work", "urgent"), memo.tags) + } + + @Test + fun attachmentsMapped() { + val dto = MemoDto(attachments = listOf( + AttachmentDto(name = "attachments/abc", filename = "img.jpg", type = "image/jpeg", size = "1024") + )) + val memo = dto.toDomain() + assertEquals(1, memo.attachments.size) + assertEquals("img.jpg", memo.attachments[0].filename) + assertEquals("image/jpeg", memo.attachments[0].mimeType) + assertTrue(memo.attachments[0].isImage) + assertEquals(1024L, memo.attachments[0].size) + } + + @Test + fun nonImageAttachment() { + val dto = MemoDto(attachments = listOf( + AttachmentDto(type = "application/pdf") + )) + assertFalse(dto.toDomain().attachments[0].isImage) + } + + @Test + fun reactionsMapped() { + val dto = MemoDto(reactions = listOf( + ReactionDto(name = "memos/m1/reactions/1", creator = "users/avinal", reactionType = "❤️") + )) + val memo = dto.toDomain() + assertEquals(1, memo.reactions.size) + assertEquals("❤️", memo.reactions[0].reactionType) + assertEquals("avinal", memo.reactions[0].creator) + } + + @Test + fun commentCountFromRelations() { + val dto = MemoDto( + name = "memos/m1", + relations = listOf( + RelationDto( + memo = RelationRefDto(name = "memos/comment1"), + relatedMemo = RelationRefDto(name = "memos/m1"), + type = "COMMENT", + ), + RelationDto( + memo = RelationRefDto(name = "memos/comment2"), + relatedMemo = RelationRefDto(name = "memos/m1"), + type = "COMMENT", + ), + RelationDto( + memo = RelationRefDto(name = "memos/m1"), + relatedMemo = RelationRefDto(name = "memos/m2"), + type = "REFERENCE", + ), + ), + ) + assertEquals(2, dto.toDomain().commentCount) + } + + @Test + fun userDtoMapped() { + val dto = UserDto(name = "users/avinal", username = "avinal", nickname = "Avinal", email = "a@b.com", role = "USER") + val user = dto.toDomain() + assertEquals("avinal", user.id) + assertEquals("avinal", user.username) + assertEquals("Avinal", user.nickname) + } + + @Test + fun attachmentSizeStringParsed() { + val dto = AttachmentDto(size = "197065") + assertEquals(197065L, dto.toDomain().size) + } + + @Test + fun attachmentSizeInvalidStringDefaultsToZero() { + val dto = AttachmentDto(size = "not-a-number") + assertEquals(0L, dto.toDomain().size) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/avinal/memos/DtoSerializationTest.kt b/composeApp/src/commonTest/kotlin/com/avinal/memos/DtoSerializationTest.kt new file mode 100644 index 0000000..8ab5167 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/avinal/memos/DtoSerializationTest.kt @@ -0,0 +1,102 @@ +package com.avinal.memos + +import com.avinal.memos.api.model.ListMemosResponse +import com.avinal.memos.api.model.MemoDto +import com.avinal.memos.api.model.MemoPropertyDto +import com.avinal.memos.api.model.AttachmentDto +import com.avinal.memos.api.model.ReactionDto +import com.avinal.memos.api.model.RelationDto +import com.avinal.memos.api.model.RelationRefDto +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DtoSerializationTest { + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun deserializeMinimalMemo() { + val str = """{"name":"memos/abc","content":"hello"}""" + val dto = json.decodeFromString(MemoDto.serializer(), str) + assertEquals("memos/abc", dto.name) + assertEquals("hello", dto.content) + assertEquals("NORMAL", dto.state) + assertEquals("PRIVATE", dto.visibility) + } + + @Test + fun deserializeFullMemo() { + val str = """ + { + "name":"memos/xyz", + "uid":"uid1", + "creator":"users/avinal", + "createTime":"2026-05-13T09:10:31Z", + "updateTime":"2026-05-13T09:10:31Z", + "content":"# Hello", + "state":"NORMAL", + "visibility":"PRIVATE", + "pinned":true, + "tags":["work","devops"], + "snippet":"Hello", + "attachments":[{"name":"attachments/a1","filename":"img.jpg","type":"image/jpeg","size":"1024"}], + "reactions":[{"name":"memos/xyz/reactions/1","creator":"users/avinal","reactionType":"❤️","createTime":"2026-05-14T13:29:20Z"}], + "relations":[{"memo":{"name":"memos/c1","snippet":""},"relatedMemo":{"name":"memos/xyz","snippet":""},"type":"COMMENT"}], + "property":{"hasLink":false,"hasTaskList":true,"hasCode":false,"hasIncompleteTasks":true,"title":"Hello"} + } + """.trimIndent() + val dto = json.decodeFromString(MemoDto.serializer(), str) + assertEquals("memos/xyz", dto.name) + assertTrue(dto.pinned) + assertEquals(listOf("work", "devops"), dto.tags) + assertEquals(1, dto.attachments.size) + assertEquals("image/jpeg", dto.attachments[0].type) + assertEquals(1, dto.reactions.size) + assertEquals("❤️", dto.reactions[0].reactionType) + assertEquals(1, dto.relations.size) + assertEquals("COMMENT", dto.relations[0].type) + assertEquals("memos/c1", dto.relations[0].memo.name) + assertTrue(dto.property!!.hasTaskList) + } + + @Test + fun deserializeListMemosResponse() { + val str = """{"memos":[{"name":"memos/1","content":"a"},{"name":"memos/2","content":"b"}],"nextPageToken":"abc123"}""" + val response = json.decodeFromString(ListMemosResponse.serializer(), str) + assertEquals(2, response.memos.size) + assertEquals("abc123", response.nextPageToken) + } + + @Test + fun deserializeEmptyListMemosResponse() { + val str = """{"memos":[],"nextPageToken":""}""" + val response = json.decodeFromString(ListMemosResponse.serializer(), str) + assertTrue(response.memos.isEmpty()) + assertEquals("", response.nextPageToken) + } + + @Test + fun unknownFieldsIgnored() { + val str = """{"name":"memos/1","content":"hello","someNewField":"value","anotherField":42}""" + val dto = json.decodeFromString(MemoDto.serializer(), str) + assertEquals("hello", dto.content) + } + + @Test + fun relationWithObjectRefs() { + val str = """{"memo":{"name":"memos/c1","snippet":"comment"},"relatedMemo":{"name":"memos/m1","snippet":"parent"},"type":"COMMENT"}""" + val rel = json.decodeFromString(RelationDto.serializer(), str) + assertEquals("memos/c1", rel.memo.name) + assertEquals("memos/m1", rel.relatedMemo.name) + assertEquals("COMMENT", rel.type) + } + + @Test + fun attachmentSizeAsString() { + val str = """{"name":"attachments/a1","filename":"doc.pdf","type":"application/pdf","size":"999999"}""" + val att = json.decodeFromString(AttachmentDto.serializer(), str) + assertEquals("999999", att.size) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/avinal/memos/MemoVisibilityTest.kt b/composeApp/src/commonTest/kotlin/com/avinal/memos/MemoVisibilityTest.kt new file mode 100644 index 0000000..65c1e27 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/avinal/memos/MemoVisibilityTest.kt @@ -0,0 +1,35 @@ +package com.avinal.memos + +import com.avinal.memos.domain.MemoVisibility +import kotlin.test.Test +import kotlin.test.assertEquals + +class MemoVisibilityTest { + + @Test + fun fromApiStringPrivate() { + assertEquals(MemoVisibility.PRIVATE, MemoVisibility.fromApiString("PRIVATE")) + } + + @Test + fun fromApiStringPublic() { + assertEquals(MemoVisibility.PUBLIC, MemoVisibility.fromApiString("PUBLIC")) + } + + @Test + fun fromApiStringProtected() { + assertEquals(MemoVisibility.PROTECTED, MemoVisibility.fromApiString("PROTECTED")) + } + + @Test + fun fromApiStringUnknownDefaultsToPrivate() { + assertEquals(MemoVisibility.PRIVATE, MemoVisibility.fromApiString("UNKNOWN")) + } + + @Test + fun toApiStringRoundTrips() { + MemoVisibility.entries.forEach { vis -> + assertEquals(vis, MemoVisibility.fromApiString(vis.toApiString())) + } + } +} diff --git a/composeApp/src/commonTest/kotlin/com/avinal/memos/TaskParserTest.kt b/composeApp/src/commonTest/kotlin/com/avinal/memos/TaskParserTest.kt new file mode 100644 index 0000000..06a71cd --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/avinal/memos/TaskParserTest.kt @@ -0,0 +1,233 @@ +package com.avinal.memos + +import com.avinal.memos.parser.TaskParser +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TaskParserTest { + + @Test + fun extractsBasicUncheckedTask() { + val tasks = TaskParser.extractTasks("m1", "- [ ] Buy milk") + assertEquals(1, tasks.size) + assertEquals("Buy milk", tasks[0].text) + assertFalse(tasks[0].isCompleted) + } + + @Test + fun extractsCheckedTask() { + val tasks = TaskParser.extractTasks("m1", "- [x] Done task") + assertEquals(1, tasks.size) + assertTrue(tasks[0].isCompleted) + } + + @Test + fun extractsCheckedTaskUppercaseX() { + val tasks = TaskParser.extractTasks("m1", "- [X] Done task") + assertEquals(1, tasks.size) + assertTrue(tasks[0].isCompleted) + } + + @Test + fun extractsPriority() { + val tasks = TaskParser.extractTasks("m1", "- [ ] Fix bug p1") + assertEquals(1, tasks[0].priority) + assertEquals("Fix bug", tasks[0].text) + } + + @Test + fun extractsAllPriorities() { + val content = """ + - [ ] Task A p1 + - [ ] Task B p2 + - [ ] Task C p3 + """.trimIndent() + val tasks = TaskParser.extractTasks("m1", content) + assertEquals(3, tasks.size) + assertEquals(1, tasks[0].priority) + assertEquals(2, tasks[1].priority) + assertEquals(3, tasks[2].priority) + } + + @Test + fun extractsExplicitDate() { + val tasks = TaskParser.extractTasks("m1", "- [ ] Review PR 2026-05-25") + assertNotNull(tasks[0].dueDate) + assertEquals(2026, tasks[0].dueDate!!.year) + assertEquals(25, tasks[0].dueDate!!.dayOfMonth) + } + + @Test + fun extractsTodayKeyword() { + val tasks = TaskParser.extractTasks("m1", "- [ ] Do thing @today") + assertNotNull(tasks[0].dueDate) + } + + @Test + fun extractsTomorrowKeyword() { + val tasks = TaskParser.extractTasks("m1", "- [ ] Do thing @tomorrow") + assertNotNull(tasks[0].dueDate) + } + + @Test + fun extractsLabels() { + val tasks = TaskParser.extractTasks("m1", "- [ ] Fix @backend @urgent") + assertEquals(listOf("backend", "urgent"), tasks[0].labels) + } + + @Test + fun labelsExcludeDateKeywords() { + val tasks = TaskParser.extractTasks("m1", "- [ ] Fix @today @backend") + assertEquals(listOf("backend"), tasks[0].labels) + } + + @Test + fun extractsLists() { + val tasks = TaskParser.extractTasks("m1", "- [ ] Review #work #devops") + assertEquals(listOf("work", "devops"), tasks[0].lists) + } + + @Test + fun listsIgnoreNumericStart() { + val tasks = TaskParser.extractTasks("m1", "- [ ] Task #123") + assertTrue(tasks[0].lists.isEmpty()) + } + + @Test + fun cleansMetadataFromText() { + val tasks = TaskParser.extractTasks("m1", "- [ ] Buy groceries @today p2 @shopping #personal") + assertEquals("Buy groceries", tasks[0].text) + } + + @Test + fun multipleTasksFromOneMemo() { + val content = """ + Some text + - [ ] Task 1 + - [x] Task 2 + - [ ] Task 3 + More text + """.trimIndent() + val tasks = TaskParser.extractTasks("m1", content) + assertEquals(3, tasks.size) + assertFalse(tasks[0].isCompleted) + assertTrue(tasks[1].isCompleted) + assertFalse(tasks[2].isCompleted) + } + + @Test + fun noTasksInPlainText() { + val tasks = TaskParser.extractTasks("m1", "Just some text\nNo tasks here") + assertTrue(tasks.isEmpty()) + } + + @Test + fun indentedTasksWork() { + val tasks = TaskParser.extractTasks("m1", " - [ ] Indented task") + assertEquals(1, tasks.size) + assertEquals("Indented task", tasks[0].text) + } + + @Test + fun stableIdsAcrossParses() { + val content = "- [ ] Task A\n- [ ] Task B" + val first = TaskParser.extractTasks("m1", content) + val second = TaskParser.extractTasks("m1", content) + assertEquals(first[0].id, second[0].id) + assertEquals(first[1].id, second[1].id) + } + + @Test + fun differentMemosProduceDifferentIds() { + val content = "- [ ] Same task" + val fromMemo1 = TaskParser.extractTasks("m1", content) + val fromMemo2 = TaskParser.extractTasks("m2", content) + assertTrue(fromMemo1[0].id != fromMemo2[0].id) + } + + @Test + fun reconstructLinePreservesMetadata() { + val tasks = TaskParser.extractTasks("m1", "- [ ] Buy milk @today p2 #personal") + val reconstructed = TaskParser.reconstructLine(tasks[0]) + assertTrue(reconstructed.startsWith("- [ ]")) + assertTrue(reconstructed.contains("Buy milk")) + assertTrue(reconstructed.contains("p2")) + assertTrue(reconstructed.contains("#personal")) + } + + @Test + fun reconstructLineForCompletedTask() { + val tasks = TaskParser.extractTasks("m1", "- [x] Done task p1") + val reconstructed = TaskParser.reconstructLine(tasks[0]) + assertTrue(reconstructed.startsWith("- [x]")) + } + + @Test + fun toggleTaskInContentChecks() { + val content = "some text\n- [ ] Task A\n- [ ] Task B\nmore" + val tasks = TaskParser.extractTasks("m1", content) + val toggled = TaskParser.toggleTaskInContent(content, tasks[0]) + assertTrue(toggled.contains("- [x] Task A")) + assertTrue(toggled.contains("- [ ] Task B")) + } + + @Test + fun toggleTaskInContentUnchecks() { + val content = "- [x] Done\n- [ ] Not done" + val tasks = TaskParser.extractTasks("m1", content) + val toggled = TaskParser.toggleTaskInContent(content, tasks[0]) + assertTrue(toggled.contains("- [ ] Done")) + } + + @Test + fun replaceTaskLineInContent() { + val content = "line1\n- [ ] Old task\nline3" + val tasks = TaskParser.extractTasks("m1", content) + val replaced = TaskParser.replaceTaskLineInContent(content, tasks[0], "- [ ] New task p1") + assertTrue(replaced.contains("- [ ] New task p1")) + assertFalse(replaced.contains("Old task")) + } + + @Test + fun noPriorityReturnsNull() { + val tasks = TaskParser.extractTasks("m1", "- [ ] Simple task") + assertNull(tasks[0].priority) + } + + @Test + fun noDateReturnsNull() { + val tasks = TaskParser.extractTasks("m1", "- [ ] Simple task") + assertNull(tasks[0].dueDate) + } + + @Test + fun originalLinePreserved() { + val line = "- [ ] Buy groceries @today p2 #personal" + val tasks = TaskParser.extractTasks("m1", line) + assertEquals(line, tasks[0].originalLine) + } + + @Test + fun bulletListNotParsedAsTask() { + val tasks = TaskParser.extractTasks("m1", "- Regular list item\n* Another item") + assertTrue(tasks.isEmpty()) + } + + @Test + fun memoIdStoredCorrectly() { + val tasks = TaskParser.extractTasks("memo123", "- [ ] Task") + assertEquals("memo123", tasks[0].memoId) + } + + @Test + fun lineIndexCorrect() { + val content = "header\n\n- [ ] First\ntext\n- [ ] Second" + val tasks = TaskParser.extractTasks("m1", content) + assertEquals(2, tasks[0].lineIndex) + assertEquals(4, tasks[1].lineIndex) + } +} diff --git a/composeApp/src/iosMain/kotlin/com/avinal/memos/util/PlatformSaveFile.ios.kt b/composeApp/src/iosMain/kotlin/com/avinal/memos/util/PlatformSaveFile.ios.kt new file mode 100644 index 0000000..9360429 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/avinal/memos/util/PlatformSaveFile.ios.kt @@ -0,0 +1,13 @@ +package com.avinal.memos.util + +import androidx.compose.runtime.Composable + +@Composable +actual fun rememberFileSaver(onSaved: (Boolean) -> Unit): (String, String) -> Unit { + return { _, _ -> /* TODO: iOS file saver */ } +} + +@Composable +actual fun rememberFileLoader(onLoaded: (String) -> Unit): () -> Unit { + return { /* TODO: iOS file loader */ } +}