WIP: New syncing

This commit is contained in:
Arumugam J 2025-05-19 09:41:17 -07:00
parent 5a69054028
commit bab5a23c26
37 changed files with 1639 additions and 288 deletions

View File

@ -24,14 +24,6 @@ android {
// Enable per-app language preferences
resourceConfigurations.addAll(listOf("ar", "ca", "cs", "de", "el", "en", "es", "fr", "it", "nb-rNO", "nl", "pl", "pt-rBR", "ru", "tr", "uk", "vi", "zh-rCN", "zh-rTW"))
// export schema
// https://stackoverflow.com/a/44645943/4594587
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
arg("KOIN_CONFIG_CHECK", "true")
arg("KOIN_DEFAULT_MODULE", "true")
}
}
dependenciesInfo {
@ -107,6 +99,14 @@ android {
}
}
// export schema
// https://stackoverflow.com/a/44645943/4594587
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
arg("KOIN_CONFIG_CHECK", "true")
arg("KOIN_DEFAULT_MODULE", "true")
}
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
@ -122,6 +122,7 @@ dependencies {
// Test
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.mockk.android)
testImplementation(libs.mockk.agent)
testImplementation(libs.roomTesting)

View File

@ -0,0 +1,427 @@
{
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "39f0553b6701ada1e9c87863b6de49a0",
"entities": [
{
"tableName": "notes",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`title` TEXT NOT NULL, `content` TEXT NOT NULL, `isList` INTEGER NOT NULL, `taskList` TEXT NOT NULL, `isArchived` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `isPinned` INTEGER NOT NULL, `isHidden` INTEGER NOT NULL, `isMarkdownEnabled` INTEGER NOT NULL, `isLocalOnly` INTEGER NOT NULL, `isCompactPreview` INTEGER NOT NULL, `screenAlwaysOn` INTEGER NOT NULL, `creationDate` INTEGER NOT NULL, `modifiedDate` INTEGER NOT NULL, `deletionDate` INTEGER, `attachments` TEXT NOT NULL, `color` TEXT NOT NULL, `notebookId` INTEGER, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`notebookId`) REFERENCES `notebooks`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )",
"fields": [
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isList",
"columnName": "isList",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "taskList",
"columnName": "taskList",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isArchived",
"columnName": "isArchived",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isDeleted",
"columnName": "isDeleted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isPinned",
"columnName": "isPinned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isHidden",
"columnName": "isHidden",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isMarkdownEnabled",
"columnName": "isMarkdownEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isLocalOnly",
"columnName": "isLocalOnly",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isCompactPreview",
"columnName": "isCompactPreview",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "screenAlwaysOn",
"columnName": "screenAlwaysOn",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "creationDate",
"columnName": "creationDate",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "modifiedDate",
"columnName": "modifiedDate",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "deletionDate",
"columnName": "deletionDate",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "notebookId",
"columnName": "notebookId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_notes_notebookId",
"unique": false,
"columnNames": [
"notebookId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_notes_notebookId` ON `${TABLE_NAME}` (`notebookId`)"
}
],
"foreignKeys": [
{
"table": "notebooks",
"onDelete": "SET NULL",
"onUpdate": "NO ACTION",
"columns": [
"notebookId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "note_tags",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tagId` INTEGER NOT NULL, `noteId` INTEGER NOT NULL, PRIMARY KEY(`noteId`, `tagId`), FOREIGN KEY(`noteId`) REFERENCES `notes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`tagId`) REFERENCES `tags`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "tagId",
"columnName": "tagId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "noteId",
"columnName": "noteId",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"noteId",
"tagId"
]
},
"indices": [
{
"name": "index_note_tags_tagId",
"unique": false,
"columnNames": [
"tagId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_note_tags_tagId` ON `${TABLE_NAME}` (`tagId`)"
},
{
"name": "index_note_tags_noteId",
"unique": false,
"columnNames": [
"noteId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_note_tags_noteId` ON `${TABLE_NAME}` (`noteId`)"
}
],
"foreignKeys": [
{
"table": "notes",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"noteId"
],
"referencedColumns": [
"id"
]
},
{
"table": "tags",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"tagId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "notebooks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`notebookName` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)",
"fields": [
{
"fieldPath": "name",
"columnName": "notebookName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "tags",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)",
"fields": [
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "reminders",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `noteId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`noteId`) REFERENCES `notes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "noteId",
"columnName": "noteId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_reminders_noteId",
"unique": false,
"columnNames": [
"noteId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_reminders_noteId` ON `${TABLE_NAME}` (`noteId`)"
}
],
"foreignKeys": [
{
"table": "notes",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"noteId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "cloud_ids",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mappingId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `localNoteId` INTEGER NOT NULL, `remoteNoteId` INTEGER, `provider` TEXT, `extras` TEXT, `isDeletedLocally` INTEGER NOT NULL, `isBeingUpdated` INTEGER NOT NULL, `storageUri` TEXT)",
"fields": [
{
"fieldPath": "mappingId",
"columnName": "mappingId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "localNoteId",
"columnName": "localNoteId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "remoteNoteId",
"columnName": "remoteNoteId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "provider",
"columnName": "provider",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "extras",
"columnName": "extras",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isDeletedLocally",
"columnName": "isDeletedLocally",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isBeingUpdated",
"columnName": "isBeingUpdated",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "storageUri",
"columnName": "storageUri",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"mappingId"
]
},
"indices": [
{
"name": "cloud_ids_id_index",
"unique": false,
"columnNames": [
"localNoteId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `cloud_ids_id_index` ON `${TABLE_NAME}` (`localNoteId`)"
},
{
"name": "cloud_ids_id_provider_index",
"unique": false,
"columnNames": [
"localNoteId",
"provider"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `cloud_ids_id_provider_index` ON `${TABLE_NAME}` (`localNoteId`, `provider`)"
}
],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '39f0553b6701ada1e9c87863b6de49a0')"
]
}
}

View File

@ -16,7 +16,6 @@ import org.qosp.notes.di.KoinWorkerFactory
import org.qosp.notes.preferences.NoteDeletionTime
import org.qosp.notes.preferences.PreferenceRepository
import java.time.Instant
import javax.inject.Inject
import kotlin.time.Duration.Companion.days
class BinCleaningWorkerTest : KoinComponent {

View File

@ -20,9 +20,6 @@ import coil.decode.ImageDecoderDecoder
import coil.decode.VideoFrameDecoder
import coil.disk.DiskCache
import coil.memory.MemoryCache
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.androix.startup.KoinStartup
@ -31,6 +28,7 @@ import org.koin.dsl.koinConfiguration
import org.koin.ksp.generated.module
import org.qosp.notes.components.workers.BinCleaningWorker
import org.qosp.notes.components.workers.SyncWorker
import org.qosp.notes.data.sync.SyncModule
import org.qosp.notes.di.DatabaseModule
import org.qosp.notes.di.KoinWorkerFactory
import org.qosp.notes.di.MarkwonModule
@ -44,7 +42,6 @@ import java.util.concurrent.TimeUnit
@OptIn(KoinExperimentalAPI::class)
class App : Application(), ImageLoaderFactory, Configuration.Provider, KoinStartup {
val syncingScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val workerFactory = KoinWorkerFactory()
override val workManagerConfiguration: Configuration =
@ -85,13 +82,14 @@ class App : Application(), ImageLoaderFactory, Configuration.Provider, KoinStart
modules(
listOf(
DatabaseModule().module,
RepositoryModule().module,
PreferencesModule().module,
UtilModule().module,
StorageModule.module,
NextcloudModule().module,
UIModule().module,
MarkwonModule().module,
NextcloudModule().module,
PreferencesModule().module,
RepositoryModule().module,
StorageModule.module,
SyncModule.module,
UIModule().module,
UtilModule().module,
)
)
}

View File

@ -6,8 +6,8 @@ import androidx.work.WorkerParameters
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import org.qosp.notes.data.repo.NoteRepository
import org.qosp.notes.data.sync.core.Success
import org.qosp.notes.data.sync.core.SyncManager
import org.qosp.notes.preferences.BackgroundSync
import org.qosp.notes.preferences.PreferenceRepository
@ -15,7 +15,7 @@ class SyncWorker(
context: Context,
params: WorkerParameters,
private val preferenceRepository: PreferenceRepository,
private val syncManager: SyncManager,
private val noteRepository: NoteRepository,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
@ -23,7 +23,7 @@ class SyncWorker(
if (preferenceRepository.get<BackgroundSync>().first() == BackgroundSync.DISABLED)
return@withContext Result.failure()
when (syncManager.sync()) {
when (noteRepository.syncNotes()) {
Success -> Result.success()
else -> Result.failure()
}

View File

@ -27,7 +27,7 @@ import org.qosp.notes.data.model.Tag
Reminder::class,
IdMapping::class,
],
version = 4,
version = 5,
exportSchema = true
)
@TypeConverters(DatabaseConverters::class)
@ -64,5 +64,18 @@ abstract class AppDatabase : RoomDatabase() {
}
}
}
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.apply {
execSQL(
"""
CREATE INDEX IF NOT EXISTS cloud_ids_id_index ON cloud_ids (localNoteId);
CREATE INDEX IF NOT EXISTS cloud_ids_id_provider_index ON cloud_ids (localNoteId, provider);
""".trimIndent()
)
}
}
}
}
}

View File

@ -26,6 +26,9 @@ interface IdMappingDao {
@Query("UPDATE cloud_ids SET isDeletedLocally = 1 WHERE localNoteId IN (:ids)")
suspend fun setNotesToBeDeleted(vararg ids: Long)
@Query("SELECT * from cloud_ids WHERE localNoteId IN (:ids)")
suspend fun getByLocalIds(vararg ids: Long): List<IdMapping>
@Query("SELECT * FROM cloud_ids WHERE remoteNoteId = :remoteId AND provider = :provider LIMIT 1")
suspend fun getByRemoteId(remoteId: Long, provider: CloudService): IdMapping?

View File

@ -45,7 +45,7 @@ interface NoteDao {
@Query(
"""
UPDATE notes SET isDeleted = 1 WHERE id IN (
SELECT localNoteId FROM cloud_ids
SELECT localNoteId FROM cloud_ids
WHERE remoteNoteId IS NOT NULL AND isDeletedLocally = 0 AND remoteNoteId NOT IN (:idsInUse)
AND provider = :provider
)"""
@ -68,10 +68,10 @@ interface NoteDao {
return rawGetQuery(
SimpleSQLiteQuery(
"""
SELECT * FROM notes
SELECT * FROM notes
WHERE isDeleted = 0 AND isLocalOnly = 0
AND id NOT IN (
SELECT localNoteId FROM cloud_ids
SELECT localNoteId FROM cloud_ids
WHERE provider = '${provider.name}'
)
ORDER BY isPinned DESC, $column $order
@ -85,7 +85,7 @@ interface NoteDao {
return rawGetQuery(
SimpleSQLiteQuery(
"""
SELECT * FROM notes WHERE isDeleted = 1
SELECT * FROM notes WHERE isDeleted = 1
ORDER BY isPinned DESC, $column $order
"""
)
@ -97,7 +97,7 @@ interface NoteDao {
return rawGetQuery(
SimpleSQLiteQuery(
"""
SELECT * FROM notes WHERE isArchived = 1 AND isDeleted = 0
SELECT * FROM notes WHERE isArchived = 1 AND isDeleted = 0
ORDER BY isPinned DESC, $column $order
"""
)
@ -109,7 +109,7 @@ interface NoteDao {
return rawGetQuery(
SimpleSQLiteQuery(
"""
SELECT * FROM notes WHERE isDeleted = 0
SELECT * FROM notes WHERE isDeleted = 0
ORDER BY isPinned DESC, $column $order
"""
)
@ -121,7 +121,7 @@ interface NoteDao {
return rawGetQuery(
SimpleSQLiteQuery(
"""
SELECT * FROM notes WHERE isArchived = 0 AND isDeleted = 0
SELECT * FROM notes WHERE isArchived = 0 AND isDeleted = 0
ORDER BY isPinned DESC, $column $order
"""
)
@ -139,13 +139,22 @@ interface NoteDao {
)
)
}
fun getAllBlankTitleNotes(): Flow<List<Note>> {
return rawGetQuery(
SimpleSQLiteQuery(
"""
SELECT * FROM notes WHERE title IS NULL OR trim(title) = ''
"""
)
)
}
fun getByNotebook(notebookId: Long, sortMethod: SortMethod): Flow<List<Note>> {
val (column, order) = getOrderByMethod(sortMethod)
return rawGetQuery(
SimpleSQLiteQuery(
"""
SELECT * FROM notes WHERE isArchived = 0 AND isDeleted = 0 AND notebookId = $notebookId
SELECT * FROM notes WHERE isArchived = 0 AND isDeleted = 0 AND notebookId = $notebookId
ORDER BY isPinned DESC, $column $order
"""
)
@ -170,7 +179,7 @@ interface NoteDao {
return rawGetQuery(
SimpleSQLiteQuery(
"""
SELECT * FROM notes WHERE isArchived = 0 AND isDeleted = 0 AND notebookId IS NULL
SELECT * FROM notes WHERE isArchived = 0 AND isDeleted = 0 AND notebookId IS NULL
ORDER BY isPinned DESC, $column $order
"""
)

View File

@ -2,6 +2,7 @@ package org.qosp.notes.data.model
import android.os.Parcelable
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@ -9,7 +10,11 @@ import org.qosp.notes.preferences.CloudService
@Serializable
@Parcelize
@Entity(tableName = "cloud_ids")
@Entity(
tableName = "cloud_ids",
indices = [Index(value = ["localNoteId"], name = "cloud_ids_id_index"),
Index(value = ["localNoteId", "provider"], name = "cloud_ids_id_provider_index")],
)
data class IdMapping(
@PrimaryKey(autoGenerate = true)
val mappingId: Long = 0L,

View File

@ -0,0 +1,340 @@
package org.qosp.notes.data.repo
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import me.msoul.datastore.defaultOf
import org.qosp.notes.data.dao.IdMappingDao
import org.qosp.notes.data.dao.NoteDao
import org.qosp.notes.data.dao.ReminderDao
import org.qosp.notes.data.model.IdMapping
import org.qosp.notes.data.model.Note
import org.qosp.notes.data.model.NoteEntity
import org.qosp.notes.data.model.Notebook
import org.qosp.notes.data.sync.core.BaseResult
import org.qosp.notes.data.sync.core.GenericError
import org.qosp.notes.data.sync.core.Success
import org.qosp.notes.data.sync.neu.BackendProvider
import org.qosp.notes.data.sync.neu.INewSyncBackend
import org.qosp.notes.data.sync.neu.NewSyncNote
import org.qosp.notes.data.sync.neu.NoteAction
import org.qosp.notes.data.sync.neu.RemoteNoteMetaData
import org.qosp.notes.data.sync.neu.SynchronizeNotes
import org.qosp.notes.preferences.CloudService
import org.qosp.notes.preferences.SortMethod
import java.time.Instant
class NewNoteRepository(
private val noteDao: NoteDao,
private val idMappingDao: IdMappingDao,
private val reminderDao: ReminderDao,
private val backendProvider: BackendProvider,
private val synchronizeNotes: SynchronizeNotes,
private val notebookRepository: NotebookRepository,
private val syncingScope: CoroutineScope
) : NoteRepository {
private val tag = NewNoteRepository::class.java.simpleName
private suspend fun cleanMappingsForLocalNotes(vararg notes: Note) {
val n = notes.filter { it.isLocalOnly }
Log.d(tag, "cleanMappingsForLocalNotes: Cleaning ${n.size} local-only notes from ${notes.size} total")
idMappingDao.setNotesToBeDeleted(*n.map { it.id }.toLongArray())
}
override suspend fun syncNotes(): BaseResult {
Log.d(tag, "syncNotes: Starting synchronization")
val syncProvider = backendProvider.syncProvider.value
if (syncProvider == null || !backendProvider.isSyncing) {
Log.d(tag, "syncNotes: Sync not available or disabled")
return Success
}
try {
// Get all local notes (excluding local-only ones)
val localNotes = getAll().first().filterNot { it.isLocalOnly }
Log.d(tag, "syncNotes: Found ${localNotes.size} local notes to sync")
// Get all remote notes and convert to metadata
val allRemoteNotes = syncProvider.getAll()
val remoteNotes = allRemoteNotes.map { it.toRemoteNoteMetaData(syncProvider.type) }
Log.d(tag, "syncNotes: Found ${remoteNotes.size} remote notes")
// Use SynchronizeNotes to determine what updates are needed
val syncResult = synchronizeNotes(localNotes, remoteNotes, syncProvider.type)
Log.d(
tag,
"syncNotes: ${syncResult.localUpdates.size} local updates, ${syncResult.remoteUpdates.size} remote updates"
)
// Apply local updates efficiently
applyLocalUpdates(syncResult.localUpdates, syncProvider, allRemoteNotes)
// Apply remote updates efficiently
applyRemoteUpdates(syncResult.remoteUpdates)
Log.d(tag, "syncNotes: Synchronization completed successfully")
} catch (e: Exception) {
Log.e(tag, "syncNotes: Synchronization failed: ${e.message}", e)
return GenericError(e.message ?: "Unknown error")
}
return Success
}
private suspend fun applyLocalUpdates(
localUpdates: List<NoteAction>,
syncProvider: INewSyncBackend,
remoteNotes: List<NewSyncNote>
) {
if (localUpdates.isEmpty()) return
// Create a map for quick lookup of remote notes by ID
val remoteNotesMap = remoteNotes.associateBy {
when (syncProvider.type) {
CloudService.NEXTCLOUD -> it.id.toString()
CloudService.FILE_STORAGE -> it.idStr
else -> it.idStr
}
}
for (action in localUpdates) {
try {
when (action) {
is NoteAction.Create -> insertNote(
remoteNotesMap[action.remoteNoteMetaData.id]?.toLocalNote() ?: continue, sync = false
)
is NoteAction.Update -> updateNote(
remoteNotesMap[action.remoteNoteMetaData.id]?.toLocalNote()?.copy(id = action.note.id)
?: continue, sync = false
)
is NoteAction.Delete -> deleteNotes(action.note, sync = false)
}
} catch (e: Exception) {
Log.e(tag, "applyLocalUpdates: Failed to apply action $action: ${e.message}")
}
}
}
private fun applyRemoteUpdates(remoteUpdates: List<NoteAction>) {
if (remoteUpdates.isEmpty()) return
for (action in remoteUpdates) {
try {
when (action) {
is NoteAction.Create -> insertRemoteNote(action.note, action.note.id)
is NoteAction.Update -> updateRemoteNote(action.note)
is NoteAction.Delete -> deleteRemoteNotes(listOf(action.note))
}
} catch (e: Exception) {
Log.e(tag, "applyRemoteUpdates: Failed to apply action $action: ${e.message}")
}
}
}
override suspend fun insertNote(note: Note, sync: Boolean): Long {
Log.d(tag, "insertNote: Creating note '${note.title}', isLocalOnly=${note.isLocalOnly}")
val noteId = noteDao.insert(note.toEntity())
if (note.isLocalOnly.not() && backendProvider.isSyncing && sync) insertRemoteNote(note, noteId)
return noteId
}
private fun insertRemoteNote(note: Note, noteId: Long) {
backendProvider.syncProvider.value?.let { syncProvider ->
syncingScope.launch {
try {
val created = syncProvider.createNote(note)
idMappingDao.insert(created.getMapping(noteId, syncProvider))
Log.d(tag, "insertNote: Synced note to ${syncProvider.type}, local ID=$noteId")
} catch (e: Exception) {
Log.e(tag, "insertNote: Sync failed for note ID=$noteId: ${e.message}")
}
}
}
}
private suspend fun updateNote(note: Note, sync: Boolean) {
Log.d(tag, "updateNote: Updating note ID=${note.id}, title='${note.title}'")
noteDao.update(note.toEntity())
if (note.isLocalOnly.not() && backendProvider.isSyncing && sync) updateRemoteNote(note)
}
private fun updateRemoteNote(note: Note) {
backendProvider.syncProvider.value?.let { syncProvider ->
syncingScope.launch {
try {
// Get the existing mapping for this note
val mapping = idMappingDao.getByLocalIdAndProvider(note.id, syncProvider.type)
if (mapping != null) {
// Update the note on the remote backend
val updatedMapping = syncProvider.updateNote(note, mapping)
idMappingDao.update(updatedMapping)
Log.d(tag, "updateNote: Successfully synced update to ${syncProvider.type}")
}
} catch (e: Exception) {
Log.e(tag, "Failed to sync note update: ${e.message}", e)
}
}
}
}
override suspend fun updateNotes(vararg notes: Note, sync: Boolean) = notes.forEach { updateNote(it, sync) }
override suspend fun moveNotesToBin(vararg notes: Note) {
Log.d(tag, "moveNotesToBin: Moving ${notes.size} notes to bin")
val entities = notes.map { it.toEntity().copy(isDeleted = true, deletionDate = Instant.now().epochSecond) }
.toTypedArray<NoteEntity>()
noteDao.update(*entities)
reminderDao.deleteIfNoteIdIn(notes.map { it.id })
cleanMappingsForLocalNotes(*notes)
deleteRemoteNotes(notes.toList())
}
private fun deleteRemoteNotes(notes: List<Note>) {
if (backendProvider.isSyncing) {
backendProvider.syncProvider.value?.let { syncProvider ->
syncingScope.launch {
val noteIds = notes.filterNot { it.isLocalOnly }.map { it.id }.toLongArray()
val mappings = idMappingDao.getByLocalIds(*noteIds).filter { it.provider == syncProvider.type }
Log.d(tag, "deleteRemoteNotes: Deleting ${mappings.size} remote notes from ${syncProvider.type}")
mappings.forEach { syncProvider.deleteNote(it) }
idMappingDao.deleteByLocalId(*noteIds)
}
}
}
}
override suspend fun restoreNotes(vararg notes: Note) {
Log.d(tag, "restoreNotes: Restoring ${notes.size} notes from bin")
val array = notes
.map { it.toEntity().copy(isDeleted = false, deletionDate = null) }
.toTypedArray()
noteDao.update(*array)
cleanMappingsForLocalNotes(*notes)
if (backendProvider.isSyncing) {
backendProvider.syncProvider.value?.let { syncProvider ->
syncingScope.launch {
val syncableNotes = notes.filterNot { it.isLocalOnly }
Log.d(tag, "restoreNotes: Re-syncing ${syncableNotes.size} restored notes to ${syncProvider.type}")
syncableNotes
.associateWith { syncProvider.createNote(it) }
.forEach { (n, syncNote) ->
idMappingDao.insert(syncNote.getMapping(n.id, syncProvider))
}
}
}
}
}
override suspend fun deleteNotes(vararg notes: Note, sync: Boolean) {
Log.d(tag, "deleteNotes: Permanently deleting ${notes.size} notes")
val array = notes.map { it.toEntity() }.toTypedArray()
noteDao.delete(*array)
if (sync) deleteRemoteNotes(notes.toList())
}
override suspend fun discardEmptyNotes(): Boolean {
val notes = noteDao.getAllBlankTitleNotes().first().filter { it.isEmpty() }.toTypedArray()
Log.d(tag, "discardEmptyNotes: Found ${notes.size} empty notes to discard")
deleteNotes(*notes)
return notes.isNotEmpty()
}
override suspend fun permanentlyDeleteNotesInBin() {
val noteIds = noteDao.getDeleted(defaultOf()).first().map { it.id }.toLongArray()
Log.d(tag, "permanentlyDeleteNotesInBin: Permanently deleting ${noteIds.size} notes from bin")
idMappingDao.deleteByLocalId(*noteIds)
noteDao.permanentlyDeleteNotesInBin()
}
override fun getById(noteId: Long): Flow<Note?> {
return noteDao.getById(noteId)
}
override fun getDeleted(sortMethod: SortMethod): Flow<List<Note>> {
return noteDao.getDeleted(sortMethod)
}
override fun getArchived(sortMethod: SortMethod): Flow<List<Note>> {
return noteDao.getArchived(sortMethod)
}
override fun getNonDeleted(sortMethod: SortMethod): Flow<List<Note>> {
return noteDao.getNonDeleted(sortMethod)
}
override fun getNonDeletedOrArchived(sortMethod: SortMethod): Flow<List<Note>> {
return noteDao.getNonDeletedOrArchived(sortMethod)
}
override fun getAll(sortMethod: SortMethod): Flow<List<Note>> {
return noteDao.getAll(sortMethod)
}
override fun getByNotebook(notebookId: Long, sortMethod: SortMethod): Flow<List<Note>> {
return noteDao.getByNotebook(notebookId, sortMethod)
}
override fun getNonRemoteNotes(provider: CloudService, sortMethod: SortMethod): Flow<List<Note>> {
return noteDao.getNonRemoteNotes(sortMethod, provider)
}
override fun getNotesWithoutNotebook(sortMethod: SortMethod): Flow<List<Note>> {
return noteDao.getNotesWithoutNotebook(sortMethod)
}
override suspend fun getNotesByCloudService(provider: CloudService): Map<IdMapping, Note?> {
val allNotes = getAll().first().associateBy { it.id }
val mappings = idMappingDao.getAllByCloudService(provider)
Log.d(tag, "getNotesByCloudService: Found ${mappings.size} mappings for $provider")
return mappings.associateWith { allNotes[it.localNoteId] }
}
private suspend fun getNotebookIdForCategory(category: String): Long? {
return category
.takeUnless { it.isBlank() }
?.let {
notebookRepository.getByName(it).first()?.id ?: notebookRepository.insert(Notebook(name = category))
}
}
}
private fun NewSyncNote.getMapping(noteId: Long, syncProvider: INewSyncBackend) = IdMapping(
localNoteId = noteId,
remoteNoteId = id,
provider = syncProvider.type,
extras = extra,
isDeletedLocally = false,
storageUri = idStr
)
private fun NewSyncNote.toRemoteNoteMetaData(cloudService: CloudService): RemoteNoteMetaData {
val remoteId = when (cloudService) {
CloudService.NEXTCLOUD -> id.toString()
CloudService.FILE_STORAGE -> idStr
else -> idStr
}
return RemoteNoteMetaData(
id = remoteId,
title = title,
lastModified = lastModified
)
}
private fun NewSyncNote.toLocalNote(): Note {
// Convert NewSyncNote to local Note with full content
return Note(
id = 0L, // Will be assigned by database
title = title,
content = content ?: "",
isPinned = favorite,
modifiedDate = lastModified,
notebookId = null, // TODO: Handle category to notebook conversion if needed
isMarkdownEnabled = true // Default to markdown enabled
)
}

View File

@ -1,195 +1,31 @@
package org.qosp.notes.data.repo
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import me.msoul.datastore.defaultOf
import org.qosp.notes.data.dao.IdMappingDao
import org.qosp.notes.data.dao.NoteDao
import org.qosp.notes.data.dao.ReminderDao
import org.qosp.notes.data.model.IdMapping
import org.qosp.notes.data.model.Note
import org.qosp.notes.data.sync.core.SyncManager
import org.qosp.notes.data.sync.core.BaseResult
import org.qosp.notes.preferences.CloudService
import org.qosp.notes.preferences.SortMethod
import java.time.Instant
class NoteRepository(
private val noteDao: NoteDao,
private val idMappingDao: IdMappingDao,
private val reminderDao: ReminderDao,
private val syncManager: SyncManager?,
) {
interface NoteRepository {
suspend fun insertNote(note: Note, sync: Boolean = true): Long
suspend fun updateNotes(vararg notes: Note, sync: Boolean = true)
suspend fun moveNotesToBin(vararg notes: Note)
suspend fun restoreNotes(vararg notes: Note)
suspend fun deleteNotes(vararg notes: Note, sync: Boolean = true)
suspend fun discardEmptyNotes(): Boolean
suspend fun permanentlyDeleteNotesInBin()
private suspend fun cleanMappingsForLocalNotes(vararg notes: Note) {
notes
.filter { it.isLocalOnly }
.also { n ->
idMappingDao.setNotesToBeDeleted(
*n.map { it.id }.toLongArray()
)
}
}
suspend fun insertNote(note: Note, shouldSync: Boolean = true): Long {
val id = noteDao.insert(note.toEntity())
if (!note.isLocalOnly && shouldSync) {
idMappingDao.insert(
IdMapping(
localNoteId = id,
remoteNoteId = null,
provider = null,
isDeletedLocally = false,
extras = null,
)
)
if (syncManager == null) return id
syncManager.syncingScope.launch {
syncManager.createNote(note.copy(id = id))
}
}
return id
}
suspend fun updateNotes(vararg notes: Note, shouldSync: Boolean = true) {
val array = notes
.map { it.toEntity() }
.toTypedArray()
noteDao.update(*array)
if (shouldSync && syncManager != null) {
syncManager.syncingScope.launch {
notes
.filterNot { it.isLocalOnly }
.forEach {
idMappingDao.setNoteIsBeingUpdated(it.id, true)
syncManager.updateOrCreate(it)
}
}
}
}
suspend fun moveNotesToBin(vararg notes: Note, shouldSync: Boolean = true) {
val array = notes
.map { it.toEntity().copy(isDeleted = true, deletionDate = Instant.now().epochSecond) }
.toTypedArray()
noteDao.update(*array)
reminderDao.deleteIfNoteIdIn(notes.map { it.id })
cleanMappingsForLocalNotes(*notes)
if (shouldSync && syncManager != null) {
syncManager.syncingScope.launch {
notes
.filterNot { it.isLocalOnly }
.forEach { syncManager.deleteNote(it) }
}
}
}
suspend fun restoreNotes(vararg notes: Note, shouldSync: Boolean = true) {
val array = notes
.map { it.toEntity().copy(isDeleted = false, deletionDate = null) }
.toTypedArray()
noteDao.update(*array)
cleanMappingsForLocalNotes(*notes)
if (shouldSync && syncManager != null) {
syncManager.syncingScope.launch {
notes
.filterNot { it.isLocalOnly }
.forEach { syncManager.updateOrCreate(it) }
}
}
}
suspend fun deleteNotes(vararg notes: Note, shouldSync: Boolean = true) {
val array = notes
.map { it.toEntity() }
.toTypedArray()
noteDao.delete(*array)
idMappingDao.setNotesToBeDeleted(*notes.map { it.id }.toLongArray())
if (shouldSync && syncManager != null) {
syncManager.syncingScope.launch {
notes
.filterNot { it.isLocalOnly }
.forEach {
syncManager.deleteNote(it)
}
}
}
}
suspend fun discardEmptyNotes(): Boolean {
val notes = noteDao.getAll(defaultOf())
.first()
.filter { it.isEmpty() }
.toTypedArray()
deleteNotes(*notes)
return notes.isNotEmpty()
}
suspend fun permanentlyDeleteNotesInBin() {
val noteIds = noteDao.getDeleted(defaultOf())
.first()
.map { it.id }
.toLongArray()
idMappingDao.setNotesToBeDeleted(*noteIds)
noteDao.permanentlyDeleteNotesInBin()
}
fun getById(noteId: Long): Flow<Note?> {
return noteDao.getById(noteId)
}
fun getDeleted(sortMethod: SortMethod = defaultOf()): Flow<List<Note>> {
return noteDao.getDeleted(sortMethod)
}
fun getArchived(sortMethod: SortMethod = defaultOf()): Flow<List<Note>> {
return noteDao.getArchived(sortMethod)
}
fun getNonDeleted(sortMethod: SortMethod = defaultOf()): Flow<List<Note>> {
return noteDao.getNonDeleted(sortMethod)
}
fun getNonDeletedOrArchived(sortMethod: SortMethod = defaultOf()): Flow<List<Note>> {
return noteDao.getNonDeletedOrArchived(sortMethod)
}
fun getAll(sortMethod: SortMethod = defaultOf()): Flow<List<Note>> {
return noteDao.getAll(sortMethod)
}
fun getByNotebook(notebookId: Long, sortMethod: SortMethod = defaultOf()): Flow<List<Note>> {
return noteDao.getByNotebook(notebookId, sortMethod)
}
fun getNonRemoteNotes(provider: CloudService, sortMethod: SortMethod = defaultOf()): Flow<List<Note>> {
return noteDao.getNonRemoteNotes(sortMethod, provider)
}
fun getNotesWithoutNotebook(sortMethod: SortMethod = defaultOf()): Flow<List<Note>> {
return noteDao.getNotesWithoutNotebook(sortMethod)
}
suspend fun getNotesByCloudService(provider: CloudService): Map<IdMapping, Note?> {
val allNotes = getAll().first().associateBy { it.id }
return idMappingDao.getAllByCloudService(provider).associateWith { allNotes[it.localNoteId] }
}
suspend fun moveRemotelyDeletedNotesToBin(idsInUse: List<Long>, provider: CloudService) {
noteDao.moveRemotelyDeletedNotesToBin(idsInUse, provider)
}
suspend fun syncNotes(): BaseResult
fun getById(noteId: Long): Flow<Note?>
fun getDeleted(sortMethod: SortMethod = defaultOf()): Flow<List<Note>>
fun getArchived(sortMethod: SortMethod = defaultOf()): Flow<List<Note>>
fun getNonDeleted(sortMethod: SortMethod = defaultOf()): Flow<List<Note>>
fun getNonDeletedOrArchived(sortMethod: SortMethod = defaultOf()): Flow<List<Note>>
fun getAll(sortMethod: SortMethod = defaultOf()): Flow<List<Note>>
fun getByNotebook(notebookId: Long, sortMethod: SortMethod = defaultOf()): Flow<List<Note>>
fun getNonRemoteNotes(provider: CloudService, sortMethod: SortMethod = defaultOf()): Flow<List<Note>>
fun getNotesWithoutNotebook(sortMethod: SortMethod = defaultOf()): Flow<List<Note>>
suspend fun getNotesByCloudService(provider: CloudService): Map<IdMapping, Note?>
}

View File

@ -0,0 +1,21 @@
package org.qosp.notes.data.sync
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
const val SYNC_SCOPE = "Sync"
@Module
@ComponentScan
object SyncModule {
@Single
@Named(SYNC_SCOPE)
fun provideSyncScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
}

View File

@ -19,8 +19,3 @@ interface ISyncBackend<T : ProviderConfig, N: SyncNote> {
suspend fun authenticate(config: T): BaseResult
suspend fun isServerCompatible(config: T): BaseResult
}
sealed class SyncResult<T> {
data class Success<T>(val data: T) : SyncResult<T>()
data class Error<T>(val error: Throwable) : SyncResult<T>()
}

View File

@ -50,7 +50,7 @@ class SyncManager(
val syncProvider: StateFlow<ISyncProvider?> = combine(
syncService,
NextcloudConfig.fromPreferences(preferenceRepository),
StorageConfig.storageLocation(preferenceRepository, context)
StorageConfig.storageLocation(preferenceRepository)
) { service, nextcloudConfig, storageConfig ->
when (service) {
DISABLED -> null
@ -120,12 +120,6 @@ class SyncManager(
return result
}
suspend fun sync() = sendMessage { Sync() }
suspend fun createNote(note: Note) = sendMessage { CreateNote(note) }
suspend fun deleteNote(note: Note) = sendMessage { DeleteNote(note) }
suspend fun updateNote(note: Note) = sendMessage { UpdateNote(note) }
suspend fun updateOrCreate(note: Note) = sendMessage { UpdateOrCreateNote(note) }

View File

@ -8,6 +8,7 @@ import org.qosp.notes.preferences.CloudService.FILE_STORAGE
import org.qosp.notes.preferences.CloudService.NEXTCLOUD
interface SyncNote {
val remoteId: String
val content: String?
val title: String
val modified: Long
@ -24,6 +25,7 @@ data class NextcloudNote(
val favorite: Boolean,
override val modified: Long,
val readOnly: Boolean? = null,
override val remoteId: String = id.toString(),
) : SyncNote {
override fun getIdMappingFor(note: Note) = IdMapping(
localNoteId = note.id,
@ -38,7 +40,8 @@ data class NoteFile(
override val modified: Long,
override val content: String?,
override val title: String,
val uri: Uri?
val uri: Uri?,
override val remoteId: String = uri?.toString().orEmpty(),
) : SyncNote {
override fun getIdMappingFor(note: Note) = IdMapping(
localNoteId = note.id,

View File

@ -0,0 +1,6 @@
package org.qosp.notes.data.sync.core
sealed class SyncResult<T> {
data class Success<T>(val data: T) : SyncResult<T>()
data class Error<T>(val error: Throwable) : SyncResult<T>()
}

View File

@ -147,8 +147,7 @@ class StorageBackend(
content = content,
modifiedDate = file.lastModified(),
isMarkdownEnabled = isMarkdown
),
shouldSync = false
), ,
)
idMappingRepository.insert(
IdMapping(
@ -233,7 +232,7 @@ class StorageBackend(
@OptIn(ExperimentalTime::class)
private inline fun <T> inStorage(config: StorageConfig, block: (DocumentFile, StorageConfig) -> T): SyncResult<T> {
val root = DocumentFile.fromTreeUri(config.context, config.location) ?: return SyncResult.Error(
val root = DocumentFile.fromTreeUri(context, config.location) ?: return SyncResult.Error(
FileNotFoundException("Unable to find ${config.location}")
)
if (!hasPermissionsAt(config.location))

View File

@ -1,7 +1,7 @@
package org.qosp.notes.data.sync.fs
import android.content.Context
import android.net.Uri
import androidx.core.net.toUri
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.qosp.notes.data.sync.core.ProviderConfig
@ -10,20 +10,14 @@ import org.qosp.notes.preferences.PreferenceRepository
data class StorageConfig(
val location: Uri,
val context: Context,
override val provider: CloudService = CloudService.FILE_STORAGE
) : ProviderConfig {
) :
ProviderConfig {
companion object {
fun storageLocation(prefRepo: PreferenceRepository, context: Context): Flow<StorageConfig?> {
return prefRepo.getEncryptedString(PreferenceRepository.STORAGE_LOCATION).map {
val location = try {
Uri.parse(it)
} catch (e: Exception) {
null
}
location?.let { l -> StorageConfig(l, context) }
fun storageLocation(prefRepo: PreferenceRepository): Flow<StorageConfig?> =
prefRepo.getEncryptedString(PreferenceRepository.STORAGE_LOCATION).map {
runCatching { it.toUri() }.getOrNull()?.let { l -> StorageConfig(l) }
}
}
}
}

View File

@ -0,0 +1,52 @@
package org.qosp.notes.data.sync.neu
import android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.qosp.notes.data.sync.SYNC_SCOPE
import org.qosp.notes.data.sync.fs.StorageConfig
import org.qosp.notes.data.sync.nextcloud.NextcloudAPI
import org.qosp.notes.data.sync.nextcloud.NextcloudConfig
import org.qosp.notes.preferences.AppPreferences
import org.qosp.notes.preferences.CloudService
import org.qosp.notes.preferences.CloudService.DISABLED
import org.qosp.notes.preferences.CloudService.FILE_STORAGE
import org.qosp.notes.preferences.CloudService.NEXTCLOUD
import org.qosp.notes.preferences.PreferenceRepository
import org.qosp.notes.ui.utils.ConnectionManager
@Single
class BackendProvider(
private val context: Context,
private val nextcloudApi: NextcloudAPI,
preferenceRepository: PreferenceRepository,
@Named(SYNC_SCOPE) syncingScope: CoroutineScope,
private val connectionManager: ConnectionManager,
) {
private val syncService: Flow<CloudService> = preferenceRepository.getAll().map { it.cloudService }
private val pref: StateFlow<AppPreferences?> =
preferenceRepository.getAll().stateIn(syncingScope, SharingStarted.Eagerly, null)
val syncProvider: StateFlow<INewSyncBackend?> = combine(
syncService,
NextcloudConfig.fromPreferences(preferenceRepository),
StorageConfig.storageLocation(preferenceRepository)
) { service, nextcloudConfig, storageConfig ->
when (service) {
DISABLED -> null
NEXTCLOUD -> nextcloudConfig?.let { NewNextcloudBackend(nextcloudApi, it) }
FILE_STORAGE -> storageConfig?.let { NewStorageBackend(context, it) }
}
}.stateIn(syncingScope, SharingStarted.Eagerly, null)
val isSyncing: Boolean
get() = syncProvider.value != null && connectionManager.isConnectionAvailable(pref.value?.syncMode)
}

View File

@ -0,0 +1,28 @@
package org.qosp.notes.data.sync.neu
import org.qosp.notes.data.model.IdMapping
import org.qosp.notes.data.model.Note
import org.qosp.notes.preferences.CloudService
interface INewSyncBackend {
val type: CloudService
suspend fun createNote(note: Note): NewSyncNote
suspend fun updateNote(note: Note, mapping: IdMapping): IdMapping
suspend fun deleteNote(mapping: IdMapping): Boolean
suspend fun getNote(mapping: IdMapping): NewSyncNote?
suspend fun getAll(): List<NewSyncNote>
suspend fun validateConfig(): BackendValidationResult
}
data class RemoteNoteMetaData(
val id: String,
val title: String,
val lastModified: Long,
)
sealed class BackendValidationResult {
object Success : BackendValidationResult()
object InvalidConfig : BackendValidationResult()
object Incompatible : BackendValidationResult()
}

View File

@ -0,0 +1,75 @@
package org.qosp.notes.data.sync.neu
import org.qosp.notes.data.model.IdMapping
import org.qosp.notes.data.model.Note
import org.qosp.notes.data.sync.core.NextcloudNote
import org.qosp.notes.data.sync.core.ServerNotSupportedException
import org.qosp.notes.data.sync.nextcloud.NextcloudAPI
import org.qosp.notes.data.sync.nextcloud.NextcloudBackend.Companion.MIN_SUPPORTED_VERSION
import org.qosp.notes.data.sync.nextcloud.NextcloudConfig
import org.qosp.notes.data.sync.nextcloud.createNote
import org.qosp.notes.data.sync.nextcloud.deleteNote
import org.qosp.notes.data.sync.nextcloud.getNote
import org.qosp.notes.data.sync.nextcloud.getNotes
import org.qosp.notes.data.sync.nextcloud.getNotesCapabilities
import org.qosp.notes.data.sync.nextcloud.model.asNextcloudNote
import org.qosp.notes.data.sync.nextcloud.updateNote
import org.qosp.notes.preferences.CloudService
class NewNextcloudBackend(
private val api: NextcloudAPI,
private val config: NextcloudConfig
) : INewSyncBackend {
override val type: CloudService = CloudService.NEXTCLOUD
override suspend fun createNote(note: Note): NewSyncNote = api.createNote(
NextcloudNote(
id = 0L, // ID will be assigned by the server
content = note.content,
title = note.title,
category = note.notebookId?.toString() ?: "",
favorite = note.isPinned,
modified = note.modifiedDate,
readOnly = null
), config
).asNewSyncNote()
override suspend fun updateNote(note: Note, mapping: IdMapping): IdMapping {
requireNotNull(mapping.remoteNoteId) { "Remote note id is null." }
val nNote = note.asNextcloudNote(mapping.remoteNoteId, "")
val updatedNote = api.updateNote(nNote, mapping.extras ?: "", config)
return mapping.copy(remoteNoteId = updatedNote.id, extras = updatedNote.etag)
}
override suspend fun deleteNote(mapping: IdMapping): Boolean = try {
// Delete the note on the server
requireNotNull(mapping.remoteNoteId) { "Remote note id is null." }
api.deleteNote(mapping.remoteNoteId, config)
true
} catch (_: Exception) {
false
}
override suspend fun getNote(mapping: IdMapping): NewSyncNote? {
requireNotNull(mapping.remoteNoteId) { "Remote note id is null." }
return api.getNote(mapping.remoteNoteId, config).asNewSyncNote()
}
override suspend fun getAll(): List<NewSyncNote> {
return api.getNotes(config).map { note -> note.asNewSyncNote() }
}
override suspend fun validateConfig(): BackendValidationResult {
val result = runCatching {
val capabilities = api.getNotesCapabilities(config)!!
val maxServerVersion = capabilities.apiVersion.last().toFloat()
if (MIN_SUPPORTED_VERSION.toFloat() > maxServerVersion) throw ServerNotSupportedException
}
return when (result.exceptionOrNull()) {
null -> BackendValidationResult.Success
is ServerNotSupportedException -> BackendValidationResult.Incompatible
else -> BackendValidationResult.InvalidConfig
}
}
}

View File

@ -0,0 +1,170 @@
package org.qosp.notes.data.sync.neu
import android.content.Context
import android.net.Uri
import android.provider.DocumentsContract
import android.util.Log
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import org.qosp.notes.data.model.IdMapping
import org.qosp.notes.data.model.Note
import org.qosp.notes.data.sync.core.NoteFile
import org.qosp.notes.data.sync.fs.StorageConfig
import org.qosp.notes.preferences.CloudService
import java.io.FileNotFoundException
import java.io.FileOutputStream
import java.io.IOException
class NewStorageBackend(
private val context: Context,
private val config: StorageConfig
) : INewSyncBackend {
companion object {
private const val TAG = "NewStorageBackend"
}
override val type: CloudService = CloudService.FILE_STORAGE
private val Note.filename: String
get() {
val ext = if (isMarkdownEnabled) "md" else "txt"
return "${title.trim()}.$ext"
}
override suspend fun createNote(note: Note): NewSyncNote {
val root = getRootDocumentFile() ?: throw IOException("Unable to access storage location")
Log.d(TAG, "createNote: $note")
val mimeType = if (note.isMarkdownEnabled) "text/markdown" else "text/plain"
val newDoc = root.createFile(mimeType, note.filename)
return newDoc?.let {
writeNoteToFile(it, note.content)
NoteFile(note.modifiedDate, note.content, note.title, newDoc.uri)
NewSyncNote(
idStr = newDoc.uri.toString(),
title = note.title,
content = note.content,
lastModified = newDoc.lastModified(),
id = 0
)
} ?: throw IOException("Unable to create file for ${note.filename}")
}
override suspend fun updateNote(note: Note, mapping: IdMapping): IdMapping {
return mapping.storageUri?.toUri()?.let { uri ->
val file = DocumentFile.fromSingleUri(context, uri) ?: throw FileNotFoundException("URI not found")
writeNoteToFile(file, note.content)
val newUri = if (note.filename != file.name) {
val succeeded = file.renameTo(note.filename)
if (!succeeded) {
throw IOException("Unable to rename file to ${file.name}")
}
file.uri
} else uri
mapping.copy(storageUri = newUri.toString())
} ?: throw IllegalArgumentException("URI cannot be null")
}
override suspend fun deleteNote(mapping: IdMapping): Boolean {
return mapping.storageUri?.toUri()?.let { uri ->
if (DocumentsContract.deleteDocument(context.contentResolver, uri)) {
Log.d(TAG, "deleteNote: Deleted the file ${uri.pathSegments.last()}")
true
} else {
Log.i(TAG, "deleteNote: Unable to delete ${uri.pathSegments.last()}")
false
}
} ?: false
}
override suspend fun getNote(mapping: IdMapping): NewSyncNote? {
return try {
val uri = mapping.storageUri?.toUri() ?: return null
val file = DocumentFile.fromSingleUri(context, uri) ?: return null
NewSyncNote(
lastModified = file.lastModified(),
content = readFileContent(file),
title = getTitleFromUri(uri),
idStr = uri.toString(),
id = 0,
)
} catch (e: Exception) {
Log.e(TAG, "getNote: Error getting note with id ${mapping.localNoteId}", e)
null
}
}
override suspend fun getAll(): List<NewSyncNote> {
val root = getRootDocumentFile() ?: return emptyList()
return try {
val files = root.listFiles()
.flatMap { if (it.isDirectory) it.listFiles().toList() else listOf(it) }
.filter { it.name?.endsWith(".md") == true || it.name?.endsWith(".txt") == true }
files.map { file ->
NewSyncNote(
idStr = file.uri.toString(),
title = getTitleFromUri(file.uri),
lastModified = file.lastModified(),
content = "",
id = 0,
)
}
} catch (e: Exception) {
Log.e(TAG, "getAll: Error listing files", e)
emptyList()
}
}
override suspend fun validateConfig(): BackendValidationResult {
return try {
val root = getRootDocumentFile()
if (root == null || !hasPermissionsAt(config.location)) {
BackendValidationResult.InvalidConfig
} else {
BackendValidationResult.Success
}
} catch (e: Exception) {
Log.e(TAG, "validateConfig: Error validating config", e)
BackendValidationResult.InvalidConfig
}
}
private fun getRootDocumentFile(): DocumentFile? {
return DocumentFile.fromTreeUri(context, config.location)
}
private fun hasPermissionsAt(uri: Uri): Boolean {
val perm = context.contentResolver.persistedUriPermissions.firstOrNull { it.uri == uri }
return perm?.let { it.isReadPermission && it.isWritePermission } ?: false
}
private fun writeNoteToFile(file: DocumentFile, content: String) {
context.contentResolver.openOutputStream(file.uri, "w")?.use { output ->
(output as? FileOutputStream)?.let {
output.channel.truncate(0)
val bytesWritten = content.encodeToByteArray().inputStream().copyTo(output)
Log.d(TAG, "writeNote: Wrote $bytesWritten bytes to ${file.name}")
} ?: run {
Log.e(TAG, "writeNoteToDocument: ${file.name} is not a file. URI:${file.uri}")
}
}
}
private fun readFileContent(file: DocumentFile): String? {
Log.d(TAG, "readFileContent: ${file.name}")
return context.contentResolver.openInputStream(file.uri)?.use { it.bufferedReader().readText() }
}
private fun getTitleFromUri(uri: Uri): String {
val fileName = uri.lastPathSegment?.substringAfterLast('/') ?: ""
return when {
fileName.endsWith(".md") -> fileName.removeSuffix(".md")
fileName.endsWith(".txt") -> fileName.removeSuffix(".txt")
else -> fileName
}
}
}

View File

@ -0,0 +1,48 @@
package org.qosp.notes.data.sync.neu
import org.qosp.notes.data.model.Note
import org.qosp.notes.data.sync.core.NextcloudNote
data class NewSyncNote(
val id: Long,
val idStr: String,
val content: String?,
val title: String,
val lastModified: Long,
val extra: String? = null,
val category: String = "",
val favorite: Boolean = false,
val readOnly: Boolean = false,
) {
fun asNextCloudNote(): NextcloudNote = NextcloudNote(
id = id,
content = content,
title = title,
modified = lastModified,
etag = extra,
category = category,
favorite = favorite,
readOnly = readOnly,
remoteId = "",
)
}
fun NextcloudNote.asNewSyncNote(): NewSyncNote = NewSyncNote(
id = id,
idStr = "",
content = content,
title = title,
lastModified = modified,
extra = etag,
category = category,
favorite = favorite,
readOnly = readOnly == true,
)
fun Note.asNewSyncNote(): NewSyncNote = NewSyncNote(
id = id,
idStr = "",
content = content,
title = title,
lastModified = modifiedDate,
)

View File

@ -0,0 +1,106 @@
package org.qosp.notes.data.sync.neu
import org.koin.core.annotation.Single
import org.qosp.notes.data.model.Note
import org.qosp.notes.data.repo.IdMappingRepository
import org.qosp.notes.preferences.CloudService
@Single
class SynchronizeNotes(private val idMappingRepository: IdMappingRepository) {
suspend operator fun invoke(
localNotes: List<Note>, remoteNotes: List<RemoteNoteMetaData>, service: CloudService
): SyncNotesResult {
// Fetch all mappings for this service at once to minimize idMapping queries (requirement #1)
val allMappings = idMappingRepository.getAllByProvider(service)
// Create maps for faster lookups
val localNotesMap = localNotes.associateBy { it.id }
val remoteNotesMap = remoteNotes.associateBy { it.id }
// Maps for local note ID to remote note ID and vice versa
val localToRemoteMap = allMappings.associateBy { it.localNoteId }
val remoteToLocalMap = allMappings.associateBy {
if (service == CloudService.NEXTCLOUD) it.remoteNoteId?.toString() ?: "" else it.storageUri ?: ""
}.filterKeys { it != "" }
val localUpdates = mutableListOf<NoteAction>()
val remoteUpdates = mutableListOf<NoteAction>()
// Process local notes
for (localNote in localNotes) {
val mapping = localToRemoteMap[localNote.id]
if (mapping != null) {
// Local note has a mapping to a remote note
val remoteNote = when (service) {
CloudService.NEXTCLOUD -> mapping.remoteNoteId.toString()
CloudService.FILE_STORAGE -> mapping.storageUri
else -> null
}?.let { remoteNotesMap[it] }
if (remoteNote != null) {
// Both local and remote notes exist, compare last modified times
if (localNote.modifiedDate > remoteNote.lastModified) {
// Local note is newer, update remote
remoteUpdates.add(NoteAction.Update(localNote, remoteNote))
} else if (localNote.modifiedDate < remoteNote.lastModified) {
// Remote note is newer, update local
localUpdates.add(NoteAction.Update(localNote, remoteNote))
}
// If equal, no action needed
} else {
// Remote note doesn't exist, create it with empty ID
// This happens when the remote note was deleted
val remoteNoteMetaData = RemoteNoteMetaData(
id = "", // Empty ID for new remote notes
title = localNote.title, lastModified = localNote.modifiedDate
)
remoteUpdates.add(NoteAction.Create(localNote, remoteNoteMetaData))
}
} else {
// Local note has no mapping, create a new remote note
val remoteNoteMetaData = RemoteNoteMetaData(
id = "", // Empty ID for new remote notes
title = localNote.title, lastModified = localNote.modifiedDate
)
remoteUpdates.add(NoteAction.Create(localNote, remoteNoteMetaData))
}
}
// Process remote notes
for (remoteNote in remoteNotes) {
val mapping = remoteToLocalMap[remoteNote.id]
if (mapping != null) {
// Remote note has a mapping to a local note
val localNote = localNotesMap[mapping.localNoteId]
if (localNote == null || mapping.isDeletedLocally) {
// Local note doesn't exist anymore, delete the remote note
val dummyLocalNote = Note(id = mapping.localNoteId)
remoteUpdates.add(NoteAction.Delete(dummyLocalNote, remoteNote))
}
// If local note exists, it was already handled in the local notes loop
} else {
// Remote note has no mapping, create a new local note
val newLocalNote = Note(
title = remoteNote.title, modifiedDate = remoteNote.lastModified
)
localUpdates.add(NoteAction.Create(newLocalNote, remoteNote))
}
}
return SyncNotesResult(localUpdates, remoteUpdates)
}
}
sealed interface NoteAction {
data class Create(val note: Note, val remoteNoteMetaData: RemoteNoteMetaData) : NoteAction
data class Update(val note: Note, val remoteNoteMetaData: RemoteNoteMetaData) : NoteAction
data class Delete(val note: Note, val remoteNoteMetaData: RemoteNoteMetaData) : NoteAction
}
data class SyncNotesResult(
val localUpdates: List<NoteAction>,
val remoteUpdates: List<NoteAction>,
)

View File

@ -6,8 +6,8 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonObject
import okhttp3.ResponseBody
import org.qosp.notes.data.sync.nextcloud.model.NextcloudCapabilities
import org.qosp.notes.data.sync.core.NextcloudNote
import org.qosp.notes.data.sync.nextcloud.model.NextcloudCapabilities
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
@ -87,6 +87,13 @@ suspend fun NextcloudAPI.deleteNote(note: NextcloudNote, config: NextcloudConfig
)
}
suspend fun NextcloudAPI.deleteNote(noteId: Long, config: NextcloudConfig) {
deleteNoteAPI(
url = config.remoteAddress + baseURL + "notes/${noteId}",
auth = config.credentials,
)
}
suspend fun NextcloudAPI.updateNote(note: NextcloudNote, etag: String, config: NextcloudConfig): NextcloudNote {
return updateNoteAPI(
note = note,

View File

@ -15,7 +15,6 @@ import org.qosp.notes.data.sync.core.NextcloudNote
import org.qosp.notes.data.sync.core.ServerNotSupported
import org.qosp.notes.data.sync.core.ServerNotSupportedException
import org.qosp.notes.data.sync.core.Success
import org.qosp.notes.data.sync.core.SyncNote
import org.qosp.notes.data.sync.core.SyncResult
import org.qosp.notes.data.sync.nextcloud.model.asNewLocalNote
import org.qosp.notes.data.sync.nextcloud.model.asNextcloudNote

View File

@ -16,6 +16,7 @@ class DatabaseModule {
.addMigrations(AppDatabase.MIGRATION_1_2)
.addMigrations(AppDatabase.MIGRATION_2_3)
.addMigrations(AppDatabase.Migration_3_4)
.addMigrations(AppDatabase.MIGRATION_4_5)
.build()
}
}

View File

@ -10,14 +10,12 @@ import org.qosp.notes.components.MediaStorageManager
import org.qosp.notes.components.workers.BinCleaningWorker
import org.qosp.notes.components.workers.SyncWorker
import org.qosp.notes.data.repo.NoteRepository
import org.qosp.notes.data.sync.core.SyncManager
import org.qosp.notes.preferences.PreferenceRepository
class KoinWorkerFactory : WorkerFactory(), KoinComponent {
private val preferenceRepository: PreferenceRepository by inject()
private val noteRepository: NoteRepository by inject()
private val mediaStorageManager: MediaStorageManager by inject()
private val syncManager: SyncManager by inject()
override fun createWorker(
appContext: Context,
@ -37,7 +35,7 @@ class KoinWorkerFactory : WorkerFactory(), KoinComponent {
appContext,
workerParameters,
preferenceRepository,
syncManager
noteRepository,
)
else -> null

View File

@ -19,22 +19,13 @@ import retrofit2.create
class NextcloudModule {
private val json = Json { ignoreUnknownKeys = true }
@OptIn(ExperimentalSerializationApi::class)
@Single
fun provideNextcloud(): NextcloudAPI {
return Retrofit.Builder()
.baseUrl("http://localhost/") // Since the URL is configurable by the user we set it later during the request
.addConverterFactory(
json.asConverterFactory("application/json".toMediaType())
)
.build()
.create<NextcloudAPI>()
}
fun provideNextcloud() = getRetrofitted<NextcloudAPI>()
@Single
fun provideNextcloudManager(
nextcloudAPI: NextcloudAPI,
@Named(NO_SYNC) noteRepository: NoteRepository,
noteRepository: NoteRepository,
@Named(NO_SYNC) notebookRepository: NotebookRepository,
idMappingRepository: IdMappingRepository,
) = NextcloudBackend(nextcloudAPI, noteRepository, notebookRepository, idMappingRepository)

View File

@ -1,15 +1,20 @@
package org.qosp.notes.di
import kotlinx.coroutines.CoroutineScope
import org.koin.core.annotation.Module
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.qosp.notes.data.AppDatabase
import org.qosp.notes.data.repo.IdMappingRepository
import org.qosp.notes.data.repo.NewNoteRepository
import org.qosp.notes.data.repo.NoteRepository
import org.qosp.notes.data.repo.NotebookRepository
import org.qosp.notes.data.repo.ReminderRepository
import org.qosp.notes.data.repo.TagRepository
import org.qosp.notes.data.sync.SYNC_SCOPE
import org.qosp.notes.data.sync.core.SyncManager
import org.qosp.notes.data.sync.neu.BackendProvider
import org.qosp.notes.data.sync.neu.SynchronizeNotes
const val NO_SYNC = "NO_SYNC"
@ -26,21 +31,26 @@ class RepositoryModule {
@Single
fun provideNotebookRepositoryWithNullSyncManager(
appDatabase: AppDatabase,
@Named(NO_SYNC) noteRepository: NoteRepository,
noteRepository: NoteRepository,
) = NotebookRepository(appDatabase.notebookDao, noteRepository, null)
@Single
fun provideNoteRepository(
fun provideNewNoteRepository(
appDatabase: AppDatabase,
syncManager: SyncManager,
) = NoteRepository(appDatabase.noteDao, appDatabase.idMappingDao, appDatabase.reminderDao, syncManager)
@Named(NO_SYNC)
@Single
fun provideNoteRepositoryWithNullSyncManager(
appDatabase: AppDatabase,
) = NoteRepository(appDatabase.noteDao, appDatabase.idMappingDao, appDatabase.reminderDao, null)
backendProvider: BackendProvider,
synchronizeNotes: SynchronizeNotes,
@Named(NO_SYNC) notebookRepository: NotebookRepository,
@Named(SYNC_SCOPE) syncingScope: CoroutineScope,
): NoteRepository =
NewNoteRepository(
noteDao = appDatabase.noteDao,
idMappingDao = appDatabase.idMappingDao,
reminderDao = appDatabase.reminderDao,
backendProvider = backendProvider,
synchronizeNotes = synchronizeNotes,
notebookRepository = notebookRepository,
syncingScope = syncingScope
)
@Single

View File

@ -17,7 +17,7 @@ object StorageModule {
fun provideStorageManager(
preferenceRepository: PreferenceRepository,
context: Context,
@Named(NO_SYNC) noteRepository: NoteRepository,
noteRepository: NoteRepository,
@Named(NO_SYNC) notebookRepository: NotebookRepository,
idMappingRepository: IdMappingRepository,
) = StorageBackend(preferenceRepository, context, noteRepository, notebookRepository, idMappingRepository)

View File

@ -1,8 +1,9 @@
package org.qosp.notes.di
import android.app.Application
import android.content.Context
import kotlinx.coroutines.CoroutineScope
import org.koin.core.annotation.Module
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.qosp.notes.App
import org.qosp.notes.BuildConfig
@ -13,6 +14,7 @@ import org.qosp.notes.data.repo.NoteRepository
import org.qosp.notes.data.repo.NotebookRepository
import org.qosp.notes.data.repo.ReminderRepository
import org.qosp.notes.data.repo.TagRepository
import org.qosp.notes.data.sync.SYNC_SCOPE
import org.qosp.notes.data.sync.core.SyncManager
import org.qosp.notes.data.sync.fs.StorageBackend
import org.qosp.notes.data.sync.nextcloud.NextcloudBackend
@ -41,17 +43,23 @@ class UtilModule {
idMappingRepository: IdMappingRepository,
nextcloudManager: NextcloudBackend,
storageManager: StorageBackend,
app: Application,
connectionManager: ConnectionManager,
@Named(SYNC_SCOPE) syncingScope: CoroutineScope,
) = SyncManager(
preferenceRepository,
idMappingRepository,
ConnectionManager(context),
connectionManager,
context,
nextcloudManager,
storageManager,
(app as App).syncingScope
syncingScope,
)
@Single
fun provideConnectionManager(
context: Context,
) = ConnectionManager(context)
@Single
fun provideBackupManager(
noteRepository: NoteRepository,

View File

@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import me.msoul.datastore.defaultOf
import org.koin.android.annotation.KoinViewModel
import org.koin.core.annotation.Named
import org.qosp.notes.components.MediaStorageManager
import org.qosp.notes.data.model.Note
import org.qosp.notes.data.model.Notebook
@ -24,8 +25,8 @@ import org.qosp.notes.data.repo.NoteRepository
import org.qosp.notes.data.repo.NotebookRepository
import org.qosp.notes.data.repo.ReminderRepository
import org.qosp.notes.data.repo.TagRepository
import org.qosp.notes.data.sync.SYNC_SCOPE
import org.qosp.notes.data.sync.core.BaseResult
import org.qosp.notes.data.sync.core.SyncManager
import org.qosp.notes.preferences.GroupNotesWithoutNotebook
import org.qosp.notes.preferences.LayoutMode
import org.qosp.notes.preferences.NoteDeletionTime
@ -45,7 +46,7 @@ class ActivityViewModel(
private val reminderManager: ReminderManager,
private val tagRepository: TagRepository,
private val mediaStorageManager: MediaStorageManager,
private val syncManager: SyncManager,
@Named(SYNC_SCOPE) private val syncScope: CoroutineScope,
) : ViewModel() {
@OptIn(ExperimentalCoroutinesApi::class)
@ -66,16 +67,11 @@ class ActivityViewModel(
var tempPhotoUri: Uri? = null
fun syncAsync(): Deferred<BaseResult> {
return syncManager.syncingScope.async {
syncManager.sync()
return syncScope.async {
noteRepository.syncNotes()
}
}
fun sync() {
syncManager.syncingScope.launch {
syncManager.sync()
}
}
fun discardEmptyNotesAsync() = viewModelScope.async(Dispatchers.IO) {
noteRepository.discardEmptyNotes()
@ -95,6 +91,7 @@ class ActivityViewModel(
noteRepository.deleteNotes(*notes)
mediaStorageManager.cleanUpStorage()
}
else -> {
noteRepository.moveNotesToBin(*notes)
}

View File

@ -256,7 +256,7 @@ abstract class AbstractNotesFragment(@LayoutRes resId: Int) : BaseFragment(resId
}
override fun onDestroyView() {
// Bug fix. See the the comments at the declaration of destinationChangedListener for more info.
// Bug fix. See the comments at the declaration of destinationChangedListener for more info.
isListenerSet = false
findNavController().removeOnDestinationChangedListener(destinationChangedListener)

View File

@ -184,7 +184,7 @@ class EditorViewModel(
viewModelScope.launch(Dispatchers.IO) {
val note = data.value.note ?: return@launch
val new = transform(note)
noteRepository.updateNotes(new, shouldSync = false)
noteRepository.updateNotes(new)
if (new.isLocalOnly) return@launch

View File

@ -0,0 +1,216 @@
package org.qosp.notes.data.sync.core
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.impl.annotations.InjectMockKs
import io.mockk.impl.annotations.MockK
import io.mockk.junit4.MockKRule
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.qosp.notes.data.model.IdMapping
import org.qosp.notes.data.model.Note
import org.qosp.notes.data.repo.IdMappingRepository
import org.qosp.notes.data.sync.neu.NoteAction
import org.qosp.notes.data.sync.neu.RemoteNoteMetaData
import org.qosp.notes.data.sync.neu.SynchronizeNotes
import org.qosp.notes.preferences.CloudService
class SynchronizeNotesTest {
@get:Rule
val mockkRule = MockKRule(this)
@MockK
private lateinit var idMappingRepository: IdMappingRepository
@InjectMockKs
private lateinit var synchronizeNotes: SynchronizeNotes
@Test
fun `empty local and remote notes returns empty result`() = runTest {
// Given
val localNotes = emptyList<Note>()
val remoteNotes = emptyList<RemoteNoteMetaData>()
coEvery { idMappingRepository.getAllByProvider(CloudService.NEXTCLOUD) } returns emptyList()
// When
val result = synchronizeNotes(localNotes, remoteNotes, CloudService.NEXTCLOUD)
// Then
assertEquals(0, result.localUpdates.size)
assertEquals(0, result.remoteUpdates.size)
coVerify { idMappingRepository.getAllByProvider(CloudService.NEXTCLOUD) }
}
@Test
fun `local notes without mapping should be created remotely`() = runTest {
// Given
val localNote = Note(id = 1L, title = "Local Note", modifiedDate = 100L)
val localNotes = listOf(localNote)
val remoteNotes = emptyList<RemoteNoteMetaData>()
coEvery { idMappingRepository.getAllByProvider(CloudService.NEXTCLOUD) } returns emptyList()
// When
val result = synchronizeNotes(localNotes, remoteNotes, CloudService.NEXTCLOUD)
// Then
assertEquals(0, result.localUpdates.size)
assertEquals(1, result.remoteUpdates.size)
val action = result.remoteUpdates[0] as NoteAction.Create
assertEquals(localNote, action.note)
assertEquals("", action.remoteNoteMetaData.id)
assertEquals(localNote.title, action.remoteNoteMetaData.title)
assertEquals(localNote.modifiedDate, action.remoteNoteMetaData.lastModified)
}
@Test
fun `remote notes without mapping should be created locally`() = runTest {
// Given
val remoteNote = RemoteNoteMetaData(id = "remote1", title = "Remote Note", lastModified = 100L)
val localNotes = emptyList<Note>()
val remoteNotes = listOf(remoteNote)
coEvery { idMappingRepository.getAllByProvider(CloudService.NEXTCLOUD) } returns emptyList()
// When
val result = synchronizeNotes(localNotes, remoteNotes, CloudService.NEXTCLOUD)
// Then
assertEquals(1, result.localUpdates.size)
assertEquals(0, result.remoteUpdates.size)
val action = result.localUpdates[0] as NoteAction.Create
assertEquals(remoteNote.title, action.note.title)
assertEquals(remoteNote.lastModified, action.note.modifiedDate)
assertEquals(remoteNote, action.remoteNoteMetaData)
}
@Test
fun `local note newer than remote note should update remote`() = runTest {
// Given
val localNote = Note(id = 1L, title = "Local Note", modifiedDate = 200L)
val remoteNote = RemoteNoteMetaData(id = "2", title = "Remote Note", lastModified = 100L)
val mapping = IdMapping(
localNoteId = 1L,
remoteNoteId = 2L,
provider = CloudService.NEXTCLOUD,
extras = null,
isDeletedLocally = false
)
coEvery { idMappingRepository.getAllByProvider(CloudService.NEXTCLOUD) } returns listOf(mapping)
// When
val result = synchronizeNotes(listOf(localNote), listOf(remoteNote), CloudService.NEXTCLOUD)
// Then
assertEquals(0, result.localUpdates.size)
assertEquals(1, result.remoteUpdates.size)
val action = result.remoteUpdates[0] as NoteAction.Update
assertEquals(localNote, action.note)
assertEquals(remoteNote, action.remoteNoteMetaData)
}
@Test
fun `remote note newer than local note should update local`() = runTest {
// Given
val localNote = Note(id = 1L, title = "Local Note", modifiedDate = 100L)
val remoteNote = RemoteNoteMetaData(id = "2", title = "Remote Note", lastModified = 200L)
val mapping = IdMapping(
localNoteId = 1L,
remoteNoteId = 2L,
provider = CloudService.NEXTCLOUD,
extras = null,
isDeletedLocally = false
)
coEvery { idMappingRepository.getAllByProvider(CloudService.NEXTCLOUD) } returns listOf(mapping)
// When
val result = synchronizeNotes(listOf(localNote), listOf(remoteNote), CloudService.NEXTCLOUD)
// Then
assertEquals(1, result.localUpdates.size)
assertEquals(0, result.remoteUpdates.size)
val action = result.localUpdates[0] as NoteAction.Update
assertEquals(localNote, action.note)
assertEquals(remoteNote, action.remoteNoteMetaData)
}
@Test
fun `deleted local note should delete remote note`() = runTest {
// Given
val remoteNote = RemoteNoteMetaData(id = "2", title = "Remote Note", lastModified = 100L)
val mapping = IdMapping(
localNoteId = 1L,
remoteNoteId = 2L,
provider = CloudService.NEXTCLOUD,
extras = null,
isDeletedLocally = false
)
coEvery { idMappingRepository.getAllByProvider(CloudService.NEXTCLOUD) } returns listOf(mapping)
// When
val result = synchronizeNotes(emptyList(), listOf(remoteNote), CloudService.NEXTCLOUD)
// Then
assertEquals(0, result.localUpdates.size)
assertEquals(1, result.remoteUpdates.size)
val action = result.remoteUpdates[0] as NoteAction.Delete
assertEquals(1L, action.note.id)
assertEquals(remoteNote, action.remoteNoteMetaData)
}
@Test
fun `deleted remote note should delete local note mapping`() = runTest {
// Given
val localNote = Note(id = 1L, title = "Local Note", modifiedDate = 100L)
val mapping = IdMapping(
localNoteId = 1L,
remoteNoteId = 2L,
provider = CloudService.NEXTCLOUD,
extras = null,
isDeletedLocally = false
)
coEvery { idMappingRepository.getAllByProvider(CloudService.NEXTCLOUD) } returns listOf(mapping)
// When
val result = synchronizeNotes(listOf(localNote), emptyList(), CloudService.NEXTCLOUD)
// Then
assertEquals(0, result.localUpdates.size)
assertEquals(1, result.remoteUpdates.size)
val action = result.remoteUpdates[0] as NoteAction.Create
assertEquals(localNote, action.note)
assertEquals("", action.remoteNoteMetaData.id)
}
@Test
fun `file storage service should use storageUri instead of remoteNoteId`() = runTest {
// Given
val localNote = Note(id = 1L, title = "Local Note", modifiedDate = 200L)
val remoteNote = RemoteNoteMetaData(id = "file://path/to/note.txt", title = "Remote Note", lastModified = 100L)
val mapping = IdMapping(
localNoteId = 1L,
remoteNoteId = null,
provider = CloudService.FILE_STORAGE,
extras = null,
isDeletedLocally = false,
storageUri = "file://path/to/note.txt"
)
coEvery { idMappingRepository.getAllByProvider(CloudService.FILE_STORAGE) } returns listOf(mapping)
// When
val result = synchronizeNotes(listOf(localNote), listOf(remoteNote), CloudService.FILE_STORAGE)
// Then
assertEquals(0, result.localUpdates.size)
assertEquals(1, result.remoteUpdates.size)
val action = result.remoteUpdates[0] as NoteAction.Update
assertEquals(localNote, action.note)
assertEquals(remoteNote, action.remoteNoteMetaData)
}
}

View File

@ -26,6 +26,7 @@ koin-annotation = "2.0.0"
kotlin = "2.1.10"
kotlinSerialization = "2.0.0"
kotlinSerializationJson = "1.8.0"
kotlinxCoroutines = "1.10.2"
ksp = "2.1.10-1.0.30"
leakcanaryAndroid = "2.10"
lifecycle = "2.8.7"
@ -97,6 +98,7 @@ koin-ksp-compiler = { module = "io.insert-koin:koin-ksp-compiler", version.ref =
koin-test = { module = "io.insert-koin:koin-test" }
koin-test-junit4 = { module = "io.insert-koin:koin-test-junit4" }
kotlin-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinSerializationJson" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
leakcanaryAndroid = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanaryAndroid" }
lifecycleCommonJava8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "lifecycle" }
lifecycleLiveDataKtx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycle" }