From 4ef6908e82ec864e155d8d9042e9163eeec9db6c Mon Sep 17 00:00:00 2001 From: Koitharu Date: Mon, 9 Jun 2025 12:43:33 +0300 Subject: [PATCH] Use kotlin serialization for sync --- app/build.gradle | 2 + .../koitharu/kotatsu/core/util/ext/Cursor.kt | 53 ++-- .../data/model/FavouriteCategorySyncDto.kt | 42 ++++ .../sync/data/model/FavouriteSyncDto.kt | 38 +++ .../kotatsu/sync/data/model/HistorySyncDto.kt | 46 ++++ .../kotatsu/sync/data/model/MangaSyncDto.kt | 56 +++++ .../sync/data/model/MangaTagSyncDto.kt | 29 +++ .../kotatsu/sync/data/model/SyncDto.kt | 12 + .../kotatsu/sync/domain/SyncHelper.kt | 236 +++++++++--------- app/src/main/res/values/constants.xml | 2 +- build.gradle | 1 + gradle/libs.versions.toml | 7 +- 12 files changed, 370 insertions(+), 154 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/sync/data/model/FavouriteCategorySyncDto.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/sync/data/model/FavouriteSyncDto.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/sync/data/model/HistorySyncDto.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/sync/data/model/MangaSyncDto.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/sync/data/model/MangaTagSyncDto.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/sync/data/model/SyncDto.kt diff --git a/app/build.gradle b/app/build.gradle index 132e6ed53..4a163d768 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -7,6 +7,7 @@ plugins { id 'kotlin-parcelize' id 'dagger.hilt.android.plugin' id 'androidx.room' + id 'org.jetbrains.kotlin.plugin.serialization' } android { @@ -152,6 +153,7 @@ dependencies { implementation libs.okhttp.tls implementation libs.okhttp.dnsoverhttps implementation libs.okio + implementation libs.kotlinx.serialization.json implementation libs.adapterdelegates implementation libs.adapterdelegates.viewbinding diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Cursor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Cursor.kt index c7c665d93..593f1a149 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Cursor.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Cursor.kt @@ -2,40 +2,23 @@ package org.koitharu.kotatsu.core.util.ext import android.content.ContentValues import android.database.Cursor -import org.json.JSONObject - -fun Cursor.toJson(): JSONObject { - val jo = JSONObject() - for (i in 0 until columnCount) { - val name = getColumnName(i) - when (getType(i)) { - Cursor.FIELD_TYPE_STRING -> jo.put(name, getString(i)) - Cursor.FIELD_TYPE_FLOAT -> jo.put(name, getDouble(i)) - Cursor.FIELD_TYPE_INTEGER -> jo.put(name, getLong(i)) - Cursor.FIELD_TYPE_NULL -> jo.put(name, null) - Cursor.FIELD_TYPE_BLOB -> jo.put(name, getBlob(i)) - } - } - return jo -} - -fun JSONObject.toContentValues(): ContentValues { - val cv = ContentValues(length()) - for (key in keys()) { - val name = key.escapeName() - when (val value = get(key)) { - JSONObject.NULL, "null", null -> cv.putNull(name) - is String -> cv.put(name, value) - is Float -> cv.put(name, value) - is Double -> cv.put(name, value) - is Int -> cv.put(name, value) - is Long -> cv.put(name, value) - else -> throw IllegalArgumentException("Value $value cannot be putted in ContentValues") - } - } - return cv -} - -private fun String.escapeName() = "`$this`" +import androidx.collection.ArraySet fun Cursor.getBoolean(columnIndex: Int) = getInt(columnIndex) > 0 + +inline fun Cursor.map(mapper: (Cursor) -> T): List = mapTo(ArrayList(count), mapper) + +inline fun Cursor.mapToSet(mapper: (Cursor) -> T): Set = mapTo(ArraySet(count), mapper) + +inline fun > Cursor.mapTo(destination: C, mapper: (Cursor) -> T): C = use { c -> + if (c.moveToFirst()) { + do { + destination.add(mapper(c)) + } while (c.moveToNext()) + } + destination +} + +inline fun buildContentValues(capacity: Int, block: ContentValues.() -> Unit): ContentValues { + return ContentValues(capacity).apply(block) +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/model/FavouriteCategorySyncDto.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/model/FavouriteCategorySyncDto.kt new file mode 100644 index 000000000..5a06fd8e7 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/model/FavouriteCategorySyncDto.kt @@ -0,0 +1,42 @@ +package org.koitharu.kotatsu.sync.data.model + +import android.database.Cursor +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.koitharu.kotatsu.core.util.ext.buildContentValues +import org.koitharu.kotatsu.core.util.ext.getBoolean + +@Serializable +data class FavouriteCategorySyncDto( + @SerialName("category_id") val categoryId: Int, + @SerialName("created_at") val createdAt: Long, + @SerialName("sort_key") val sortKey: Int, + @SerialName("title") val title: String, + @SerialName("order") val order: String, + @SerialName("track") val track: Boolean, + @SerialName("show_in_lib") val isVisibleInLibrary: Boolean, + @SerialName("deleted_at") val deletedAt: Long, +) { + + constructor(cursor: Cursor) : this( + categoryId = cursor.getInt(cursor.getColumnIndexOrThrow("category_id")), + createdAt = cursor.getLong(cursor.getColumnIndexOrThrow("created_at")), + sortKey = cursor.getInt(cursor.getColumnIndexOrThrow("sort_key")), + title = cursor.getString(cursor.getColumnIndexOrThrow("title")), + order = cursor.getString(cursor.getColumnIndexOrThrow("order")), + track = cursor.getBoolean(cursor.getColumnIndexOrThrow("track")), + isVisibleInLibrary = cursor.getBoolean(cursor.getColumnIndexOrThrow("show_in_lib")), + deletedAt = cursor.getLong(cursor.getColumnIndexOrThrow("deleted_at")), + ) + + fun toContentValues() = buildContentValues(8) { + put("category_id", categoryId) + put("created_at", createdAt) + put("sort_key", sortKey) + put("title", title) + put("order", order) + put("track", track) + put("show_in_lib", isVisibleInLibrary) + put("deleted_at", deletedAt) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/model/FavouriteSyncDto.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/model/FavouriteSyncDto.kt new file mode 100644 index 000000000..9dd115c8d --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/model/FavouriteSyncDto.kt @@ -0,0 +1,38 @@ +package org.koitharu.kotatsu.sync.data.model + +import android.database.Cursor +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.koitharu.kotatsu.core.util.ext.buildContentValues +import org.koitharu.kotatsu.core.util.ext.getBoolean + +@Serializable +data class FavouriteSyncDto( + @SerialName("manga_id") val mangaId: Long, + @SerialName("manga") val manga: MangaSyncDto, + @SerialName("category_id") val categoryId: Int, + @SerialName("sort_key") val sortKey: Int, + @SerialName("pinned") val pinned: Boolean, + @SerialName("created_at") val createdAt: Long, + @SerialName("deleted_at") var deletedAt: Long, +) { + + constructor(cursor: Cursor, manga: MangaSyncDto) : this( + mangaId = cursor.getLong(cursor.getColumnIndexOrThrow("manga_id")), + manga = manga, + categoryId = cursor.getInt(cursor.getColumnIndexOrThrow("category_id")), + sortKey = cursor.getInt(cursor.getColumnIndexOrThrow("sort_key")), + pinned = cursor.getBoolean(cursor.getColumnIndexOrThrow("pinned")), + createdAt = cursor.getLong(cursor.getColumnIndexOrThrow("created_at")), + deletedAt = cursor.getLong(cursor.getColumnIndexOrThrow("deleted_at")), + ) + + fun toContentValues() = buildContentValues(6) { + put("manga_id", mangaId) + put("category_id", categoryId) + put("sort_key", sortKey) + put("pinned", pinned) + put("created_at", createdAt) + put("deleted_at", deletedAt) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/model/HistorySyncDto.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/model/HistorySyncDto.kt new file mode 100644 index 000000000..ad5fb89de --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/model/HistorySyncDto.kt @@ -0,0 +1,46 @@ +package org.koitharu.kotatsu.sync.data.model + +import android.database.Cursor +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.koitharu.kotatsu.core.util.ext.buildContentValues + +@Serializable +data class HistorySyncDto( + @SerialName("manga_id") val mangaId: Long, + @SerialName("created_at") val createdAt: Long, + @SerialName("updated_at") val updatedAt: Long, + @SerialName("chapter_id") val chapterId: Long, + @SerialName("page") val page: Int, + @SerialName("scroll") val scroll: Float, + @SerialName("percent") val percent: Float, + @SerialName("deleted_at") val deletedAt: Long, + @SerialName("chapters") val chaptersCount: Int, + @SerialName("manga") val manga: MangaSyncDto, +) { + + constructor(cursor: Cursor, manga: MangaSyncDto) : this( + mangaId = cursor.getLong(cursor.getColumnIndexOrThrow("manga_id")), + createdAt = cursor.getLong(cursor.getColumnIndexOrThrow("created_at")), + updatedAt = cursor.getLong(cursor.getColumnIndexOrThrow("updated_at")), + chapterId = cursor.getLong(cursor.getColumnIndexOrThrow("chapter_id")), + page = cursor.getInt(cursor.getColumnIndexOrThrow("page")), + scroll = cursor.getFloat(cursor.getColumnIndexOrThrow("scroll")), + percent = cursor.getFloat(cursor.getColumnIndexOrThrow("percent")), + deletedAt = cursor.getLong(cursor.getColumnIndexOrThrow("deleted_at")), + chaptersCount = cursor.getInt(cursor.getColumnIndexOrThrow("chapters")), + manga = manga, + ) + + fun toContentValues() = buildContentValues(9) { + put("manga_id", mangaId) + put("created_at", createdAt) + put("updated_at", updatedAt) + put("chapter_id", chapterId) + put("page", page) + put("scroll", scroll) + put("percent", percent) + put("deleted_at", deletedAt) + put("chapters", chaptersCount) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/model/MangaSyncDto.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/model/MangaSyncDto.kt new file mode 100644 index 000000000..430beb350 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/model/MangaSyncDto.kt @@ -0,0 +1,56 @@ +package org.koitharu.kotatsu.sync.data.model + +import android.database.Cursor +import androidx.core.database.getStringOrNull +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.koitharu.kotatsu.core.util.ext.buildContentValues + +@Serializable +data class MangaSyncDto( + @SerialName("manga_id") val id: Long, + @SerialName("title") val title: String, + @SerialName("alt_title") val altTitle: String?, + @SerialName("url") val url: String, + @SerialName("public_url") val publicUrl: String, + @SerialName("rating") val rating: Float, + @SerialName("content_rating") val contentRating: String?, + @SerialName("cover_url") val coverUrl: String, + @SerialName("large_cover_url") val largeCoverUrl: String?, + @SerialName("tags") val tags: Set, + @SerialName("state") val state: String?, + @SerialName("author") val author: String?, + @SerialName("source") val source: String, +) { + + constructor(cursor: Cursor, tags: Set) : this( + id = cursor.getLong(cursor.getColumnIndexOrThrow("manga_id")), + title = cursor.getString(cursor.getColumnIndexOrThrow("title")), + altTitle = cursor.getStringOrNull(cursor.getColumnIndexOrThrow("alt_title")), + url = cursor.getString(cursor.getColumnIndexOrThrow("url")), + publicUrl = cursor.getString(cursor.getColumnIndexOrThrow("public_url")), + rating = cursor.getFloat(cursor.getColumnIndexOrThrow("rating")), + contentRating = cursor.getStringOrNull(cursor.getColumnIndexOrThrow("content_rating")), + coverUrl = cursor.getString(cursor.getColumnIndexOrThrow("cover_url")), + largeCoverUrl = cursor.getStringOrNull(cursor.getColumnIndexOrThrow("large_cover_url")), + tags = tags, + state = cursor.getStringOrNull(cursor.getColumnIndexOrThrow("state")), + author = cursor.getStringOrNull(cursor.getColumnIndexOrThrow("author")), + source = cursor.getString(cursor.getColumnIndexOrThrow("source")), + ) + + fun toContentValues() = buildContentValues(12) { + put("manga_id", id) + put("title", title) + put("alt_title", altTitle) + put("url", url) + put("public_url", publicUrl) + put("rating", rating) + put("content_rating", contentRating) + put("cover_url", coverUrl) + put("large_cover_url", largeCoverUrl) + put("state", state) + put("author", author) + put("source", source) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/model/MangaTagSyncDto.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/model/MangaTagSyncDto.kt new file mode 100644 index 000000000..4b82f3a84 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/model/MangaTagSyncDto.kt @@ -0,0 +1,29 @@ +package org.koitharu.kotatsu.sync.data.model + +import android.database.Cursor +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.koitharu.kotatsu.core.util.ext.buildContentValues + +@Serializable +data class MangaTagSyncDto( + @SerialName("tag_id") val id: Long, + @SerialName("title") val title: String, + @SerialName("key") val key: String, + @SerialName("source") val source: String, +) { + + constructor(cursor: Cursor) : this( + id = cursor.getLong(cursor.getColumnIndexOrThrow("tag_id")), + title = cursor.getString(cursor.getColumnIndexOrThrow("title")), + key = cursor.getString(cursor.getColumnIndexOrThrow("key")), + source = cursor.getString(cursor.getColumnIndexOrThrow("source")), + ) + + fun toContentValues() = buildContentValues(4) { + put("tag_id", id) + put("title", title) + put("key", key) + put("source", source) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/model/SyncDto.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/model/SyncDto.kt new file mode 100644 index 000000000..f810d6d84 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/model/SyncDto.kt @@ -0,0 +1,12 @@ +package org.koitharu.kotatsu.sync.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SyncDto( + @SerialName("history") val history: List?, + @SerialName("categories") val categories: List?, + @SerialName("favourites") val favourites: List?, + @SerialName("timestamp") val timestamp: Long, +) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/domain/SyncHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/domain/SyncHelper.kt index 5a2d8da75..ff96992eb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/domain/SyncHelper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/sync/domain/SyncHelper.kt @@ -11,16 +11,20 @@ import android.content.SyncStats import android.database.Cursor import android.util.Log import androidx.annotation.WorkerThread -import androidx.core.content.contentValuesOf import androidx.core.net.toUri import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request -import org.json.JSONArray -import org.json.JSONObject +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.internal.closeQuietly +import okio.IOException +import org.jetbrains.annotations.Blocking import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES @@ -30,20 +34,23 @@ import org.koitharu.kotatsu.core.db.TABLE_MANGA import org.koitharu.kotatsu.core.db.TABLE_MANGA_TAGS import org.koitharu.kotatsu.core.db.TABLE_TAGS import org.koitharu.kotatsu.core.network.BaseHttpClient -import org.koitharu.kotatsu.core.util.ext.parseJsonOrNull +import org.koitharu.kotatsu.core.util.ext.buildContentValues +import org.koitharu.kotatsu.core.util.ext.map +import org.koitharu.kotatsu.core.util.ext.mapToSet import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.core.util.ext.toContentValues -import org.koitharu.kotatsu.core.util.ext.toJson -import org.koitharu.kotatsu.core.util.ext.toRequestBody -import org.koitharu.kotatsu.parsers.util.json.mapJSONTo import org.koitharu.kotatsu.sync.data.SyncAuthApi import org.koitharu.kotatsu.sync.data.SyncAuthenticator import org.koitharu.kotatsu.sync.data.SyncInterceptor import org.koitharu.kotatsu.sync.data.SyncSettings +import org.koitharu.kotatsu.sync.data.model.FavouriteCategorySyncDto +import org.koitharu.kotatsu.sync.data.model.FavouriteSyncDto +import org.koitharu.kotatsu.sync.data.model.HistorySyncDto +import org.koitharu.kotatsu.sync.data.model.MangaSyncDto +import org.koitharu.kotatsu.sync.data.model.MangaTagSyncDto +import org.koitharu.kotatsu.sync.data.model.SyncDto +import java.net.HttpURLConnection import java.util.concurrent.TimeUnit -private const val FIELD_TIMESTAMP = "timestamp" - class SyncHelper @AssistedInject constructor( @ApplicationContext context: Context, @BaseHttpClient baseHttpClient: OkHttpClient, @@ -54,6 +61,7 @@ class SyncHelper @AssistedInject constructor( private val authorityHistory = context.getString(R.string.sync_authority_history) private val authorityFavourites = context.getString(R.string.sync_authority_favourites) + private val mediaTypeJson = "application/json".toMediaType() private val httpClient = baseHttpClient.newBuilder() .authenticator(SyncAuthenticator(context, account, settings, SyncAuthApi(OkHttpClient()))) .addInterceptor(SyncInterceptor(context, account)) @@ -66,20 +74,26 @@ class SyncHelper @AssistedInject constructor( @WorkerThread fun syncFavourites(stats: SyncStats) { - val data = JSONObject() - data.put(TABLE_FAVOURITE_CATEGORIES, getFavouriteCategories()) - data.put(TABLE_FAVOURITES, getFavourites()) - data.put(FIELD_TIMESTAMP, System.currentTimeMillis()) + val payload = Json.encodeToString( + SyncDto( + history = null, + favourites = getFavourites(), + categories = getFavouriteCategories(), + timestamp = System.currentTimeMillis(), + ), + ) val request = Request.Builder() .url("$baseUrl/resource/$TABLE_FAVOURITES") - .post(data.toRequestBody()) + .post(payload.toRequestBody(mediaTypeJson)) .build() - val response = httpClient.newCall(request).execute().parseJsonOrNull() - if (response != null) { - val categoriesResult = upsertFavouriteCategories(response.getJSONArray(TABLE_FAVOURITE_CATEGORIES)) + val response = httpClient.newCall(request).execute().parseDtoOrNull() + response?.categories?.let { categories -> + val categoriesResult = upsertFavouriteCategories(categories) stats.numDeletes += categoriesResult.first().count?.toLong() ?: 0L stats.numInserts += categoriesResult.drop(1).sumOf { it.count?.toLong() ?: 0L } - val favouritesResult = upsertFavourites(response.getJSONArray(TABLE_FAVOURITES)) + } + response?.favourites?.let { favourites -> + val favouritesResult = upsertFavourites(favourites) stats.numDeletes += favouritesResult.first().count?.toLong() ?: 0L stats.numInserts += favouritesResult.drop(1).sumOf { it.count?.toLong() ?: 0L } stats.numEntries += stats.numInserts + stats.numDeletes @@ -87,20 +101,24 @@ class SyncHelper @AssistedInject constructor( gcFavourites() } + @Blocking @WorkerThread fun syncHistory(stats: SyncStats) { - val data = JSONObject() - data.put(TABLE_HISTORY, getHistory()) - data.put(FIELD_TIMESTAMP, System.currentTimeMillis()) + val payload = Json.encodeToString( + SyncDto( + history = getHistory(), + favourites = null, + categories = null, + timestamp = System.currentTimeMillis(), + ), + ) val request = Request.Builder() .url("$baseUrl/resource/$TABLE_HISTORY") - .post(data.toRequestBody()) + .post(payload.toRequestBody(mediaTypeJson)) .build() - val response = httpClient.newCall(request).execute().parseJsonOrNull() - if (response != null) { - val result = upsertHistory( - json = response.getJSONArray(TABLE_HISTORY), - ) + val response = httpClient.newCall(request).execute().parseDtoOrNull() + response?.history?.let { history -> + val result = upsertHistory(history) stats.numDeletes += result.first().count?.toLong() ?: 0L stats.numInserts += result.drop(1).sumOf { it.count?.toLong() ?: 0L } stats.numEntries += stats.numInserts + stats.numDeletes @@ -118,140 +136,119 @@ class SyncHelper @AssistedInject constructor( } } - private fun upsertHistory(json: JSONArray): Array { + private fun upsertHistory(history: List): Array { val uri = uri(authorityHistory, TABLE_HISTORY) val operations = ArrayList() - json.mapJSONTo(operations) { jo -> - operations.addAll(upsertManga(jo.removeJSONObject("manga"), authorityHistory)) + history.mapTo(operations) { + operations.addAll(upsertManga(it.manga, authorityHistory)) ContentProviderOperation.newInsert(uri) - .withValues(jo.toContentValues()) + .withValues(it.toContentValues()) .build() } return provider.applyBatch(operations) } - private fun upsertFavouriteCategories(json: JSONArray): Array { + private fun upsertFavouriteCategories(categories: List): Array { val uri = uri(authorityFavourites, TABLE_FAVOURITE_CATEGORIES) val operations = ArrayList() - json.mapJSONTo(operations) { jo -> + categories.mapTo(operations) { ContentProviderOperation.newInsert(uri) - .withValues(jo.toContentValues()) + .withValues(it.toContentValues()) .build() } return provider.applyBatch(operations) } - private fun upsertFavourites(json: JSONArray): Array { + private fun upsertFavourites(favourites: List): Array { val uri = uri(authorityFavourites, TABLE_FAVOURITES) val operations = ArrayList() - json.mapJSONTo(operations) { jo -> - operations.addAll(upsertManga(jo.removeJSONObject("manga"), authorityFavourites)) + favourites.mapTo(operations) { + operations.addAll(upsertManga(it.manga, authorityFavourites)) ContentProviderOperation.newInsert(uri) - .withValues(jo.toContentValues()) + .withValues(it.toContentValues()) .build() } return provider.applyBatch(operations) } - private fun upsertManga(json: JSONObject, authority: String): List { - val tags = json.removeJSONArray(TABLE_TAGS) - val result = ArrayList(tags.length() * 2 + 1) - for (i in 0 until tags.length()) { - val tag = tags.getJSONObject(i) + private fun upsertManga(manga: MangaSyncDto, authority: String): List { + val tags = manga.tags + val result = ArrayList(tags.size * 2 + 1) + for (tag in tags) { result += ContentProviderOperation.newInsert(uri(authority, TABLE_TAGS)) .withValues(tag.toContentValues()) .build() result += ContentProviderOperation.newInsert(uri(authority, TABLE_MANGA_TAGS)) .withValues( - contentValuesOf( - "manga_id" to json.getLong("manga_id"), - "tag_id" to tag.getLong("tag_id"), - ), + buildContentValues(2) { + put("manga_id", manga.id) + put("tag_id", tag.id) + }, ).build() } result.add( 0, ContentProviderOperation.newInsert(uri(authority, TABLE_MANGA)) - .withValues(json.toContentValues()) + .withValues(manga.toContentValues()) .build(), ) return result } - private fun getHistory(): JSONArray { + private fun getHistory(): List { return provider.query(authorityHistory, TABLE_HISTORY).use { cursor -> - val json = JSONArray() + val result = ArrayList(cursor.count) if (cursor.moveToFirst()) { do { - val jo = cursor.toJson() - jo.put("manga", getManga(authorityHistory, jo.getLong("manga_id"))) - json.put(jo) + val mangaId = cursor.getLong(cursor.getColumnIndexOrThrow("manga_id")) + result.add(HistorySyncDto(cursor, getManga(authorityHistory, mangaId))) } while (cursor.moveToNext()) } - json + result } } - private fun getFavourites(): JSONArray { - return provider.query(authorityFavourites, TABLE_FAVOURITES).use { cursor -> - val json = JSONArray() - if (cursor.moveToFirst()) { - do { - val jo = cursor.toJson() - jo.put("manga", getManga(authorityFavourites, jo.getLong("manga_id"))) - json.put(jo) - } while (cursor.moveToNext()) - } - json + private fun getFavourites(): List { + return provider.query(authorityFavourites, TABLE_FAVOURITES).map { cursor -> + val manga = getManga(authorityFavourites, cursor.getLong(cursor.getColumnIndexOrThrow("manga_id"))) + FavouriteSyncDto(cursor, manga) } } - private fun getFavouriteCategories(): JSONArray { - return provider.query(authorityFavourites, TABLE_FAVOURITE_CATEGORIES).use { cursor -> - val json = JSONArray() - if (cursor.moveToFirst()) { - do { - json.put(cursor.toJson()) - } while (cursor.moveToNext()) - } - json + private fun getFavouriteCategories(): List = + provider.query(authorityFavourites, TABLE_FAVOURITE_CATEGORIES).map { cursor -> + FavouriteCategorySyncDto(cursor) } + + private fun getManga(authority: String, id: Long): MangaSyncDto { + val tags = requireNotNull( + provider.query( + uri(authority, TABLE_MANGA_TAGS), + arrayOf("tag_id"), + "manga_id = ?", + arrayOf(id.toString()), + null, + )?.mapToSet { + val tagId = it.getLong(it.getColumnIndexOrThrow("tag_id")) + getTag(authority, tagId) + }, + ) + return requireNotNull( + provider.query( + uri(authority, TABLE_MANGA), + null, + "manga_id = ?", + arrayOf(id.toString()), + null, + )?.use { cursor -> + cursor.moveToFirst() + MangaSyncDto(cursor, tags) + }, + ) } - private fun getManga(authority: String, id: Long): JSONObject { - val manga = provider.query( - uri(authority, TABLE_MANGA), - null, - "manga_id = ?", - arrayOf(id.toString()), - null, - )?.use { cursor -> - cursor.moveToFirst() - cursor.toJson() - } - requireNotNull(manga) - val tags = provider.query( - uri(authority, TABLE_MANGA_TAGS), - arrayOf("tag_id"), - "manga_id = ?", - arrayOf(id.toString()), - null, - )?.use { cursor -> - val json = JSONArray() - if (cursor.moveToFirst()) { - do { - val tagId = cursor.getLong(0) - json.put(getTag(authority, tagId)) - } while (cursor.moveToNext()) - } - json - } - manga.put("tags", requireNotNull(tags)) - return manga - } - - private fun getTag(authority: String, tagId: Long): JSONObject { - val tag = provider.query( + private fun getTag(authority: String, tagId: Long): MangaTagSyncDto = requireNotNull( + provider.query( uri(authority, TABLE_TAGS), null, "tag_id = ?", @@ -259,13 +256,12 @@ class SyncHelper @AssistedInject constructor( null, )?.use { cursor -> if (cursor.moveToFirst()) { - cursor.toJson() + MangaTagSyncDto(cursor) } else { null } - } - return requireNotNull(tag) - } + }, + ) private fun gcFavourites() { val deletedAt = System.currentTimeMillis() - defaultGcPeriod @@ -290,9 +286,17 @@ class SyncHelper @AssistedInject constructor( private fun uri(authority: String, table: String) = "content://$authority/$table".toUri() - private fun JSONObject.removeJSONObject(name: String) = remove(name) as JSONObject - - private fun JSONObject.removeJSONArray(name: String) = remove(name) as JSONArray + private fun Response.parseDtoOrNull(): SyncDto? { + return try { + when { + !isSuccessful -> throw IOException(body?.string()) + code == HttpURLConnection.HTTP_NO_CONTENT -> null + else -> Json.decodeFromString(body?.string() ?: return null) + } + } finally { + closeQuietly() + } + } @AssistedFactory interface Factory { diff --git a/app/src/main/res/values/constants.xml b/app/src/main/res/values/constants.xml index 9a18c5eb1..f1a65e2b9 100644 --- a/app/src/main/res/values/constants.xml +++ b/app/src/main/res/values/constants.xml @@ -44,8 +44,8 @@ 1 - https://moe.shirizu.org https://sync.kotatsu.app + https://moe.shirizu.org http://54.254.71.100 http://86.57.183.214:8081 diff --git a/build.gradle b/build.gradle index 1f9ffe611..1b6586e7e 100644 --- a/build.gradle +++ b/build.gradle @@ -4,4 +4,5 @@ plugins { alias(libs.plugins.hilt) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.room) apply false + alias(libs.plugins.kotlinx.serizliation) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5c351f309..71aabc58f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,6 +36,7 @@ parsers = "bcde8ef2a2" preference = "1.2.1" recyclerview = "1.4.0" room = "2.7.1" +serialization = "1.8.1" ssiv = "9a67b6a7c9" swiperefreshlayout = "1.1.0" testRules = "1.6.1" @@ -96,6 +97,7 @@ kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = " kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-guava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava", version.ref = "coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json-jvm", version.ref = "serialization" } leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanary" } lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" } lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycle" } @@ -112,7 +114,8 @@ workinspector = { module = "com.github.Koitharu:WorkInspector", version.ref = "w [plugins] android-application = { id = "com.android.application", version.ref = "gradle" } -kotlin = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } -ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "dagger" } +kotlin = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlinx-serizliation = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } room = { id = "androidx.room", version.ref = "room" }