First step syncronization implementation

This commit is contained in:
Koitharu
2022-04-28 15:02:29 +03:00
parent e34acf010e
commit 837fb91133
36 changed files with 1160 additions and 23 deletions

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.sync
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
import org.koitharu.kotatsu.sync.ui.SyncAuthViewModel
val syncModule
get() = module {
viewModel { SyncAuthViewModel(androidContext(), get()) }
}

View File

@@ -0,0 +1,29 @@
package org.koitharu.kotatsu.sync.data
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import okhttp3.Credentials
import okhttp3.Interceptor
import okhttp3.Response
class AccountInterceptor(
context: Context,
private val account: Account,
) : Interceptor {
private val accountManager = AccountManager.get(context)
override fun intercept(chain: Interceptor.Chain): Response {
val password = accountManager.getPassword(account)
val request = if (password != null) {
val credential: String = Credentials.basic(account.name, password)
chain.request().newBuilder()
.header("Authorization", credential)
.build()
} else {
chain.request()
}
return chain.proceed(request)
}
}

View File

@@ -0,0 +1,28 @@
package org.koitharu.kotatsu.sync.domain
class SyncAuthResult(
val email: String,
val password: String,
val token: String,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as SyncAuthResult
if (email != other.email) return false
if (password != other.password) return false
if (token != other.token) return false
return true
}
override fun hashCode(): Int {
var result = email.hashCode()
result = 31 * result + password.hashCode()
result = 31 * result + token.hashCode()
return result
}
}

View File

