mirror of
https://github.com/avinal/nikki.git
synced 2026-07-04 05:50:10 +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:
@@ -2,6 +2,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
package com.avinal.memos
|
package com.avinal.memos
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
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
|
import com.avinal.memos.util.LocalAppDependencies
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@@ -20,6 +27,11 @@ class MainActivity : ComponentActivity() {
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
deps.initialize()
|
deps.initialize()
|
||||||
com.avinal.memos.util.appContext = applicationContext
|
com.avinal.memos.util.appContext = applicationContext
|
||||||
|
|
||||||
|
TaskNotificationManager.createChannel(this)
|
||||||
|
requestNotificationPermission()
|
||||||
|
scheduleTaskChecker(applicationContext)
|
||||||
|
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
CompositionLocalProvider(LocalAppDependencies provides deps) {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ kotlin {
|
|||||||
compileSdk = 36
|
compileSdk = 36
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
|
|
||||||
|
withHostTest {}
|
||||||
|
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget.set(JvmTarget.JVM_11)
|
jvmTarget.set(JvmTarget.JVM_11)
|
||||||
}
|
}
|
||||||
@@ -68,6 +70,11 @@ kotlin {
|
|||||||
iosMain.dependencies {
|
iosMain.dependencies {
|
||||||
implementation(libs.ktor.client.darwin)
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
@@ -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))
|
Spacer(Modifier.height(36.dp))
|
||||||
SectionHeader("about")
|
SectionHeader("about")
|
||||||
SettingsItem("version", "1.0.0")
|
SettingsItem("version", "1.0.0")
|
||||||
|
|||||||
@@ -3,18 +3,22 @@ package com.avinal.memos.ui.settings
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.avinal.memos.domain.AuthRepository
|
import com.avinal.memos.domain.AuthRepository
|
||||||
|
import com.avinal.memos.domain.Memo
|
||||||
import com.avinal.memos.domain.MemoRepository
|
import com.avinal.memos.domain.MemoRepository
|
||||||
|
import com.avinal.memos.domain.MemoVisibility
|
||||||
import com.avinal.memos.domain.User
|
import com.avinal.memos.domain.User
|
||||||
import com.avinal.memos.ui.theme.MetroTheme
|
import com.avinal.memos.ui.theme.MetroTheme
|
||||||
|
import com.avinal.memos.util.BackupManager
|
||||||
import com.avinal.memos.util.TokenStore
|
import com.avinal.memos.util.TokenStore
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class SettingsViewModel(
|
class SettingsViewModel(
|
||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
private val tokenStore: TokenStore,
|
val tokenStore: TokenStore,
|
||||||
private val memoRepository: MemoRepository,
|
private val memoRepository: MemoRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -29,6 +33,9 @@ class SettingsViewModel(
|
|||||||
val currentAccent: StateFlow<String> = tokenStore.accentColor
|
val currentAccent: StateFlow<String> = tokenStore.accentColor
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "Cobalt")
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "Cobalt")
|
||||||
|
|
||||||
|
val notificationsEnabled: StateFlow<Boolean> = tokenStore.notificationsEnabled
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch { authRepository.validateToken() }
|
viewModelScope.launch { authRepository.validateToken() }
|
||||||
}
|
}
|
||||||
@@ -41,6 +48,33 @@ class SettingsViewModel(
|
|||||||
viewModelScope.launch { tokenStore.saveAccentColor(name) }
|
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() {
|
fun logout() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
memoRepository.clearCache()
|
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 accessToken: Flow<String?> = dataStore.data.map { it[KEY_ACCESS_TOKEN] }
|
||||||
val theme: Flow<String> = dataStore.data.map { it[KEY_THEME] ?: "DARK" }
|
val theme: Flow<String> = dataStore.data.map { it[KEY_THEME] ?: "DARK" }
|
||||||
val accentColor: Flow<String> = dataStore.data.map { it[KEY_ACCENT] ?: "Cobalt" }
|
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) {
|
suspend fun saveCredentials(serverUrl: String, token: String) {
|
||||||
dataStore.edit { prefs ->
|
dataStore.edit { prefs ->
|
||||||
@@ -29,6 +30,10 @@ class TokenStore(private val dataStore: DataStore<Preferences>) {
|
|||||||
dataStore.edit { it[KEY_ACCENT] = name }
|
dataStore.edit { it[KEY_ACCENT] = name }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun saveNotificationsEnabled(enabled: Boolean) {
|
||||||
|
dataStore.edit { it[KEY_NOTIFICATIONS] = enabled.toString() }
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun clear() {
|
suspend fun clear() {
|
||||||
dataStore.edit {
|
dataStore.edit {
|
||||||
val theme = it[KEY_THEME]
|
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_ACCESS_TOKEN = stringPreferencesKey("access_token")
|
||||||
private val KEY_THEME = stringPreferencesKey("app_theme")
|
private val KEY_THEME = stringPreferencesKey("app_theme")
|
||||||
private val KEY_ACCENT = stringPreferencesKey("accent_color")
|
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 */ }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user