mirror of
https://github.com/quillpad/quillpad.git
synced 2025-12-28 05:13:46 +00:00
WIP: New syncing
This commit is contained in:
parent
5a69054028
commit
bab5a23c26
@ -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)
|
||||
|
||||
427
app/schemas/org.qosp.notes.data.AppDatabase/5.json
Normal file
427
app/schemas/org.qosp.notes.data.AppDatabase/5.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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?
|
||||
|
||||
|
||||
@ -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
|
||||
"""
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
340
app/src/main/java/org/qosp/notes/data/repo/NewNoteRepository.kt
Normal file
340
app/src/main/java/org/qosp/notes/data/repo/NewNoteRepository.kt
Normal 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
|
||||
)
|
||||
}
|
||||
@ -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?>
|
||||
}
|
||||
|
||||
21
app/src/main/java/org/qosp/notes/data/sync/SyncModule.kt
Normal file
21
app/src/main/java/org/qosp/notes/data/sync/SyncModule.kt
Normal 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)
|
||||
|
||||
}
|
||||
@ -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>()
|
||||
}
|
||||
|
||||
@ -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) }
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>()
|
||||
}
|
||||
@ -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))
|
||||
|
||||
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
@ -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>,
|
||||
)
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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" }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user