@@ -0,0 +1,206 @@
package org.koitharu.kotatsu.sync.domain
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentProviderOperation
import android.content.Context
import android.content.SyncResult
import android.net.Uri
import androidx.annotation.WorkerThread
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONObject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_FAVOURITES
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_FAVOURITE_CATEGORIES
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_HISTORY
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_MANGA
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_MANGA_TAGS
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_TAGS
import org.koitharu.kotatsu.parsers.util.json.mapJSONTo
import org.koitharu.kotatsu.parsers.util.parseJson
import org.koitharu.kotatsu.sync.data.AccountInterceptor
import org.koitharu.kotatsu.utils.ext.toContentValues
import org.koitharu.kotatsu.utils.ext.toJson
import org.koitharu.kotatsu.utils.ext.toRequestBody
private const val AUTHORITY_HISTORY = "org.koitharu.kotatsu.history"
private const val AUTHORITY_FAVOURITES = "org.koitharu.kotatsu.favourites"
/**
* Warning! This class may be used in another process
*/
class SyncRepository(
context: Context,
account: Account,
private val provider: ContentProviderClient,
) {
private val httpClient = OkHttpClient.Builder()
.addInterceptor(AccountInterceptor(context, account))
.build()
private val baseUrl = context.getString(R.string.url_sync_server)
@WorkerThread
fun syncFavouriteCategories(syncResult: SyncResult) {
val uri = uri(AUTHORITY_FAVOURITES, TABLE_FAVOURITE_CATEGORIES)
val data = JSONObject()
provider.query(uri, null, null, null, null)?.use { cursor ->
val favourites = JSONArray()
if (cursor.moveToFirst()) {
do {
favourites.put(cursor.toJson())
} while (cursor.moveToNext())
}
data.put(TABLE_FAVOURITES, favourites)
}
data.put("timestamp", System.currentTimeMillis())
val request = Request.Builder()
.url("$baseUrl/resource/$TABLE_FAVOURITE_CATEGORIES")
.post(data.toRequestBody())
.build()
val response = httpClient.newCall(request).execute().parseJson()
val operations = ArrayList<ContentProviderOperation>()
val timestamp = response.getLong("timestamp")
operations += ContentProviderOperation.newDelete(uri)
.withSelection("created_at < ?", arrayOf(timestamp.toString()))
.build()
val ja = response.getJSONArray(TABLE_FAVOURITE_CATEGORIES)
ja.mapJSONTo(operations) { jo ->
ContentProviderOperation.newInsert(uri)
.withValues(jo.toContentValues())
.build()
}
val result = provider.applyBatch(operations)
syncResult.stats.numDeletes = result.first().count?.toLong() ?: 0L
syncResult.stats.numInserts = result.drop(1).sumOf { it.count?.toLong() ?: 0L }
}
@WorkerThread
fun syncFavourites(syncResult: SyncResult) {
val uri = uri(AUTHORITY_FAVOURITES, TABLE_FAVOURITES)
val data = JSONObject()
provider.query(uri, null, null, null, null)?.use { cursor ->
val jsonArray = JSONArray()
if (cursor.moveToFirst()) {
do {
val jo = cursor.toJson()
jo.put("manga", getManga(AUTHORITY_FAVOURITES, jo.getLong("manga_id")))
jsonArray.put(jo)
} while (cursor.moveToNext())
}
data.put(TABLE_FAVOURITES, jsonArray)
}
data.put("timestamp", System.currentTimeMillis())
val request = Request.Builder()
.url("$baseUrl/resource/$TABLE_FAVOURITES")
.post(data.toRequestBody())
.build()
val response = httpClient.newCall(request).execute().parseJson()
val operations = ArrayList<ContentProviderOperation>()
val timestamp = response.getLong("timestamp")
operations += ContentProviderOperation.newDelete(uri)
.withSelection("created_at < ?", arrayOf(timestamp.toString()))
.build()
val ja = response.getJSONArray(TABLE_FAVOURITES)
ja.mapJSONTo(operations) { jo ->
ContentProviderOperation.newInsert(uri)
.withValues(jo.toContentValues())
.build()
}
val result = provider.applyBatch(operations)
syncResult.stats.numDeletes = result.first().count?.toLong() ?: 0L
syncResult.stats.numInserts = result.drop(1).sumOf { it.count?.toLong() ?: 0L }
}
@WorkerThread
fun syncHistory(syncResult: SyncResult) {
val uri = uri(AUTHORITY_HISTORY, TABLE_HISTORY)
val data = JSONObject()
provider.query(uri, null, null, null, null)?.use { cursor ->
val jsonArray = JSONArray()
if (cursor.moveToFirst()) {
do {
val jo = cursor.toJson()
jo.put("manga", getManga(AUTHORITY_HISTORY, jo.getLong("manga_id")))
jsonArray.put(jo)
} while (cursor.moveToNext())
}
data.put(TABLE_HISTORY, jsonArray)
}
data.put("timestamp", System.currentTimeMillis())
val request = Request.Builder()
.url("$baseUrl/resource/$TABLE_HISTORY")
.post(data.toRequestBody())
.build()
val response = httpClient.newCall(request).execute().parseJson()
val operations = ArrayList<ContentProviderOperation>()
val timestamp = response.getLong("timestamp")
operations += ContentProviderOperation.newDelete(uri)
.withSelection("updated_at < ?", arrayOf(timestamp.toString()))
.build()
val ja = response.getJSONArray(TABLE_HISTORY)
ja.mapJSONTo(operations) { jo ->
ContentProviderOperation.newInsert(uri)
.withValues(jo.toContentValues())
.build()
}
val result = provider.applyBatch(operations)
syncResult.stats.numDeletes = result.first().count?.toLong() ?: 0L
syncResult.stats.numInserts = result.drop(1).sumOf { it.count?.toLong() ?: 0L }
}
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(
uri(authority, TABLE_TAGS),
null,
"tag_id = ?",
arrayOf(tagId.toString()),
null,
)?.use { cursor ->
if (cursor.moveToFirst()) {
cursor.toJson()
} else {
null
}
}
return requireNotNull(tag)
}
private fun uri(authority: String, table: String) = Uri.parse("content://$authority/$table")
}

View File

