Use kotlin serialization for sync

master
Koitharu 11 months ago
parent b854ca8807
commit 4ef6908e82
Signed by: Koitharu
GPG Key ID: 676DEE768C17A9D7

@ -7,6 +7,7 @@ plugins {
id 'kotlin-parcelize' id 'kotlin-parcelize'
id 'dagger.hilt.android.plugin' id 'dagger.hilt.android.plugin'
id 'androidx.room' id 'androidx.room'
id 'org.jetbrains.kotlin.plugin.serialization'
} }
android { android {
@ -152,6 +153,7 @@ dependencies {
implementation libs.okhttp.tls implementation libs.okhttp.tls
implementation libs.okhttp.dnsoverhttps implementation libs.okhttp.dnsoverhttps
implementation libs.okio implementation libs.okio
implementation libs.kotlinx.serialization.json
implementation libs.adapterdelegates implementation libs.adapterdelegates
implementation libs.adapterdelegates.viewbinding implementation libs.adapterdelegates.viewbinding

@ -2,40 +2,23 @@ package org.koitharu.kotatsu.core.util.ext
import android.content.ContentValues import android.content.ContentValues
import android.database.Cursor import android.database.Cursor
import org.json.JSONObject import androidx.collection.ArraySet
fun Cursor.toJson(): JSONObject { fun Cursor.getBoolean(columnIndex: Int) = getInt(columnIndex) > 0
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 { inline fun <T> Cursor.map(mapper: (Cursor) -> T): List<T> = mapTo(ArrayList(count), mapper)
val cv = ContentValues(length())
for (key in keys()) { inline fun <T> Cursor.mapToSet(mapper: (Cursor) -> T): Set<T> = mapTo(ArraySet(count), mapper)
val name = key.escapeName()
when (val value = get(key)) { inline fun <T, C: MutableCollection<in T>> Cursor.mapTo(destination: C, mapper: (Cursor) -> T): C = use { c ->
JSONObject.NULL, "null", null -> cv.putNull(name) if (c.moveToFirst()) {
is String -> cv.put(name, value) do {
is Float -> cv.put(name, value) destination.add(mapper(c))
is Double -> cv.put(name, value) } while (c.moveToNext())
is Int -> cv.put(name, value)
is Long -> cv.put(name, value)
else -> throw IllegalArgumentException("Value $value cannot be putted in ContentValues")
}
} }
return cv destination
} }
private fun String.escapeName() = "`$this`" inline fun buildContentValues(capacity: Int, block: ContentValues.() -> Unit): ContentValues {
return ContentValues(capacity).apply(block)
fun Cursor.getBoolean(columnIndex: Int) = getInt(columnIndex) > 0 }

@ -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)
}
}

@ -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)
}
}

@ -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)
}
}

@ -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<MangaTagSyncDto>,
@SerialName("state") val state: String?,
@SerialName("author") val author: String?,
@SerialName("source") val source: String,
) {
constructor(cursor: Cursor, tags: Set<MangaTagSyncDto>) : 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)
}
}

@ -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)
}
}

@ -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<HistorySyncDto>?,
@SerialName("categories") val categories: List<FavouriteCategorySyncDto>?,
@SerialName("favourites") val favourites: List<FavouriteSyncDto>?,
@SerialName("timestamp") val timestamp: Long,
)

