Move sources from java to kotlin dir

This commit is contained in:
Koitharu
2023-05-22 18:16:50 +03:00
parent a8f5714b35
commit c3216871ed
711 changed files with 1 additions and 0 deletions

View File

@@ -0,0 +1,47 @@
package org.koitharu.kotatsu.sync.data
import dagger.Reusable
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import org.koitharu.kotatsu.core.exceptions.SyncApiException
import org.koitharu.kotatsu.core.network.BaseHttpClient
import org.koitharu.kotatsu.core.util.ext.toRequestBody
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.parseJson
import org.koitharu.kotatsu.parsers.util.removeSurrounding
import javax.inject.Inject
@Reusable
class SyncAuthApi @Inject constructor(
@BaseHttpClient private val okHttpClient: OkHttpClient,
) {
suspend fun authenticate(host: String, email: String, password: String): String {
val body = JSONObject(
mapOf("email" to email, "password" to password),
).toRequestBody()
val scheme = getScheme(host)
val request = Request.Builder()
.url("$scheme://$host/auth")
.post(body)
.build()
val response = okHttpClient.newCall(request).await()
if (response.isSuccessful) {
return response.parseJson().getString("token")
} else {
val code = response.code
val message = response.use { checkNotNull(it.body).string() }.removeSurrounding('"')
throw SyncApiException(message, code)
}
}
private suspend fun getScheme(host: String): String {
val request = Request.Builder()
.url("http://$host/")
.head()
.build()
val response = okHttpClient.newCall(request).await()
return response.request.url.scheme
}
}

View File

@@ -0,0 +1,41 @@
package org.koitharu.kotatsu.sync.data
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import kotlinx.coroutines.runBlocking
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.CommonHeaders
class SyncAuthenticator(
context: Context,
private val account: Account,
private val syncSettings: SyncSettings,
private val authApi: SyncAuthApi,
) : Authenticator {
private val accountManager = AccountManager.get(context)
private val tokenType = context.getString(R.string.account_type_sync)
override fun authenticate(route: Route?, response: Response): Request? {
val newToken = tryRefreshToken() ?: return null
accountManager.setAuthToken(account, tokenType, newToken)
return response.request.newBuilder()
.header(CommonHeaders.AUTHORIZATION, "Bearer $newToken")
.build()
}
private fun tryRefreshToken() = runCatching {
runBlocking {
authApi.authenticate(
syncSettings.host,
account.name,
accountManager.getPassword(account),
)
}
}.getOrNull()
}

View File

@@ -0,0 +1,31 @@
package org.koitharu.kotatsu.sync.data
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import okhttp3.Interceptor
import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.DATABASE_VERSION
import org.koitharu.kotatsu.core.network.CommonHeaders
class SyncInterceptor(
context: Context,
private val account: Account,
) : Interceptor {
private val accountManager = AccountManager.get(context)
private val tokenType = context.getString(R.string.account_type_sync)
override fun intercept(chain: Interceptor.Chain): Response {
val token = accountManager.peekAuthToken(account, tokenType)
val requestBuilder = chain.request().newBuilder()
if (token != null) {
requestBuilder.header(CommonHeaders.AUTHORIZATION, "Bearer $token")
}
requestBuilder.header("X-App-Version", BuildConfig.VERSION_CODE.toString())
requestBuilder.header("X-Db-Version", DATABASE_VERSION.toString())
return chain.proceed(requestBuilder.build())
}
}

View File

@@ -0,0 +1,46 @@
package org.koitharu.kotatsu.sync.data
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
import androidx.annotation.WorkerThread
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import javax.inject.Inject
@Reusable
class SyncSettings(
context: Context,
private val account: Account?,
) {
@Inject
constructor(@ApplicationContext context: Context) : this(
context,
AccountManager.get(context)
.getAccountsByType(context.getString(R.string.account_type_sync))
.firstOrNull(),
)
private val accountManager = AccountManager.get(context)
private val defaultHost = context.getString(R.string.sync_host_default)
@get:WorkerThread
@set:WorkerThread
var host: String
get() = account?.let {
accountManager.getUserData(it, KEY_HOST)
}.ifNullOrEmpty { defaultHost }
set(value) {
account?.let {
accountManager.setUserData(it, KEY_HOST, value)
}
}
companion object {
const val KEY_HOST = "host"
}
}

