diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index bba1d89b0..7e15a761e 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -9,6 +9,10 @@
+
+
+
+
-
-
+ android:label="@string/downloads"
+ android:launchMode="singleTop" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt
index 24a487c60..36b12937c 100644
--- a/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/history/data/HistoryEntity.kt
@@ -4,16 +4,17 @@ import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
+import org.koitharu.kotatsu.core.db.MangaDatabase.Companion.TABLE_HISTORY
import org.koitharu.kotatsu.core.db.entity.MangaEntity
@Entity(
- tableName = "history",
+ tableName = TABLE_HISTORY,
foreignKeys = [
ForeignKey(
entity = MangaEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
- onDelete = ForeignKey.CASCADE
+ onDelete = ForeignKey.CASCADE,
)
]
)
diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/SyncModule.kt b/app/src/main/java/org/koitharu/kotatsu/sync/SyncModule.kt
new file mode 100644
index 000000000..05e45f8c8
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/sync/SyncModule.kt
@@ -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()) }
+ }
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/data/AccountInterceptor.kt b/app/src/main/java/org/koitharu/kotatsu/sync/data/AccountInterceptor.kt
new file mode 100644
index 000000000..aaa718151
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/sync/data/AccountInterceptor.kt
@@ -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)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncAuthResult.kt b/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncAuthResult.kt
new file mode 100644
index 000000000..e16e1241c
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncAuthResult.kt
@@ -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
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncRepository.kt b/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncRepository.kt
new file mode 100644
index 000000000..8e9303873
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncRepository.kt
@@ -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()
+ 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()
+ 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()
+ 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")
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthActivity.kt b/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthActivity.kt
new file mode 100644
index 000000000..8d8efbf37
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthActivity.kt
@@ -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(), View.OnClickListener {
+
+ private var accountAuthenticatorResponse: AccountAuthenticatorResponse? = null
+ private var resultBundle: Bundle? = null
+
+ private val viewModel by viewModel()
+
+ 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
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthViewModel.kt
new file mode 100644
index 000000000..bfe0ec079
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthViewModel.kt
@@ -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()
+
+ 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()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthenticator.kt b/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthenticator.kt
new file mode 100644
index 000000000..48c0aef8d
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthenticator.kt
@@ -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?,
+ 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?,
+ ): Bundle? = null
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthenticatorService.kt b/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthenticatorService.kt
new file mode 100644
index 000000000..6f7ca8161
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthenticatorService.kt
@@ -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
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncProvider.kt b/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncProvider.kt
new file mode 100644
index 000000000..0c159a53b
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncProvider.kt
@@ -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()
+ 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?,
+ selection: String?,
+ selectionArgs: Array?,
+ 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?): 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?): 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): Array {
+ return database.runInTransaction(Callable { super.applyBatch(operations) })
+ }
+
+ override fun bulkInsert(uri: Uri, values: Array): Int {
+ return database.runInTransaction(Callable { super.bulkInsert(uri, values) })
+ }
+
+ private fun getTableName(uri: Uri): String? {
+ return uri.pathSegments.singleOrNull()?.takeIf { it in supportedTables }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncAdapter.kt
new file mode 100644
index 000000000..b2e9daa9f
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncAdapter.kt
@@ -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)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncProvider.kt b/app/src/main/java/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncProvider.kt
new file mode 100644
index 000000000..d09666ee6
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncProvider.kt
@@ -0,0 +1,5 @@
+package org.koitharu.kotatsu.sync.ui.favourites
+
+import org.koitharu.kotatsu.sync.ui.SyncProvider
+
+class FavouritesSyncProvider : SyncProvider()
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncService.kt b/app/src/main/java/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncService.kt
new file mode 100644
index 000000000..397b4e144
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncService.kt
@@ -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
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/ui/history/HistorySyncAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/sync/ui/history/HistorySyncAdapter.kt
new file mode 100644
index 000000000..f574131b6
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/sync/ui/history/HistorySyncAdapter.kt
@@ -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)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/ui/history/HistorySyncProvider.kt b/app/src/main/java/org/koitharu/kotatsu/sync/ui/history/HistorySyncProvider.kt
new file mode 100644
index 000000000..f4bf2cdd3
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/sync/ui/history/HistorySyncProvider.kt
@@ -0,0 +1,5 @@
+package org.koitharu.kotatsu.sync.ui.history
+
+import org.koitharu.kotatsu.sync.ui.SyncProvider
+
+class HistorySyncProvider : SyncProvider()
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/ui/history/HistorySyncService.kt b/app/src/main/java/org/koitharu/kotatsu/sync/ui/history/HistorySyncService.kt
new file mode 100644
index 000000000..4fdc8f00e
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/sync/ui/history/HistorySyncService.kt
@@ -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
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt
index 733bf17d4..f9f2452ba 100644
--- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/AndroidExt.kt
@@ -1,14 +1,20 @@
package org.koitharu.kotatsu.utils.ext
import android.content.Context
+import android.content.OperationApplicationException
+import android.content.SyncResult
+import android.database.SQLException
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkRequest
import android.net.Uri
import android.os.Build
import androidx.work.CoroutineWorker
-import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
+import okio.IOException
+import org.json.JSONException
+import org.koitharu.kotatsu.BuildConfig
+import kotlin.coroutines.resume
val Context.connectivityManager: ConnectivityManager
get() = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
@@ -40,4 +46,14 @@ fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this)
suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatching {
val info = getForegroundInfo()
setForeground(info)
-}.isSuccess
\ No newline at end of file
+}.isSuccess
+
+fun SyncResult.onError(error: Throwable) {
+ when (error) {
+ is IOException -> stats.numIoExceptions++
+ is OperationApplicationException,
+ is SQLException -> databaseError = true
+ is JSONException -> stats.numParseExceptions++
+ else -> if (BuildConfig.DEBUG) throw error
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CursorExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CursorExt.kt
new file mode 100644
index 000000000..852d92e10
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CursorExt.kt
@@ -0,0 +1,39 @@
+package org.koitharu.kotatsu.utils.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)) {
+ 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`"
\ No newline at end of file
diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/HttpExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/HttpExt.kt
new file mode 100644
index 000000000..3f3b966cc
--- /dev/null
+++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/HttpExt.kt
@@ -0,0 +1,9 @@
+package org.koitharu.kotatsu.utils.ext
+
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONObject
+
+private val TYPE_JSON = "application/json".toMediaType()
+
+fun JSONObject.toRequestBody() = toString().toRequestBody(TYPE_JSON)
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_sync.xml b/app/src/main/res/drawable/ic_sync.xml
new file mode 100644
index 000000000..ad631f02e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_sync.xml
@@ -0,0 +1,12 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_sync_auth.xml b/app/src/main/res/layout/activity_sync_auth.xml
new file mode 100644
index 000000000..a0c7fefcd
--- /dev/null
+++ b/app/src/main/res/layout/activity_sync_auth.xml
@@ -0,0 +1,177 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/constants.xml b/app/src/main/res/values/constants.xml
index 5132610e6..e63a0cb2d 100644
--- a/app/src/main/res/values/constants.xml
+++ b/app/src/main/res/values/constants.xml
@@ -1,9 +1,11 @@
- https://github.com/nv95/Kotatsu/issues
- https://discord.gg/NNJ5RgVBC5
- https://4pda.to/forum/index.php?showtopic=697669
- https://hosted.weblate.org/engage/kotatsu
+ https://github.com/nv95/Kotatsu/issues
+ https://discord.gg/NNJ5RgVBC5
+ https://4pda.to/forum/index.php?showtopic=697669
+ https://hosted.weblate.org/engage/kotatsu
+ org.kotatsu.sync
+ http://192.168.0.113:8080
- -1
- 1
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 828ad9fe6..24a6d446d 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -279,4 +279,10 @@
Helps avoid blocking your IP address
Saved manga processing
Chapters will be removed in the background. It can take some time
+ Canceled
+ Account already exists
+ Back
+ Synchronization
+ Sync your data
+ Enter your email to continue
\ No newline at end of file
diff --git a/app/src/main/res/xml/authenticator_sync.xml b/app/src/main/res/xml/authenticator_sync.xml
new file mode 100644
index 000000000..371460404
--- /dev/null
+++ b/app/src/main/res/xml/authenticator_sync.xml
@@ -0,0 +1,7 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/pref_sync.xml b/app/src/main/res/xml/pref_sync.xml
new file mode 100644
index 000000000..f8cafca8f
--- /dev/null
+++ b/app/src/main/res/xml/pref_sync.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/sync_favourites.xml b/app/src/main/res/xml/sync_favourites.xml
new file mode 100644
index 000000000..fbd69b79d
--- /dev/null
+++ b/app/src/main/res/xml/sync_favourites.xml
@@ -0,0 +1,8 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/sync_history.xml b/app/src/main/res/xml/sync_history.xml
new file mode 100644
index 000000000..97110bb53
--- /dev/null
+++ b/app/src/main/res/xml/sync_history.xml
@@ -0,0 +1,8 @@
+
+
\ No newline at end of file