@ -11,16 +11,20 @@ import android.content.SyncStats
import android.database.Cursor import android.database.Cursor
import android.util.Log import android.util.Log
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.core.content.contentValuesOf
import androidx.core.net.toUri import androidx.core.net.toUri
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.json.JSONArray import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject import okhttp3.Response
import okhttp3.internal.closeQuietly
import okio.IOException
import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES 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_MANGA_TAGS
import org.koitharu.kotatsu.core.db.TABLE_TAGS import org.koitharu.kotatsu.core.db.TABLE_TAGS
import org.koitharu.kotatsu.core.network.BaseHttpClient 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.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.SyncAuthApi
import org.koitharu.kotatsu.sync.data.SyncAuthenticator import org.koitharu.kotatsu.sync.data.SyncAuthenticator
import org.koitharu.kotatsu.sync.data.SyncInterceptor import org.koitharu.kotatsu.sync.data.SyncInterceptor
import org.koitharu.kotatsu.sync.data.SyncSettings 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 import java.util.concurrent.TimeUnit
private const val FIELD_TIMESTAMP = "timestamp"
class SyncHelper @AssistedInject constructor( class SyncHelper @AssistedInject constructor(
@ApplicationContext context: Context, @ApplicationContext context: Context,
@BaseHttpClient baseHttpClient: OkHttpClient, @BaseHttpClient baseHttpClient: OkHttpClient,
@ -54,6 +61,7 @@ class SyncHelper @AssistedInject constructor(
private val authorityHistory = context.getString(R.string.sync_authority_history) private val authorityHistory = context.getString(R.string.sync_authority_history)
private val authorityFavourites = context.getString(R.string.sync_authority_favourites) private val authorityFavourites = context.getString(R.string.sync_authority_favourites)
private val mediaTypeJson = "application/json".toMediaType()
private val httpClient = baseHttpClient.newBuilder() private val httpClient = baseHttpClient.newBuilder()
.authenticator(SyncAuthenticator(context, account, settings, SyncAuthApi(OkHttpClient()))) .authenticator(SyncAuthenticator(context, account, settings, SyncAuthApi(OkHttpClient())))
.addInterceptor(SyncInterceptor(context, account)) .addInterceptor(SyncInterceptor(context, account))
@ -66,20 +74,26 @@ class SyncHelper @AssistedInject constructor(
@WorkerThread @WorkerThread
fun syncFavourites(stats: SyncStats) { fun syncFavourites(stats: SyncStats) {
val data = JSONObject() val payload = Json.encodeToString(
data.put(TABLE_FAVOURITE_CATEGORIES, getFavouriteCategories()) SyncDto(
data.put(TABLE_FAVOURITES, getFavourites()) history = null,
data.put(FIELD_TIMESTAMP, System.currentTimeMillis()) favourites = getFavourites(),
categories = getFavouriteCategories(),
timestamp = System.currentTimeMillis(),
),
)
val request = Request.Builder() val request = Request.Builder()
.url("$baseUrl/resource/$TABLE_FAVOURITES") .url("$baseUrl/resource/$TABLE_FAVOURITES")
.post(data.toRequestBody()) .post(payload.toRequestBody(mediaTypeJson))
.build() .build()
val response = httpClient.newCall(request).execute().parseJsonOrNull() val response = httpClient.newCall(request).execute().parseDtoOrNull()
if (response != null) { response?.categories?.let { categories ->
val categoriesResult = upsertFavouriteCategories(response.getJSONArray(TABLE_FAVOURITE_CATEGORIES)) val categoriesResult = upsertFavouriteCategories(categories)
stats.numDeletes += categoriesResult.first().count?.toLong() ?: 0L stats.numDeletes += categoriesResult.first().count?.toLong() ?: 0L
stats.numInserts += categoriesResult.drop(1).sumOf { it.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.numDeletes += favouritesResult.first().count?.toLong() ?: 0L
stats.numInserts += favouritesResult.drop(1).sumOf { it.count?.toLong() ?: 0L } stats.numInserts += favouritesResult.drop(1).sumOf { it.count?.toLong() ?: 0L }
stats.numEntries += stats.numInserts + stats.numDeletes stats.numEntries += stats.numInserts + stats.numDeletes
@ -87,20 +101,24 @@ class SyncHelper @AssistedInject constructor(
gcFavourites() gcFavourites()
} }
@Blocking
@WorkerThread @WorkerThread
fun syncHistory(stats: SyncStats) { fun syncHistory(stats: SyncStats) {
val data = JSONObject() val payload = Json.encodeToString(
data.put(TABLE_HISTORY, getHistory()) SyncDto(
data.put(FIELD_TIMESTAMP, System.currentTimeMillis()) history = getHistory(),
favourites = null,
categories = null,
timestamp = System.currentTimeMillis(),
),
)
val request = Request.Builder() val request = Request.Builder()
.url("$baseUrl/resource/$TABLE_HISTORY") .url("$baseUrl/resource/$TABLE_HISTORY")
.post(data.toRequestBody()) .post(payload.toRequestBody(mediaTypeJson))
.build() .build()
val response = httpClient.newCall(request).execute().parseJsonOrNull() val response = httpClient.newCall(request).execute().parseDtoOrNull()
if (response != null) { response?.history?.let { history ->
val result = upsertHistory( val result = upsertHistory(history)
json = response.getJSONArray(TABLE_HISTORY),
)
stats.numDeletes += result.first().count?.toLong() ?: 0L stats.numDeletes += result.first().count?.toLong() ?: 0L
stats.numInserts += result.drop(1).sumOf { it.count?.toLong() ?: 0L } stats.numInserts += result.drop(1).sumOf { it.count?.toLong() ?: 0L }
stats.numEntries += stats.numInserts + stats.numDeletes stats.numEntries += stats.numInserts + stats.numDeletes
@ -118,140 +136,119 @@ class SyncHelper @AssistedInject constructor(
} }
} }
private fun upsertHistory(json: JSONArray): Array<ContentProviderResult> { private fun upsertHistory(history: List<HistorySyncDto>): Array<ContentProviderResult> {
val uri = uri(authorityHistory, TABLE_HISTORY) val uri = uri(authorityHistory, TABLE_HISTORY)
val operations = ArrayList<ContentProviderOperation>() val operations = ArrayList<ContentProviderOperation>()
json.mapJSONTo(operations) { jo -> history.mapTo(operations) {
operations.addAll(upsertManga(jo.removeJSONObject("manga"), authorityHistory)) operations.addAll(upsertManga(it.manga, authorityHistory))
ContentProviderOperation.newInsert(uri) ContentProviderOperation.newInsert(uri)
.withValues(jo.toContentValues()) .withValues(it.toContentValues())
.build() .build()
} }
return provider.applyBatch(operations) return provider.applyBatch(operations)
} }
private fun upsertFavouriteCategories(json: JSONArray): Array<ContentProviderResult> { private fun upsertFavouriteCategories(categories: List<FavouriteCategorySyncDto>): Array<ContentProviderResult> {
val uri = uri(authorityFavourites, TABLE_FAVOURITE_CATEGORIES) val uri = uri(authorityFavourites, TABLE_FAVOURITE_CATEGORIES)
val operations = ArrayList<ContentProviderOperation>() val operations = ArrayList<ContentProviderOperation>()
json.mapJSONTo(operations) { jo -> categories.mapTo(operations) {
ContentProviderOperation.newInsert(uri) ContentProviderOperation.newInsert(uri)
.withValues(jo.toContentValues()) .withValues(it.toContentValues())
.build() .build()
} }
return provider.applyBatch(operations) return provider.applyBatch(operations)
} }
private fun upsertFavourites(json: JSONArray): Array<ContentProviderResult> { private fun upsertFavourites(favourites: List<FavouriteSyncDto>): Array<ContentProviderResult> {
val uri = uri(authorityFavourites, TABLE_FAVOURITES) val uri = uri(authorityFavourites, TABLE_FAVOURITES)
val operations = ArrayList<ContentProviderOperation>() val operations = ArrayList<ContentProviderOperation>()
json.mapJSONTo(operations) { jo -> favourites.mapTo(operations) {
operations.addAll(upsertManga(jo.removeJSONObject("manga"), authorityFavourites)) operations.addAll(upsertManga(it.manga, authorityFavourites))
ContentProviderOperation.newInsert(uri) ContentProviderOperation.newInsert(uri)
.withValues(jo.toContentValues()) .withValues(it.toContentValues())
.build() .build()
} }
return provider.applyBatch(operations) return provider.applyBatch(operations)
} }
private fun upsertManga(json: JSONObject, authority: String): List<ContentProviderOperation> { private fun upsertManga(manga: MangaSyncDto, authority: String): List<ContentProviderOperation> {
val tags = json.removeJSONArray(TABLE_TAGS) val tags = manga.tags
val result = ArrayList<ContentProviderOperation>(tags.length() * 2 + 1) val result = ArrayList<ContentProviderOperation>(tags.size * 2 + 1)
for (i in 0 until tags.length()) { for (tag in tags) {
val tag = tags.getJSONObject(i)
result += ContentProviderOperation.newInsert(uri(authority, TABLE_TAGS)) result += ContentProviderOperation.newInsert(uri(authority, TABLE_TAGS))
.withValues(tag.toContentValues()) .withValues(tag.toContentValues())
.build() .build()
result += ContentProviderOperation.newInsert(uri(authority, TABLE_MANGA_TAGS)) result += ContentProviderOperation.newInsert(uri(authority, TABLE_MANGA_TAGS))
.withValues( .withValues(
contentValuesOf( buildContentValues(2) {
"manga_id" to json.getLong("manga_id"), put("manga_id", manga.id)
"tag_id" to tag.getLong("tag_id"), put("tag_id", tag.id)
), },
).build() ).build()
} }
result.add( result.add(
0, 0,
ContentProviderOperation.newInsert(uri(authority, TABLE_MANGA)) ContentProviderOperation.newInsert(uri(authority, TABLE_MANGA))
.withValues(json.toContentValues()) .withValues(manga.toContentValues())
.build(), .build(),
) )
return result return result
} }
private fun getHistory(): JSONArray { private fun getHistory(): List<HistorySyncDto> {
return provider.query(authorityHistory, TABLE_HISTORY).use { cursor -> return provider.query(authorityHistory, TABLE_HISTORY).use { cursor ->
val json = JSONArray() val result = ArrayList<HistorySyncDto>(cursor.count)
if (cursor.moveToFirst()) { if (cursor.moveToFirst()) {
do { do {
val jo = cursor.toJson() val mangaId = cursor.getLong(cursor.getColumnIndexOrThrow("manga_id"))
jo.put("manga", getManga(authorityHistory, jo.getLong("manga_id"))) result.add(HistorySyncDto(cursor, getManga(authorityHistory, mangaId)))
json.put(jo)
} while (cursor.moveToNext()) } while (cursor.moveToNext())
} }
json result
} }
} }
private fun getFavourites(): JSONArray { private fun getFavourites(): List<FavouriteSyncDto> {
return provider.query(authorityFavourites, TABLE_FAVOURITES).use { cursor -> return provider.query(authorityFavourites, TABLE_FAVOURITES).map { cursor ->
val json = JSONArray() val manga = getManga(authorityFavourites, cursor.getLong(cursor.getColumnIndexOrThrow("manga_id")))
if (cursor.moveToFirst()) { FavouriteSyncDto(cursor, manga)
do {
val jo = cursor.toJson()
jo.put("manga", getManga(authorityFavourites, jo.getLong("manga_id")))
json.put(jo)
} while (cursor.moveToNext())
}
json
} }
} }
private fun getFavouriteCategories(): JSONArray { private fun getFavouriteCategories(): List<FavouriteCategorySyncDto> =
return provider.query(authorityFavourites, TABLE_FAVOURITE_CATEGORIES).use { cursor -> provider.query(authorityFavourites, TABLE_FAVOURITE_CATEGORIES).map { cursor ->
val json = JSONArray() FavouriteCategorySyncDto(cursor)
if (cursor.moveToFirst()) {
do {
json.put(cursor.toJson())
} while (cursor.moveToNext())
}
json
}
} }
private fun getManga(authority: String, id: Long): JSONObject { private fun getManga(authority: String, id: Long): MangaSyncDto {
val manga = provider.query( val tags = requireNotNull(
uri(authority, TABLE_MANGA), provider.query(
null, uri(authority, TABLE_MANGA_TAGS),
arrayOf("tag_id"),
"manga_id = ?", "manga_id = ?",
arrayOf(id.toString()), arrayOf(id.toString()),
null, null,
)?.use { cursor -> )?.mapToSet {
cursor.moveToFirst() val tagId = it.getLong(it.getColumnIndexOrThrow("tag_id"))
cursor.toJson() getTag(authority, tagId)
} },
requireNotNull(manga) )
val tags = provider.query( return requireNotNull(
uri(authority, TABLE_MANGA_TAGS), provider.query(
arrayOf("tag_id"), uri(authority, TABLE_MANGA),
null,
"manga_id = ?", "manga_id = ?",
arrayOf(id.toString()), arrayOf(id.toString()),
null, null,
)?.use { cursor -> )?.use { cursor ->
val json = JSONArray() cursor.moveToFirst()
if (cursor.moveToFirst()) { MangaSyncDto(cursor, tags)
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 { private fun getTag(authority: String, tagId: Long): MangaTagSyncDto = requireNotNull(
val tag = provider.query( provider.query(
uri(authority, TABLE_TAGS), uri(authority, TABLE_TAGS),
null, null,
"tag_id = ?", "tag_id = ?",
@ -259,13 +256,12 @@ class SyncHelper @AssistedInject constructor(
null, null,
)?.use { cursor -> )?.use { cursor ->
if (cursor.moveToFirst()) { if (cursor.moveToFirst()) {
cursor.toJson() MangaTagSyncDto(cursor)
} else { } else {
null null
} }
} },
return requireNotNull(tag) )
}
private fun gcFavourites() { private fun gcFavourites() {
val deletedAt = System.currentTimeMillis() - defaultGcPeriod 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 uri(authority: String, table: String) = "content://$authority/$table".toUri()
private fun JSONObject.removeJSONObject(name: String) = remove(name) as JSONObject private fun Response.parseDtoOrNull(): SyncDto? {
return try {
private fun JSONObject.removeJSONArray(name: String) = remove(name) as JSONArray when {
!isSuccessful -> throw IOException(body?.string())
code == HttpURLConnection.HTTP_NO_CONTENT -> null
else -> Json.decodeFromString<SyncDto>(body?.string() ?: return null)
}
} finally {
closeQuietly()
}
}
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {

@ -44,8 +44,8 @@
<item>1</item> <item>1</item>
</string-array> </string-array>
<string-array name="sync_url_list" translatable="false"> <string-array name="sync_url_list" translatable="false">
<item>https://moe.shirizu.org</item>
<item>https://sync.kotatsu.app</item> <item>https://sync.kotatsu.app</item>
<item>https://moe.shirizu.org</item>
<item>http://54.254.71.100</item> <item>http://54.254.71.100</item>
<item>http://86.57.183.214:8081</item> <item>http://86.57.183.214:8081</item>
</string-array> </string-array>

@ -4,4 +4,5 @@ plugins {
alias(libs.plugins.hilt) apply false alias(libs.plugins.hilt) apply false
alias(libs.plugins.ksp) apply false alias(libs.plugins.ksp) apply false
alias(libs.plugins.room) apply false alias(libs.plugins.room) apply false
alias(libs.plugins.kotlinx.serizliation) apply false
} }

@ -36,6 +36,7 @@ parsers = "bcde8ef2a2"
preference = "1.2.1" preference = "1.2.1"
recyclerview = "1.4.0" recyclerview = "1.4.0"
room = "2.7.1" room = "2.7.1"
serialization = "1.8.1"
ssiv = "9a67b6a7c9" ssiv = "9a67b6a7c9"
swiperefreshlayout = "1.1.0" swiperefreshlayout = "1.1.0"
testRules = "1.6.1" 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-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-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-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" } leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanary" }
lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" } lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" }
lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", 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] [plugins]
android-application = { id = "com.android.application", version.ref = "gradle" } android-application = { id = "com.android.application", version.ref = "gradle" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "dagger" }
kotlin = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } 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" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "dagger" }
room = { id = "androidx.room", version.ref = "room" } room = { id = "androidx.room", version.ref = "room" }

Loading…
Cancel
Save