View File

@@ -0,0 +1,29 @@
package org.koitharu.kotatsu.sync.domain
class SyncAuthResult(
val host: String,
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 (host != other.host) return false
if (email != other.email) return false
if (password != other.password) return false
return token == other.token
}
override fun hashCode(): Int {
var result = host.hashCode()
result = 31 * result + email.hashCode()
result = 31 * result + password.hashCode()
result = 31 * result + token.hashCode()
return result
}
}

View File

@@ -0,0 +1,138 @@
package org.koitharu.kotatsu.sync.domain
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentResolver
import android.content.ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE
import android.content.Context
import android.os.Bundle
import androidx.room.InvalidationTracker
import androidx.room.withTransaction
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES
import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Singleton
@Singleton
class SyncController @Inject constructor(
@ApplicationContext context: Context,
private val dbProvider: Provider<MangaDatabase>,
) : InvalidationTracker.Observer(arrayOf(TABLE_HISTORY, TABLE_FAVOURITES, TABLE_FAVOURITE_CATEGORIES)) {
private val authorityHistory = context.getString(R.string.sync_authority_history)
private val authorityFavourites = context.getString(R.string.sync_authority_favourites)
private val am = AccountManager.get(context)
private val accountType = context.getString(R.string.account_type_sync)
private val mutex = Mutex()
private val defaultGcPeriod = TimeUnit.DAYS.toMillis(2) // gc period if sync disabled
override fun onInvalidated(tables: Set<String>) {
requestSync(
favourites = TABLE_FAVOURITES in tables || TABLE_FAVOURITE_CATEGORIES in tables,
history = TABLE_HISTORY in tables,
)
}
fun isEnabled(account: Account): Boolean {
return ContentResolver.getMasterSyncAutomatically() && (ContentResolver.getSyncAutomatically(
account,
authorityFavourites,
) || ContentResolver.getSyncAutomatically(
account,
authorityHistory,
))
}
fun getLastSync(account: Account, authority: String): Long {
val key = "last_sync_" + authority.substringAfterLast('.')
val rawValue = am.getUserData(account, key) ?: return 0L
return rawValue.toLongOrNull() ?: 0L
}
fun observeSyncStatus(): Flow<Boolean> = callbackFlow {
val handle = ContentResolver.addStatusChangeListener(SYNC_OBSERVER_TYPE_ACTIVE) { which ->
trySendBlocking(which and SYNC_OBSERVER_TYPE_ACTIVE != 0)
}
awaitClose { ContentResolver.removeStatusChangeListener(handle) }
}
suspend fun requestFullSync() = withContext(Dispatchers.Default) {
requestSyncImpl(favourites = true, history = true)
}
private fun requestSync(favourites: Boolean, history: Boolean) = processLifecycleScope.launch(Dispatchers.Default) {
requestSyncImpl(favourites = favourites, history = history)
}
private suspend fun requestSyncImpl(favourites: Boolean, history: Boolean) = mutex.withLock {
if (!favourites && !history) {
return
}
val db = dbProvider.get()
val account = peekAccount()
if (account == null || !ContentResolver.getMasterSyncAutomatically()) {
db.gc(favourites, history)
return
}
var gcHistory = false
var gcFavourites = false
if (favourites) {
if (ContentResolver.getSyncAutomatically(account, authorityFavourites)) {
ContentResolver.requestSync(account, authorityFavourites, Bundle.EMPTY)
} else {
gcFavourites = true
}
}
if (history) {
if (ContentResolver.getSyncAutomatically(account, authorityHistory)) {
ContentResolver.requestSync(account, authorityHistory, Bundle.EMPTY)
} else {
gcHistory = true
}
}
if (gcHistory || gcFavourites) {
db.gc(gcFavourites, gcHistory)
}
}
private fun peekAccount(): Account? {
return am.getAccountsByType(accountType).firstOrNull()
}
private suspend fun MangaDatabase.gc(favourites: Boolean, history: Boolean) = withTransaction {
val deletedAt = System.currentTimeMillis() - defaultGcPeriod
if (history) {
historyDao.gc(deletedAt)
}
if (favourites) {
favouritesDao.gc(deletedAt)
favouriteCategoriesDao.gc(deletedAt)
}
}
companion object {
@JvmStatic
fun setLastSync(context: Context, account: Account, authority: String, time: Long) {
val key = "last_sync_" + authority.substringAfterLast('.')
val am = AccountManager.get(context)
am.setUserData(account, key, time.toString())
}
}
}

