From 837fb9113314f71f1d1fd9f287bcb6d7ba762ee4 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Thu, 28 Apr 2022 15:02:29 +0300 Subject: [PATCH 01/14] First step syncronization implementation --- app/src/main/AndroidManifest.xml | 62 +++++- .../java/org/koitharu/kotatsu/KotatsuApp.kt | 2 + .../koitharu/kotatsu/core/db/MangaDatabase.kt | 7 + .../kotatsu/core/db/entity/MangaEntity.kt | 3 +- .../kotatsu/core/db/entity/MangaTagsEntity.kt | 10 +- .../kotatsu/core/db/entity/TagEntity.kt | 3 +- .../data/FavouriteCategoryEntity.kt | 3 +- .../favourites/data/FavouriteEntity.kt | 4 +- .../kotatsu/history/data/HistoryDao.kt | 3 - .../kotatsu/history/data/HistoryEntity.kt | 5 +- .../org/koitharu/kotatsu/sync/SyncModule.kt | 12 + .../kotatsu/sync/data/AccountInterceptor.kt | 29 +++ .../kotatsu/sync/domain/SyncAuthResult.kt | 28 +++ .../kotatsu/sync/domain/SyncRepository.kt | 206 ++++++++++++++++++ .../kotatsu/sync/ui/SyncAuthActivity.kt | 159 ++++++++++++++ .../kotatsu/sync/ui/SyncAuthViewModel.kt | 44 ++++ .../kotatsu/sync/ui/SyncAuthenticator.kt | 79 +++++++ .../sync/ui/SyncAuthenticatorService.kt | 19 ++ .../koitharu/kotatsu/sync/ui/SyncProvider.kt | 96 ++++++++ .../ui/favourites/FavouritesSyncAdapter.kt | 28 +++ .../ui/favourites/FavouritesSyncProvider.kt | 5 + .../ui/favourites/FavouritesSyncService.kt | 19 ++ .../sync/ui/history/HistorySyncAdapter.kt | 27 +++ .../sync/ui/history/HistorySyncProvider.kt | 5 + .../sync/ui/history/HistorySyncService.kt | 19 ++ .../koitharu/kotatsu/utils/ext/AndroidExt.kt | 20 +- .../koitharu/kotatsu/utils/ext/CursorExt.kt | 39 ++++ .../org/koitharu/kotatsu/utils/ext/HttpExt.kt | 9 + app/src/main/res/drawable/ic_sync.xml | 12 + .../main/res/layout/activity_sync_auth.xml | 177 +++++++++++++++ app/src/main/res/values/constants.xml | 10 +- app/src/main/res/values/strings.xml | 6 + app/src/main/res/xml/authenticator_sync.xml | 7 + app/src/main/res/xml/pref_sync.xml | 10 + app/src/main/res/xml/sync_favourites.xml | 8 + app/src/main/res/xml/sync_history.xml | 8 + 36 files changed, 1160 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/sync/SyncModule.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/sync/data/AccountInterceptor.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncAuthResult.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncRepository.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthActivity.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthViewModel.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthenticator.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncAuthenticatorService.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/sync/ui/SyncProvider.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncAdapter.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncProvider.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncService.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/sync/ui/history/HistorySyncAdapter.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/sync/ui/history/HistorySyncProvider.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/sync/ui/history/HistorySyncService.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/utils/ext/CursorExt.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/utils/ext/HttpExt.kt create mode 100644 app/src/main/res/drawable/ic_sync.xml create mode 100644 app/src/main/res/layout/activity_sync_auth.xml create mode 100644 app/src/main/res/xml/authenticator_sync.xml create mode 100644 app/src/main/res/xml/pref_sync.xml create mode 100644 app/src/main/res/xml/sync_favourites.xml create mode 100644 app/src/main/res/xml/sync_history.xml 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 @@ + + + + + + + + + + + + + + + + + +