@@ -0,0 +1,159 @@
package org.koitharu.kotatsu.sync.ui
import android.accounts.Account
import android.accounts.AccountAuthenticatorResponse
import android.accounts.AccountManager
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.View
import android.widget.Button
import androidx.core.graphics.Insets
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.transition.Fade
import androidx.transition.TransitionManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivitySyncAuthBinding
import org.koitharu.kotatsu.sync.domain.SyncAuthResult
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class SyncAuthActivity : BaseActivity<ActivitySyncAuthBinding>(), View.OnClickListener {
private var accountAuthenticatorResponse: AccountAuthenticatorResponse? = null
private var resultBundle: Bundle? = null
private val viewModel by viewModel<SyncAuthViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivitySyncAuthBinding.inflate(layoutInflater))
accountAuthenticatorResponse = intent.getParcelableExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE)
accountAuthenticatorResponse?.onRequestContinued()
binding.buttonCancel.setOnClickListener(this)
binding.buttonNext.setOnClickListener(this)
binding.buttonBack.setOnClickListener(this)
binding.buttonDone.setOnClickListener(this)
binding.editEmail.addTextChangedListener(EmailTextWatcher(binding.buttonNext))
binding.editPassword.addTextChangedListener(PasswordTextWatcher(binding.buttonDone))
viewModel.onTokenObtained.observe(this, ::onTokenReceived)
viewModel.onError.observe(this, ::onError)
viewModel.isLoading.observe(this, ::onLoadingStateChanged)
}
override fun onWindowInsetsChanged(insets: Insets) {
val basePadding = resources.getDimensionPixelOffset(R.dimen.screen_padding)
binding.root.setPadding(
basePadding + insets.left,
basePadding + insets.top,
basePadding + insets.right,
basePadding + insets.bottom,
)
}
override fun onBackPressed() {
if (binding.switcher.isVisible && binding.switcher.displayedChild > 0) {
binding.switcher.showPrevious()
} else {
super.onBackPressed()
}
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_cancel -> {
setResult(RESULT_CANCELED)
finish()
}
R.id.button_next -> {
binding.switcher.showNext()
}
R.id.button_back -> {
binding.switcher.showPrevious()
}
R.id.button_done -> {
viewModel.obtainToken(
email = binding.editEmail.text.toString(),
password = binding.editPassword.text.toString(),
)
}
}
}
override fun finish() {
accountAuthenticatorResponse?.let { response ->
resultBundle?.also {
response.onResult(it)
} ?: response.onError(AccountManager.ERROR_CODE_CANCELED, getString(R.string.canceled))
}
super.finish()
}
private fun onLoadingStateChanged(isLoading: Boolean) {
if (isLoading == binding.layoutProgress.isVisible) {
return
}
TransitionManager.beginDelayedTransition(binding.root, Fade())
binding.switcher.isGone = isLoading
binding.layoutProgress.isVisible = isLoading
}
private fun onError(error: Throwable) {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.error)
.setMessage(error.getDisplayMessage(resources))
.setNegativeButton(R.string.close, null)
.show()
}
private fun onTokenReceived(authResult: SyncAuthResult) {
val am = AccountManager.get(this)
val account = Account(authResult.email, getString(R.string.account_type_sync))
val result = Bundle()
if (am.addAccountExplicitly(account, authResult.password, Bundle())) {
result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name)
result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type)
result.putString(AccountManager.KEY_AUTHTOKEN, authResult.token)
am.setAuthToken(account, account.type, authResult.token)
} else {
result.putString(AccountManager.KEY_ERROR_MESSAGE, getString(R.string.account_already_exists))
}
resultBundle = result
setResult(RESULT_OK)
finish()
}
private class EmailTextWatcher(
private val button: Button,
) : TextWatcher {
private val regexEmail = Regex("^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,6}$", RegexOption.IGNORE_CASE)
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
override fun afterTextChanged(s: Editable?) {
val text = s?.toString()
button.isEnabled = !text.isNullOrEmpty() && regexEmail.matches(text)
}
}
private class PasswordTextWatcher(
private val button: Button,
) : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
override fun afterTextChanged(s: Editable?) {
val text = s?.toString()
button.isEnabled = text != null && text.length >= 4
}
}
}

View File

@@ -0,0 +1,44 @@
package org.koitharu.kotatsu.sync.ui
import android.content.Context
import kotlinx.coroutines.Dispatchers
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.parseJson
import org.koitharu.kotatsu.sync.domain.SyncAuthResult
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.toRequestBody
import java.util.*
class SyncAuthViewModel(
context: Context,
private val okHttpClient: OkHttpClient,
) : BaseViewModel() {
private val baseUrl = context.getString(R.string.url_sync_server)
val onTokenObtained = SingleLiveEvent<SyncAuthResult>()
fun obtainToken(email: String, password: String) {
launchLoadingJob(Dispatchers.Default) {
authenticate(email, password)
val token = UUID.randomUUID().toString()
val result = SyncAuthResult(email, password, token)
onTokenObtained.postCall(result)
}
}
private suspend fun authenticate(email: String, password: String) {
val body = JSONObject(
mapOf("email" to email, "password" to password)
).toRequestBody()
val request = Request.Builder()
.url("$baseUrl/register")
.post(body)
.build()
val response = okHttpClient.newCall(request).await().parseJson()
}
}