View File

@@ -0,0 +1,324 @@
package org.koitharu.kotatsu.sync.domain
import android.accounts.Account
import android.content.ContentProviderClient
import android.content.ContentProviderOperation
import android.content.ContentProviderResult
import android.content.Context
import android.content.OperationApplicationException
import android.content.SyncResult
import android.database.Cursor
import android.net.Uri
import androidx.annotation.WorkerThread
import androidx.core.content.contentValuesOf
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.json.JSONArray
import org.json.JSONObject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES
import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import org.koitharu.kotatsu.core.db.TABLE_MANGA
import org.koitharu.kotatsu.core.db.TABLE_MANGA_TAGS
import org.koitharu.kotatsu.core.db.TABLE_TAGS
import org.koitharu.kotatsu.core.logs.LoggersModule
import org.koitharu.kotatsu.core.network.GZipInterceptor
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.parseJsonOrNull
import org.koitharu.kotatsu.core.util.ext.toContentValues
import org.koitharu.kotatsu.core.util.ext.toJson
import org.koitharu.kotatsu.core.util.ext.toRequestBody
import org.koitharu.kotatsu.parsers.util.json.mapJSONTo
import org.koitharu.kotatsu.sync.data.SyncAuthApi
import org.koitharu.kotatsu.sync.data.SyncAuthenticator
import org.koitharu.kotatsu.sync.data.SyncInterceptor
import org.koitharu.kotatsu.sync.data.SyncSettings
import java.util.concurrent.TimeUnit
private const val FIELD_TIMESTAMP = "timestamp"
/**
* Warning! This class may be used in another process
*/
@WorkerThread
class SyncHelper(
context: Context,
private val account: Account,
private val provider: ContentProviderClient,
) {
private val authorityHistory = context.getString(R.string.sync_authority_history)
private val authorityFavourites = context.getString(R.string.sync_authority_favourites)
private val settings = SyncSettings(context, account)
private val httpClient = OkHttpClient.Builder()
.authenticator(SyncAuthenticator(context, account, settings, SyncAuthApi(OkHttpClient())))
.addInterceptor(SyncInterceptor(context, account))
.addInterceptor(GZipInterceptor())
.build()
private val baseUrl: String by lazy {
val host = settings.host
val scheme = getScheme(host)
"$scheme://$host"
}
private val defaultGcPeriod: Long // gc period if sync enabled
get() = TimeUnit.DAYS.toMillis(4)
private val logger = LoggersModule.provideSyncLogger(context, AppSettings(context))
fun syncFavourites(syncResult: SyncResult) {
val data = JSONObject()
data.put(TABLE_FAVOURITE_CATEGORIES, getFavouriteCategories())
data.put(TABLE_FAVOURITES, getFavourites())
data.put(FIELD_TIMESTAMP, System.currentTimeMillis())
val request = Request.Builder()
.url("$baseUrl/resource/$TABLE_FAVOURITES")
.post(data.toRequestBody())
.build()
val response = httpClient.newCall(request).execute().log().parseJsonOrNull()
if (response != null) {
val timestamp = response.getLong(FIELD_TIMESTAMP)
val categoriesResult =
upsertFavouriteCategories(response.getJSONArray(TABLE_FAVOURITE_CATEGORIES), timestamp)
syncResult.stats.numDeletes += categoriesResult.first().count?.toLong() ?: 0L
syncResult.stats.numInserts += categoriesResult.drop(1).sumOf { it.count?.toLong() ?: 0L }
val favouritesResult = upsertFavourites(response.getJSONArray(TABLE_FAVOURITES), timestamp)
syncResult.stats.numDeletes += favouritesResult.first().count?.toLong() ?: 0L
syncResult.stats.numInserts += favouritesResult.drop(1).sumOf { it.count?.toLong() ?: 0L }
}
gcFavourites()
}
fun syncHistory(syncResult: SyncResult) {
val data = JSONObject()
data.put(TABLE_HISTORY, getHistory())
data.put(FIELD_TIMESTAMP, System.currentTimeMillis())
val request = Request.Builder()
.url("$baseUrl/resource/$TABLE_HISTORY")
.post(data.toRequestBody())
.build()
val response = httpClient.newCall(request).execute().log().parseJsonOrNull()
if (response != null) {
val result = upsertHistory(
json = response.getJSONArray(TABLE_HISTORY),
timestamp = response.getLong(FIELD_TIMESTAMP),
)
syncResult.stats.numDeletes += result.first().count?.toLong() ?: 0L
syncResult.stats.numInserts += result.drop(1).sumOf { it.count?.toLong() ?: 0L }
}
gcHistory()
}
fun onError(e: Throwable) {
if (logger.isEnabled) {
logger.log("Sync error", e)
}
}
fun onSyncComplete(result: SyncResult) {
if (logger.isEnabled) {
logger.log("Sync finshed: ${result.toDebugString()}")
logger.flushBlocking()
}
}
private fun upsertHistory(json: JSONArray, timestamp: Long): Array<ContentProviderResult> {
val uri = uri(authorityHistory, TABLE_HISTORY)
val operations = ArrayList<ContentProviderOperation>()
operations += ContentProviderOperation.newDelete(uri)
.withSelection("updated_at < ?", arrayOf(timestamp.toString()))
.build()
json.mapJSONTo(operations) { jo ->
operations.addAll(upsertManga(jo.removeJSONObject("manga"), authorityHistory))
ContentProviderOperation.newInsert(uri)
.withValues(jo.toContentValues())
.build()
}
return provider.applyBatch(operations)
}
private fun upsertFavouriteCategories(json: JSONArray, timestamp: Long): Array<ContentProviderResult> {
val uri = uri(authorityFavourites, TABLE_FAVOURITE_CATEGORIES)
val operations = ArrayList<ContentProviderOperation>()
operations += ContentProviderOperation.newDelete(uri)
.withSelection("created_at < ?", arrayOf(timestamp.toString()))
.build()
json.mapJSONTo(operations) { jo ->
ContentProviderOperation.newInsert(uri)
.withValues(jo.toContentValues())
.build()
}
return provider.applyBatch(operations)
}
private fun upsertFavourites(json: JSONArray, timestamp: Long): Array<ContentProviderResult> {
val uri = uri(authorityFavourites, TABLE_FAVOURITES)
val operations = ArrayList<ContentProviderOperation>()
operations += ContentProviderOperation.newDelete(uri)
.withSelection("created_at < ?", arrayOf(timestamp.toString()))
.build()
json.mapJSONTo(operations) { jo ->
operations.addAll(upsertManga(jo.removeJSONObject("manga"), authorityFavourites))
ContentProviderOperation.newInsert(uri)
.withValues(jo.toContentValues())
.build()
}
return provider.applyBatch(operations)
}
private fun upsertManga(json: JSONObject, authority: String): List<ContentProviderOperation> {
val tags = json.removeJSONArray(TABLE_TAGS)
val result = ArrayList<ContentProviderOperation>(tags.length() * 2 + 1)
for (i in 0 until tags.length()) {
val tag = tags.getJSONObject(i)
result += ContentProviderOperation.newInsert(uri(authority, TABLE_TAGS))
.withValues(tag.toContentValues())
.build()
result += ContentProviderOperation.newInsert(uri(authority, TABLE_MANGA_TAGS))
.withValues(
contentValuesOf(
"manga_id" to json.getLong("manga_id"),
"tag_id" to tag.getLong("tag_id"),
),
).build()
}
result.add(
0,
ContentProviderOperation.newInsert(uri(authority, TABLE_MANGA))
.withValues(json.toContentValues())
.build(),
)
return result
}
private fun getHistory(): JSONArray {
return provider.query(authorityHistory, TABLE_HISTORY).use { cursor ->
val json = JSONArray()
if (cursor.moveToFirst()) {
do {
val jo = cursor.toJson()
jo.put("manga", getManga(authorityHistory, jo.getLong("manga_id")))
json.put(jo)
} while (cursor.moveToNext())
}
json
}
}
private fun getFavourites(): JSONArray {
return provider.query(authorityFavourites, TABLE_FAVOURITES).use { cursor ->
val json = JSONArray()
if (cursor.moveToFirst()) {
do {
val jo = cursor.toJson()
jo.put("manga", getManga(authorityFavourites, jo.getLong("manga_id")))
json.put(jo)
} while (cursor.moveToNext())
}
json
}
}
private fun getFavouriteCategories(): JSONArray {
return provider.query(authorityFavourites, TABLE_FAVOURITE_CATEGORIES).use { cursor ->
val json = JSONArray()
if (cursor.moveToFirst()) {
do {
json.put(cursor.toJson())
} while (cursor.moveToNext())
}
json
}
}
private fun 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 getScheme(host: String): String {
val request = Request.Builder()
.url("http://$host/")
.head()
.build()
val response = httpClient.newCall(request).execute()
return response.request.url.scheme
}
private fun gcFavourites() {
val deletedAt = System.currentTimeMillis() - defaultGcPeriod
val selection = "deleted_at != 0 AND deleted_at < ?"
val args = arrayOf(deletedAt.toString())
provider.delete(uri(authorityFavourites, TABLE_FAVOURITES), selection, args)
provider.delete(uri(authorityFavourites, TABLE_FAVOURITE_CATEGORIES), selection, args)
}
private fun gcHistory() {
val deletedAt = System.currentTimeMillis() - defaultGcPeriod
val selection = "deleted_at != 0 AND deleted_at < ?"
val args = arrayOf(deletedAt.toString())
provider.delete(uri(authorityHistory, TABLE_HISTORY), selection, args)
}
private fun ContentProviderClient.query(authority: String, table: String): Cursor {
val uri = uri(authority, table)
return query(uri, null, null, null, null)
?: throw OperationApplicationException("Query failed: $uri")
}
private fun uri(authority: String, table: String) = Uri.parse("content://$authority/$table")
private fun JSONObject.removeJSONObject(name: String) = remove(name) as JSONObject
private fun JSONObject.removeJSONArray(name: String) = remove(name) as JSONArray
private fun Response.log() = apply {
if (logger.isEnabled) {
logger.log("$code ${request.url}")
}
}
}

