1
0
mirror of https://github.com/avinal/nikki.git synced 2026-07-03 21:40:09 +05:30

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 <avinal.xlvii@gmail.com>
This commit is contained in:
2026-05-21 13:44:01 +05:30
parent 3070651652
commit e165be9f6c
19 changed files with 1064 additions and 1 deletions
+1
View File
@@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="true"
@@ -1,10 +1,17 @@
package com.avinal.memos
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.CompositionLocalProvider
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.avinal.memos.notifications.TaskNotificationManager
import com.avinal.memos.notifications.scheduleTaskChecker
import com.avinal.memos.util.LocalAppDependencies
class MainActivity : ComponentActivity() {
@@ -20,6 +27,11 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
deps.initialize()
com.avinal.memos.util.appContext = applicationContext
TaskNotificationManager.createChannel(this)
requestNotificationPermission()
scheduleTaskChecker(applicationContext)
enableEdgeToEdge()
setContent {
CompositionLocalProvider(LocalAppDependencies provides deps) {
@@ -27,4 +39,14 @@ class MainActivity : ComponentActivity() {
}
}
}
private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1001)
}
}
}
}
+7
View File
@@ -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)
}
}
}
@@ -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<MemosDatabase>(
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()
}
}
@@ -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)
}
}
@@ -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<TaskCheckWorker>(15, TimeUnit.MINUTES).build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"task_check",
ExistingPeriodicWorkPolicy.KEEP,
request,
)
}
@@ -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") }
}
@@ -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")
@@ -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<String> = tokenStore.accentColor
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "Cobalt")
val notificationsEnabled: StateFlow<Boolean> = 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()
@@ -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<BackupMemo> = emptyList(),
)
@Serializable
data class BackupMemo(
val content: String,
val visibility: String = "PRIVATE",
val pinned: Boolean = false,
val tags: List<String> = emptyList(),
)
object BackupManager {
fun exportToJson(memos: List<Memo>): 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
}
}
}
@@ -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
@@ -13,6 +13,7 @@ class TokenStore(private val dataStore: DataStore<Preferences>) {
val accessToken: Flow<String?> = dataStore.data.map { it[KEY_ACCESS_TOKEN] }
val theme: Flow<String> = dataStore.data.map { it[KEY_THEME] ?: "DARK" }
val accentColor: Flow<String> = dataStore.data.map { it[KEY_ACCENT] ?: "Cobalt" }
val notificationsEnabled: Flow<Boolean> = 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<Preferences>) {
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<Preferences>) {
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")
}
}
@@ -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<String> = ApiResult.Success("hello")
assertIs<ApiResult.Success<String>>(result)
assertEquals("hello", result.data)
}
@Test
fun errorHoldsCodeAndMessage() {
val result: ApiResult<String> = ApiResult.Error(404, "not found")
assertIs<ApiResult.Error>(result)
assertEquals(404, result.code)
assertEquals("not found", result.message)
}
@Test
fun networkErrorHoldsException() {
val ex = RuntimeException("timeout")
val result: ApiResult<String> = ApiResult.NetworkError(ex)
assertIs<ApiResult.NetworkError>(result)
assertEquals("timeout", result.exception.message)
}
}
@@ -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<String> = 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())
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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()))
}
}
}
@@ -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)
}
}
@@ -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 */ }
}