View File

@@ -0,0 +1,79 @@
package org.koitharu.kotatsu.sync.ui
import android.accounts.AbstractAccountAuthenticator
import android.accounts.Account
import android.accounts.AccountAuthenticatorResponse
import android.accounts.AccountManager
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.text.TextUtils
class SyncAuthenticator(private val context: Context) : AbstractAccountAuthenticator(context) {
override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?): Bundle? = null
override fun addAccount(
response: AccountAuthenticatorResponse?,
accountType: String?,
authTokenType: String?,
requiredFeatures: Array<out String>?,
options: Bundle?,
): Bundle {
val intent = Intent(context, SyncAuthActivity::class.java)
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response)
val bundle = Bundle()
if (options != null) {
bundle.putAll(options)
}
bundle.putParcelable(AccountManager.KEY_INTENT, intent)
return bundle
}
override fun confirmCredentials(
response: AccountAuthenticatorResponse?,
account: Account?,
options: Bundle?,
): Bundle? = null
override fun getAuthToken(
response: AccountAuthenticatorResponse?,
account: Account,
authTokenType: String?,
options: Bundle?,
): Bundle {
val result = Bundle()
val am = AccountManager.get(context.applicationContext)
val authToken = am.peekAuthToken(account, authTokenType)
if (!TextUtils.isEmpty(authToken)) {
result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name)
result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type)
result.putString(AccountManager.KEY_AUTHTOKEN, authToken)
} else {
val intent = Intent(context, SyncAuthActivity::class.java)
intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response)
// intent.putExtra(SyncAuthActivity.EXTRA_TOKEN_TYPE, authTokenType)
val bundle = Bundle()
if (options != null) {
bundle.putAll(options)
}
bundle.putParcelable(AccountManager.KEY_INTENT, intent)
}
return result
}
override fun getAuthTokenLabel(authTokenType: String?): String? = null
override fun updateCredentials(
response: AccountAuthenticatorResponse?,
account: Account?,
authTokenType: String?,
options: Bundle?,
): Bundle? = null
override fun hasFeatures(
response: AccountAuthenticatorResponse?,
account: Account?,
features: Array<out String>?,
): Bundle? = null
}

View File

@@ -0,0 +1,19 @@
package org.koitharu.kotatsu.sync.ui
import android.app.Service
import android.content.Intent
import android.os.IBinder
class SyncAuthenticatorService : Service() {
private lateinit var authenticator: SyncAuthenticator
override fun onCreate() {
super.onCreate()
authenticator = SyncAuthenticator(this)
}
override fun onBind(intent: Intent?): IBinder? {
return authenticator.iBinder
}
}

View File

@@ -0,0 +1,96 @@
package org.koitharu.kotatsu.sync.ui
import android.content.ContentProvider
import android.content.ContentProviderOperation
import android.content.ContentProviderResult
import android.content.ContentValues
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import android.net.Uri
import androidx.sqlite.db.SupportSQLiteQueryBuilder
import java.util.concurrent.Callable
import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_FAVOURITES
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_FAVOURITE_CATEGORIES
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_HISTORY
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_MANGA
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_MANGA_TAGS
import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_TAGS
abstract class SyncProvider : ContentProvider() {
private val database by inject<MangaDatabase>()
private val supportedTables = setOf(
TABLE_FAVOURITES,
TABLE_MANGA,
TABLE_TAGS,
TABLE_FAVOURITE_CATEGORIES,
TABLE_HISTORY,
TABLE_MANGA_TAGS,
)
override fun onCreate(): Boolean {
return true
}
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?
): Cursor? = if (getTableName(uri) != null) {
val sqlQuery = SupportSQLiteQueryBuilder.builder(uri.lastPathSegment)
.columns(projection)
.selection(selection, selectionArgs)
.orderBy(sortOrder)
.create()
database.openHelper.readableDatabase.query(sqlQuery)
} else {
null
}
override fun getType(uri: Uri): String? {
return getTableName(uri)?.let { "vnd.android.cursor.dir/" }
}
override fun insert(uri: Uri, values: ContentValues?): Uri? {
val table = getTableName(uri)
if (values == null || table == null) {
return null
}
val db = database.openHelper.writableDatabase
db.insert(table, SQLiteDatabase.CONFLICT_REPLACE, values)
return null
}
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
val table = getTableName(uri)
if (table == null) {
return 0
}
return database.openHelper.writableDatabase.delete(table, selection, selectionArgs)
}
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int {
val table = getTableName(uri)
if (values == null || table == null) {
return 0
}
return database.openHelper.writableDatabase
.update(table, SQLiteDatabase.CONFLICT_IGNORE, values, selection, selectionArgs)
}
override fun applyBatch(operations: ArrayList<ContentProviderOperation>): Array<ContentProviderResult> {
return database.runInTransaction(Callable { super.applyBatch(operations) })
}
override fun bulkInsert(uri: Uri, values: Array<out ContentValues>): Int {
return database.runInTransaction(Callable { super.bulkInsert(uri, values) })
}
private fun getTableName(uri: Uri): String? {
return uri.pathSegments.singleOrNull()?.takeIf { it in supportedTables }
}
}