View File

@@ -0,0 +1,78 @@
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 SyncAccountAuthenticator(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)
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,213 @@
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 android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.viewModels
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.fragment.app.FragmentResultListener
import androidx.transition.Fade
import androidx.transition.TransitionManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat
import org.koitharu.kotatsu.databinding.ActivitySyncAuthBinding
import org.koitharu.kotatsu.sync.data.SyncSettings
import org.koitharu.kotatsu.sync.domain.SyncAuthResult
@AndroidEntryPoint
class SyncAuthActivity : BaseActivity<ActivitySyncAuthBinding>(), View.OnClickListener, FragmentResultListener {
private var accountAuthenticatorResponse: AccountAuthenticatorResponse? = null
private var resultBundle: Bundle? = null
private val pageBackCallback = PageBackCallback()
private val viewModel by viewModels<SyncAuthViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivitySyncAuthBinding.inflate(layoutInflater))
accountAuthenticatorResponse =
intent.getParcelableExtraCompat(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE)
accountAuthenticatorResponse?.onRequestContinued()
viewBinding.buttonCancel.setOnClickListener(this)
viewBinding.buttonNext.setOnClickListener(this)
viewBinding.buttonBack.setOnClickListener(this)
viewBinding.buttonDone.setOnClickListener(this)
viewBinding.layoutProgress.setOnClickListener(this)
viewBinding.buttonSettings.setOnClickListener(this)
viewBinding.editEmail.addTextChangedListener(EmailTextWatcher(viewBinding.buttonNext))
viewBinding.editPassword.addTextChangedListener(PasswordTextWatcher(viewBinding.buttonDone))
onBackPressedDispatcher.addCallback(pageBackCallback)
viewModel.onTokenObtained.observe(this, ::onTokenReceived)
viewModel.onError.observe(this, ::onError)
viewModel.isLoading.observe(this, ::onLoadingStateChanged)
viewModel.onAccountAlreadyExists.observe(this) {
onAccountAlreadyExists()
}
supportFragmentManager.setFragmentResultListener(SyncHostDialogFragment.REQUEST_KEY, this, this)
pageBackCallback.update()
}
override fun onWindowInsetsChanged(insets: Insets) {
val basePadding = resources.getDimensionPixelOffset(R.dimen.screen_padding)
viewBinding.root.setPadding(
basePadding + insets.left,
basePadding + insets.top,
basePadding + insets.right,
basePadding + insets.bottom,
)
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_cancel -> {
setResult(RESULT_CANCELED)
finish()
}
R.id.button_next -> {
viewBinding.groupLogin.isVisible = false
viewBinding.groupPassword.isVisible = true
pageBackCallback.update()
viewBinding.editPassword.requestFocus()
}
R.id.button_back -> {
viewBinding.groupPassword.isVisible = false
viewBinding.groupLogin.isVisible = true
pageBackCallback.update()
viewBinding.editEmail.requestFocus()
}
R.id.button_done -> {
viewModel.obtainToken(
email = viewBinding.editEmail.text.toString(),
password = viewBinding.editPassword.text.toString(),
)
}
R.id.button_settings -> {
SyncHostDialogFragment.show(supportFragmentManager)
}
}
}
override fun onFragmentResult(requestKey: String, result: Bundle) {
val host = result.getString(SyncHostDialogFragment.KEY_HOST) ?: return
viewModel.host.value = host
}
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 == viewBinding.layoutProgress.isVisible) {
return
}
TransitionManager.beginDelayedTransition(viewBinding.root, Fade())
viewBinding.layoutProgress.isVisible = isLoading
pageBackCallback.update()
}
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 userdata = Bundle(1)
userdata.putString(SyncSettings.KEY_HOST, authResult.host)
val result = Bundle()
if (am.addAccountExplicitly(account, authResult.password, userdata)) {
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 fun onAccountAlreadyExists() {
Toast.makeText(this, R.string.account_already_exists, Toast.LENGTH_SHORT)
.show()
accountAuthenticatorResponse?.onError(
AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION,
getString(R.string.account_already_exists),
)
super.finishAfterTransition()
}
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
}
}
private inner class PageBackCallback : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
viewBinding.groupLogin.isVisible = true
viewBinding.groupPassword.isVisible = false
viewBinding.editEmail.requestFocus()
update()
}
fun update() {
isEnabled = !viewBinding.layoutProgress.isVisible && viewBinding.groupPassword.isVisible
}
}
}

