Move sources from java to kotlin dir
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
124
app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncProvider.kt
Normal file
124
app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncProvider.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.koitharu.kotatsu.sync.ui.favourites
|
||||
|
||||
import org.koitharu.kotatsu.sync.ui.SyncProvider
|
||||
|
||||
class FavouritesSyncProvider : SyncProvider()
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.koitharu.kotatsu.sync.ui.history
|
||||
|
||||
import org.koitharu.kotatsu.sync.ui.SyncProvider
|
||||
|
||||
class HistorySyncProvider : SyncProvider()
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user