View File

@@ -0,0 +1,28 @@
package org.koitharu.kotatsu.sync.ui.favourites
import android.accounts.Account
import android.content.AbstractThreadedSyncAdapter
import android.content.ContentProviderClient
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import org.koitharu.kotatsu.sync.domain.SyncRepository
import org.koitharu.kotatsu.utils.ext.onError
class FavouritesSyncAdapter(context: Context) : AbstractThreadedSyncAdapter(context, true) {
override fun onPerformSync(
account: Account,
extras: Bundle,
authority: String,
provider: ContentProviderClient,
syncResult: SyncResult,
) {
// Debug.waitForDebugger()
val repository = SyncRepository(context, account, provider)
runCatching {
repository.syncFavouriteCategories(syncResult)
repository.syncFavourites(syncResult)
}.onFailure(syncResult::onError)
}
}

View File

@@ -0,0 +1,5 @@
package org.koitharu.kotatsu.sync.ui.favourites
import org.koitharu.kotatsu.sync.ui.SyncProvider
class FavouritesSyncProvider : SyncProvider()

View File

@@ -0,0 +1,19 @@
package org.koitharu.kotatsu.sync.ui.favourites
import android.app.Service
import android.content.Intent
import android.os.IBinder
class FavouritesSyncService : Service() {
private lateinit var syncAdapter: FavouritesSyncAdapter
override fun onCreate() {
super.onCreate()
syncAdapter = FavouritesSyncAdapter(applicationContext)
}
override fun onBind(intent: Intent?): IBinder {
return syncAdapter.syncAdapterBinder
}
}

View File

@@ -0,0 +1,27 @@
package org.koitharu.kotatsu.sync.ui.history
import android.accounts.Account
import android.content.AbstractThreadedSyncAdapter
import android.content.ContentProviderClient
import android.content.Context
import android.content.SyncResult
import android.os.Bundle
import org.koitharu.kotatsu.sync.domain.SyncRepository
import org.koitharu.kotatsu.utils.ext.onError
class HistorySyncAdapter(context: Context) : AbstractThreadedSyncAdapter(context, true) {
override fun onPerformSync(
account: Account,
extras: Bundle,
authority: String,
provider: ContentProviderClient,
syncResult: SyncResult,
) {
// Debug.waitForDebugger()
val repository = SyncRepository(context, account, provider)
runCatching {
repository.syncHistory(syncResult)
}.onFailure(syncResult::onError)
}
}

View File

@@ -0,0 +1,5 @@
package org.koitharu.kotatsu.sync.ui.history
import org.koitharu.kotatsu.sync.ui.SyncProvider
class HistorySyncProvider : SyncProvider()

View File

@@ -0,0 +1,19 @@
package org.koitharu.kotatsu.sync.ui.history
import android.app.Service
import android.content.Intent
import android.os.IBinder
class HistorySyncService : Service() {
private lateinit var syncAdapter: HistorySyncAdapter
override fun onCreate() {
super.onCreate()
syncAdapter = HistorySyncAdapter(applicationContext)
}
override fun onBind(intent: Intent?): IBinder {
return syncAdapter.syncAdapterBinder
}
}