View File

@@ -0,0 +1,47 @@
package org.koitharu.kotatsu.sync.ui
import android.accounts.AccountManager
import android.content.Context
import androidx.lifecycle.MutableLiveData
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.SingleLiveEvent
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.sync.data.SyncAuthApi
import org.koitharu.kotatsu.sync.domain.SyncAuthResult
import javax.inject.Inject
@HiltViewModel
class SyncAuthViewModel @Inject constructor(
@ApplicationContext context: Context,
private val api: SyncAuthApi,
) : BaseViewModel() {
val onAccountAlreadyExists = SingleLiveEvent<Unit>()
val onTokenObtained = SingleLiveEvent<SyncAuthResult>()
val host = MutableLiveData("")
private val defaultHost = context.getString(R.string.sync_host_default)
init {
launchJob(Dispatchers.Default) {
val am = AccountManager.get(context)
val accounts = am.getAccountsByType(context.getString(R.string.account_type_sync))
if (accounts.isNotEmpty()) {
onAccountAlreadyExists.emitCall(Unit)
}
}
}
fun obtainToken(email: String, password: String) {
val hostValue = host.value.ifNullOrEmpty { defaultHost }
launchLoadingJob(Dispatchers.Default) {
val token = api.authenticate(hostValue, email, password)
val result = SyncAuthResult(host.value.orEmpty(), email, password, token)
onTokenObtained.emitCall(result)
}
}
}

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: SyncAccountAuthenticator
override fun onCreate() {
super.onCreate()
authenticator = SyncAccountAuthenticator(this)
}
override fun onBind(intent: Intent?): IBinder? {
return authenticator.iBinder
}
}

View File

@@ -0,0 +1,81 @@
package org.koitharu.kotatsu.sync.ui
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import android.view.ViewGroup.MarginLayoutParams
import android.widget.ArrayAdapter
import androidx.core.os.bundleOf
import androidx.core.view.updateLayoutParams
import androidx.fragment.app.FragmentManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.databinding.PreferenceDialogAutocompletetextviewBinding
import org.koitharu.kotatsu.settings.DomainValidator
import org.koitharu.kotatsu.sync.data.SyncSettings
import javax.inject.Inject
@AndroidEntryPoint
class SyncHostDialogFragment : AlertDialogFragment<PreferenceDialogAutocompletetextviewBinding>(),
DialogInterface.OnClickListener {
@Inject
lateinit var syncSettings: SyncSettings
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?
) = PreferenceDialogAutocompletetextviewBinding.inflate(inflater, container, false)
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
return super.onBuildDialog(builder)
.setPositiveButton(android.R.string.ok, this)
.setNegativeButton(android.R.string.cancel, this)
.setCancelable(false)
.setTitle(R.string.server_address)
}
override fun onViewBindingCreated(
binding: PreferenceDialogAutocompletetextviewBinding,
savedInstanceState: Bundle?
) {
super.onViewBindingCreated(binding, savedInstanceState)
binding.message.updateLayoutParams<MarginLayoutParams> {
topMargin = binding.root.resources.getDimensionPixelOffset(R.dimen.screen_padding)
bottomMargin = topMargin
}
binding.message.setText(R.string.sync_host_description)
val entries = binding.root.resources.getStringArray(R.array.sync_host_list)
val editText = binding.edit
editText.setText(syncSettings.host)
editText.threshold = 0
editText.setAdapter(ArrayAdapter(binding.root.context, android.R.layout.simple_spinner_dropdown_item, entries))
binding.dropdown.setOnClickListener {
editText.showDropDown()
}
DomainValidator().attachToEditText(editText)
}
override fun onClick(dialog: DialogInterface, which: Int) {
when (which) {
DialogInterface.BUTTON_POSITIVE -> {
val result = requireViewBinding().edit.text?.toString().orEmpty()
syncSettings.host = result
parentFragmentManager.setFragmentResult(REQUEST_KEY, bundleOf(KEY_HOST to result))
}
}
dialog.dismiss()
}
companion object {
private const val TAG = "SyncHostDialogFragment"
const val REQUEST_KEY = "sync_host"
const val KEY_HOST = "host"
fun show(fm: FragmentManager) = SyncHostDialogFragment().show(fm, TAG)
}
}

View File

@@ -0,0 +1,124 @@
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.SupportSQLiteDatabase
import androidx.sqlite.db.SupportSQLiteQueryBuilder
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import org.koitharu.kotatsu.core.db.*
import java.util.concurrent.Callable
abstract class SyncProvider : ContentProvider() {
private val entryPoint by lazy {
EntryPointAccessors.fromApplication(checkNotNull(context), SyncProviderEntryPoint::class.java)
}
private val database by lazy { entryPoint.database }
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? {
val tableName = getTableName(uri) ?: return null
val sqlQuery = SupportSQLiteQueryBuilder.builder(tableName)
.columns(projection)
.selection(selection, selectionArgs)
.orderBy(sortOrder)
.create()
return database.openHelper.readableDatabase.query(sqlQuery)
}
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
if (db.insert(table, SQLiteDatabase.CONFLICT_IGNORE, values) < 0) {
db.update(table, values)
}
return uri
}
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
val table = getTableName(uri) ?: 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 runAtomicTransaction { super.applyBatch(operations) }
}
override fun bulkInsert(uri: Uri, values: Array<out ContentValues>): Int {
return runAtomicTransaction { super.bulkInsert(uri, values) }
}
private fun getTableName(uri: Uri): String? {
return uri.pathSegments.singleOrNull()?.takeIf { it in supportedTables }
}
private fun <R> runAtomicTransaction(callable: Callable<R>): R {
return synchronized(database) {
database.runInTransaction(callable)
}
}
private fun SupportSQLiteDatabase.update(table: String, values: ContentValues) {
val keys = when (table) {
TABLE_TAGS -> listOf("tag_id")
TABLE_MANGA_TAGS -> listOf("tag_id", "manga_id")
TABLE_MANGA -> listOf("manga_id")
TABLE_FAVOURITES -> listOf("manga_id", "category_id")
TABLE_FAVOURITE_CATEGORIES -> listOf("category_id")
TABLE_HISTORY -> listOf("manga_id")
else -> throw IllegalArgumentException("Update for $table is not supported")
}
val whereClause = keys.joinToString(" AND ") { "`$it` = ?" }
val whereArgs = Array<Any>(keys.size) { i -> values.get("`${keys[i]}`") ?: values.get(keys[i]) }
this.update(table, SQLiteDatabase.CONFLICT_IGNORE, values, whereClause, whereArgs)
}
@EntryPoint
@InstallIn(SingletonComponent::class)
interface SyncProviderEntryPoint {
val database: MangaDatabase
}
}

View File

@@ -0,0 +1,18 @@
package org.koitharu.kotatsu.sync.ui
import android.accounts.Account
import android.content.Intent
import android.os.Bundle
private const val ACCOUNT_KEY = "account"
private const val ACTION_ACCOUNT_SYNC_SETTINGS = "android.settings.ACCOUNT_SYNC_SETTINGS"
private const val EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args"
@Suppress("FunctionName")
fun SyncSettingsIntent(account: Account): Intent {
val args = Bundle(1)
args.putParcelable(ACCOUNT_KEY, account)
val intent = Intent(ACTION_ACCOUNT_SYNC_SETTINGS)
intent.putExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS, args)
return intent
}

View File

@@ -0,0 +1,37 @@
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.R
import org.koitharu.kotatsu.core.util.ext.onError
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.sync.domain.SyncController
import org.koitharu.kotatsu.sync.domain.SyncHelper
class FavouritesSyncAdapter(context: Context) : AbstractThreadedSyncAdapter(context, true) {
override fun onPerformSync(
account: Account,
extras: Bundle,
authority: String,
provider: ContentProviderClient,
syncResult: SyncResult,
) {
if (!context.resources.getBoolean(R.bool.is_sync_enabled)) {
return
}
val syncHelper = SyncHelper(context, account, provider)
runCatchingCancellable {
syncHelper.syncFavourites(syncResult)
SyncController.setLastSync(context, account, authority, System.currentTimeMillis())
}.onFailure { e ->
syncResult.onError(e)
syncHelper.onError(e)
}
syncHelper.onSyncComplete(syncResult)
}
}

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,37 @@
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.R
import org.koitharu.kotatsu.core.util.ext.onError
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.sync.domain.SyncController
import org.koitharu.kotatsu.sync.domain.SyncHelper
class HistorySyncAdapter(context: Context) : AbstractThreadedSyncAdapter(context, true) {
override fun onPerformSync(
account: Account,
extras: Bundle,
authority: String,
provider: ContentProviderClient,
syncResult: SyncResult,
) {
if (!context.resources.getBoolean(R.bool.is_sync_enabled)) {
return
}
val syncHelper = SyncHelper(context, account, provider)
runCatchingCancellable {
syncHelper.syncHistory(syncResult)
SyncController.setLastSync(context, account, authority, System.currentTimeMillis())
}.onFailure { e ->
syncResult.onError(e)
syncHelper.onError(e)
}
syncHelper.onSyncComplete(syncResult)
}
}

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