Merge branch 'feature/sync' into feature/nextgen
commit
57c1d070d1
@ -0,0 +1,13 @@
|
|||||||
|
package org.koitharu.kotatsu.core.db.migrations
|
||||||
|
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
|
class Migration13To14 : Migration(13, 14) {
|
||||||
|
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
database.execSQL("ALTER TABLE favourite_categories ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
|
||||||
|
database.execSQL("ALTER TABLE favourites ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
|
||||||
|
database.execSQL("ALTER TABLE history ADD COLUMN `deleted_at` INTEGER NOT NULL DEFAULT 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
package org.koitharu.kotatsu.sync
|
||||||
|
|
||||||
|
import androidx.room.InvalidationTracker
|
||||||
|
import org.koin.android.ext.koin.androidContext
|
||||||
|
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||||
|
import org.koin.dsl.bind
|
||||||
|
import org.koin.dsl.module
|
||||||
|
import org.koitharu.kotatsu.sync.data.SyncAuthApi
|
||||||
|
import org.koitharu.kotatsu.sync.domain.SyncController
|
||||||
|
import org.koitharu.kotatsu.sync.ui.SyncAuthViewModel
|
||||||
|
|
||||||
|
val syncModule
|
||||||
|
get() = module {
|
||||||
|
|
||||||
|
single { SyncController(androidContext()) } bind InvalidationTracker.Observer::class
|
||||||
|
|
||||||
|
factory { SyncAuthApi(androidContext(), get()) }
|
||||||
|
|
||||||
|
viewModel { SyncAuthViewModel(get()) }
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
package org.koitharu.kotatsu.sync.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.parsers.util.await
|
||||||
|
import org.koitharu.kotatsu.parsers.util.parseJson
|
||||||
|
import org.koitharu.kotatsu.utils.ext.toRequestBody
|
||||||
|
|
||||||
|
class SyncAuthApi(
|
||||||
|
context: Context,
|
||||||
|
private val okHttpClient: OkHttpClient,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val baseUrl = context.getString(R.string.url_sync_server)
|
||||||
|
|
||||||
|
suspend fun authenticate(email: String, password: String): String {
|
||||||
|
val body = JSONObject(
|
||||||
|
mapOf("email" to email, "password" to password)
|
||||||
|
).toRequestBody()
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$baseUrl/auth")
|
||||||
|
.post(body)
|
||||||
|
.build()
|
||||||
|
val response = okHttpClient.newCall(request).await().parseJson()
|
||||||
|
return response.getString("token")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
class SyncAuthenticator(
|
||||||
|
context: Context,
|
||||||
|
private val account: Account,
|
||||||
|
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("Authorization", "Bearer $newToken")
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tryRefreshToken() = runCatching {
|
||||||
|
runBlocking {
|
||||||
|
authApi.authenticate(
|
||||||
|
account.name,
|
||||||
|
accountManager.getPassword(account),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
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("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,28 @@
|
|||||||
|
package org.koitharu.kotatsu.sync.domain
|
||||||
|
|
||||||
|
class SyncAuthResult(
|
||||||
|
val email: String,
|
||||||
|
val password: String,
|
||||||
|
val token: String,
|
||||||
|
) {
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as SyncAuthResult
|
||||||
|
|
||||||
|
if (email != other.email) return false
|
||||||
|
if (password != other.password) return false
|
||||||
|
if (token != other.token) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = email.hashCode()
|
||||||
|
result = 31 * result + password.hashCode()
|
||||||
|
result = 31 * result + token.hashCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,139 @@
|
|||||||
|
package org.koitharu.kotatsu.sync.domain
|
||||||
|
|
||||||
|
import android.accounts.Account
|
||||||
|
import android.accounts.AccountManager
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.ArrayMap
|
||||||
|
import androidx.room.InvalidationTracker
|
||||||
|
import androidx.room.withTransaction
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
|
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.utils.ext.processLifecycleScope
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class SyncController(
|
||||||
|
context: Context,
|
||||||
|
) : InvalidationTracker.Observer(arrayOf(TABLE_HISTORY, TABLE_FAVOURITES, TABLE_FAVOURITE_CATEGORIES)) {
|
||||||
|
|
||||||
|
private val am = AccountManager.get(context)
|
||||||
|
private val accountType = context.getString(R.string.account_type_sync)
|
||||||
|
private val minSyncInterval = if (BuildConfig.DEBUG) {
|
||||||
|
TimeUnit.SECONDS.toMillis(5)
|
||||||
|
} else {
|
||||||
|
TimeUnit.MINUTES.toMillis(4)
|
||||||
|
}
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private val jobs = ArrayMap<String, Job>(2)
|
||||||
|
private val defaultGcPeriod: Long // gc period if sync disabled
|
||||||
|
get() = TimeUnit.HOURS.toMillis(2)
|
||||||
|
|
||||||
|
override fun onInvalidated(tables: MutableSet<String>) {
|
||||||
|
requestSync(
|
||||||
|
favourites = TABLE_FAVOURITES in tables || TABLE_FAVOURITE_CATEGORIES in tables,
|
||||||
|
history = TABLE_HISTORY in tables,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 setLastSync(account: Account, authority: String, time: Long) {
|
||||||
|
val key = "last_sync_" + authority.substringAfterLast('.')
|
||||||
|
am.setUserData(account, key, time.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun requestFullSync() = withContext(Dispatchers.Default) {
|
||||||
|
requestSyncImpl(favourites = true, history = true, db = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun requestFullSyncAndGc(database: MangaDatabase) = withContext(Dispatchers.Default) {
|
||||||
|
requestSyncImpl(favourites = true, history = true, db = database)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestSync(favourites: Boolean, history: Boolean) = processLifecycleScope.launch(Dispatchers.Default) {
|
||||||
|
requestSyncImpl(favourites = favourites, history = history, db = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun requestSyncImpl(favourites: Boolean, history: Boolean, db: MangaDatabase?) = mutex.withLock {
|
||||||
|
if (!favourites && !history) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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, AUTHORITY_FAVOURITES)) {
|
||||||
|
scheduleSync(account, AUTHORITY_FAVOURITES)
|
||||||
|
} else {
|
||||||
|
gcFavourites = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (history) {
|
||||||
|
if (ContentResolver.getSyncAutomatically(account, AUTHORITY_HISTORY)) {
|
||||||
|
scheduleSync(account, AUTHORITY_HISTORY)
|
||||||
|
} else {
|
||||||
|
gcHistory = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (db != null && (gcHistory || gcFavourites)) {
|
||||||
|
db.gc(gcFavourites, gcHistory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleSync(account: Account, authority: String) {
|
||||||
|
if (ContentResolver.isSyncActive(account, authority) || ContentResolver.isSyncPending(account, authority)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val job = jobs[authority]
|
||||||
|
if (job?.isActive == true) {
|
||||||
|
// already scheduled
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val lastSyncTime = getLastSync(account, authority)
|
||||||
|
val timeLeft = System.currentTimeMillis() - lastSyncTime + minSyncInterval
|
||||||
|
if (timeLeft <= 0) {
|
||||||
|
jobs.remove(authority)
|
||||||
|
ContentResolver.requestSync(account, authority, Bundle.EMPTY)
|
||||||
|
} else {
|
||||||
|
jobs[authority] = processLifecycleScope.launch(Dispatchers.Default) {
|
||||||
|
try {
|
||||||
|
delay(timeLeft)
|
||||||
|
} finally {
|
||||||
|
// run even if scope cancelled
|
||||||
|
ContentResolver.requestSync(account, authority, Bundle.EMPTY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,272 @@
|
|||||||
|
package org.koitharu.kotatsu.sync.domain
|
||||||
|
|
||||||
|
import android.accounts.Account
|
||||||
|
import android.content.*
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.annotation.WorkerThread
|
||||||
|
import androidx.core.content.contentValuesOf
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.db.*
|
||||||
|
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.utils.GZipInterceptor
|
||||||
|
import org.koitharu.kotatsu.utils.ext.parseJsonOrNull
|
||||||
|
import org.koitharu.kotatsu.utils.ext.toContentValues
|
||||||
|
import org.koitharu.kotatsu.utils.ext.toJson
|
||||||
|
import org.koitharu.kotatsu.utils.ext.toRequestBody
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
const val AUTHORITY_HISTORY = "org.koitharu.kotatsu.history"
|
||||||
|
const val AUTHORITY_FAVOURITES = "org.koitharu.kotatsu.favourites"
|
||||||
|
|
||||||
|
private const val FIELD_TIMESTAMP = "timestamp"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warning! This class may be used in another process
|
||||||
|
*/
|
||||||
|
@WorkerThread
|
||||||
|
class SyncHelper(
|
||||||
|
context: Context,
|
||||||
|
account: Account,
|
||||||
|
private val provider: ContentProviderClient,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val httpClient = OkHttpClient.Builder()
|
||||||
|
.authenticator(SyncAuthenticator(context, account, SyncAuthApi(context, OkHttpClient())))
|
||||||
|
.addInterceptor(SyncInterceptor(context, account))
|
||||||
|
.addInterceptor(GZipInterceptor())
|
||||||
|
.build()
|
||||||
|
private val baseUrl = context.getString(R.string.url_sync_server)
|
||||||
|
private val defaultGcPeriod: Long // gc period if sync enabled
|
||||||
|
get() = TimeUnit.DAYS.toMillis(4)
|
||||||
|
|
||||||
|
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().parseJsonOrNull() ?: return
|
||||||
|
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().parseJsonOrNull() ?: return
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun upsertHistory(json: JSONArray, timestamp: Long): Array<ContentProviderResult> {
|
||||||
|
val uri = uri(AUTHORITY_HISTORY, 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"), AUTHORITY_HISTORY))
|
||||||
|
ContentProviderOperation.newInsert(uri)
|
||||||
|
.withValues(jo.toContentValues())
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
return provider.applyBatch(operations)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun upsertFavouriteCategories(json: JSONArray, timestamp: Long): Array<ContentProviderResult> {
|
||||||
|
val uri = uri(AUTHORITY_FAVOURITES, 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(AUTHORITY_FAVOURITES, 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"), AUTHORITY_FAVOURITES))
|
||||||
|
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(AUTHORITY_HISTORY, TABLE_HISTORY).use { cursor ->
|
||||||
|
val json = JSONArray()
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
do {
|
||||||
|
val jo = cursor.toJson()
|
||||||
|
jo.put("manga", getManga(AUTHORITY_HISTORY, jo.getLong("manga_id")))
|
||||||
|
json.put(jo)
|
||||||
|
} while (cursor.moveToNext())
|
||||||
|
}
|
||||||
|
json
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getFavourites(): JSONArray {
|
||||||
|
return provider.query(AUTHORITY_FAVOURITES, TABLE_FAVOURITES).use { cursor ->
|
||||||
|
val json = JSONArray()
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
do {
|
||||||
|
val jo = cursor.toJson()
|
||||||
|
jo.put("manga", getManga(AUTHORITY_FAVOURITES, jo.getLong("manga_id")))
|
||||||
|
json.put(jo)
|
||||||
|
} while (cursor.moveToNext())
|
||||||
|
}
|
||||||
|
json
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getFavouriteCategories(): JSONArray {
|
||||||
|
return provider.query(AUTHORITY_FAVOURITES, 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 gcFavourites() {
|
||||||
|
val deletedAt = System.currentTimeMillis() - defaultGcPeriod
|
||||||
|
val selection = "deleted_at != 0 AND deleted_at < ?"
|
||||||
|
val args = arrayOf(deletedAt.toString())
|
||||||
|
provider.delete(uri(AUTHORITY_FAVOURITES, TABLE_FAVOURITES), selection, args)
|
||||||
|
provider.delete(uri(AUTHORITY_FAVOURITES, 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(AUTHORITY_HISTORY, 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
|
||||||
|
}
|
||||||
@ -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,159 @@
|
|||||||
|
package org.koitharu.kotatsu.sync.ui
|
||||||
|
|
||||||
|
import android.accounts.Account
|
||||||
|
import android.accounts.AccountAuthenticatorResponse
|
||||||
|
import android.accounts.AccountManager
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.text.Editable
|
||||||
|
import android.text.TextWatcher
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.Button
|
||||||
|
import androidx.core.graphics.Insets
|
||||||
|
import androidx.core.view.isGone
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.transition.Fade
|
||||||
|
import androidx.transition.TransitionManager
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.base.ui.BaseActivity
|
||||||
|
import org.koitharu.kotatsu.databinding.ActivitySyncAuthBinding
|
||||||
|
import org.koitharu.kotatsu.sync.domain.SyncAuthResult
|
||||||
|
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||||
|
|
||||||
|
class SyncAuthActivity : BaseActivity<ActivitySyncAuthBinding>(), View.OnClickListener {
|
||||||
|
|
||||||
|
private var accountAuthenticatorResponse: AccountAuthenticatorResponse? = null
|
||||||
|
private var resultBundle: Bundle? = null
|
||||||
|
|
||||||
|
private val viewModel by viewModel<SyncAuthViewModel>()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(ActivitySyncAuthBinding.inflate(layoutInflater))
|
||||||
|
accountAuthenticatorResponse = intent.getParcelableExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE)
|
||||||
|
accountAuthenticatorResponse?.onRequestContinued()
|
||||||
|
binding.buttonCancel.setOnClickListener(this)
|
||||||
|
binding.buttonNext.setOnClickListener(this)
|
||||||
|
binding.buttonBack.setOnClickListener(this)
|
||||||
|
binding.buttonDone.setOnClickListener(this)
|
||||||
|
binding.editEmail.addTextChangedListener(EmailTextWatcher(binding.buttonNext))
|
||||||
|
binding.editPassword.addTextChangedListener(PasswordTextWatcher(binding.buttonDone))
|
||||||
|
|
||||||
|
viewModel.onTokenObtained.observe(this, ::onTokenReceived)
|
||||||
|
viewModel.onError.observe(this, ::onError)
|
||||||
|
viewModel.isLoading.observe(this, ::onLoadingStateChanged)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onWindowInsetsChanged(insets: Insets) {
|
||||||
|
val basePadding = resources.getDimensionPixelOffset(R.dimen.screen_padding)
|
||||||
|
binding.root.setPadding(
|
||||||
|
basePadding + insets.left,
|
||||||
|
basePadding + insets.top,
|
||||||
|
basePadding + insets.right,
|
||||||
|
basePadding + insets.bottom,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBackPressed() {
|
||||||
|
if (binding.switcher.isVisible && binding.switcher.displayedChild > 0) {
|
||||||
|
binding.switcher.showPrevious()
|
||||||
|
} else {
|
||||||
|
super.onBackPressed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClick(v: View) {
|
||||||
|
when (v.id) {
|
||||||
|
R.id.button_cancel -> {
|
||||||
|
setResult(RESULT_CANCELED)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
R.id.button_next -> {
|
||||||
|
binding.switcher.showNext()
|
||||||
|
}
|
||||||
|
R.id.button_back -> {
|
||||||
|
binding.switcher.showPrevious()
|
||||||
|
}
|
||||||
|
R.id.button_done -> {
|
||||||
|
viewModel.obtainToken(
|
||||||
|
email = binding.editEmail.text.toString(),
|
||||||
|
password = binding.editPassword.text.toString(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun finish() {
|
||||||
|
accountAuthenticatorResponse?.let { response ->
|
||||||
|
resultBundle?.also {
|
||||||
|
response.onResult(it)
|
||||||
|
} ?: response.onError(AccountManager.ERROR_CODE_CANCELED, getString(R.string.canceled))
|
||||||
|
}
|
||||||
|
super.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onLoadingStateChanged(isLoading: Boolean) {
|
||||||
|
if (isLoading == binding.layoutProgress.isVisible) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
TransitionManager.beginDelayedTransition(binding.root, Fade())
|
||||||
|
binding.switcher.isGone = isLoading
|
||||||
|
binding.layoutProgress.isVisible = isLoading
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onError(error: Throwable) {
|
||||||
|
MaterialAlertDialogBuilder(this)
|
||||||
|
.setTitle(R.string.error)
|
||||||
|
.setMessage(error.getDisplayMessage(resources))
|
||||||
|
.setNegativeButton(R.string.close, null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onTokenReceived(authResult: SyncAuthResult) {
|
||||||
|
val am = AccountManager.get(this)
|
||||||
|
val account = Account(authResult.email, getString(R.string.account_type_sync))
|
||||||
|
val result = Bundle()
|
||||||
|
if (am.addAccountExplicitly(account, authResult.password, Bundle())) {
|
||||||
|
result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name)
|
||||||
|
result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type)
|
||||||
|
result.putString(AccountManager.KEY_AUTHTOKEN, authResult.token)
|
||||||
|
am.setAuthToken(account, account.type, authResult.token)
|
||||||
|
} else {
|
||||||
|
result.putString(AccountManager.KEY_ERROR_MESSAGE, getString(R.string.account_already_exists))
|
||||||
|
}
|
||||||
|
resultBundle = result
|
||||||
|
setResult(RESULT_OK)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class EmailTextWatcher(
|
||||||
|
private val button: Button,
|
||||||
|
) : TextWatcher {
|
||||||
|
|
||||||
|
private val regexEmail = Regex("^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,6}$", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
|
||||||
|
|
||||||
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
|
||||||
|
|
||||||
|
override fun afterTextChanged(s: Editable?) {
|
||||||
|
val text = s?.toString()
|
||||||
|
button.isEnabled = !text.isNullOrEmpty() && regexEmail.matches(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class PasswordTextWatcher(
|
||||||
|
private val button: Button,
|
||||||
|
) : TextWatcher {
|
||||||
|
|
||||||
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
|
||||||
|
|
||||||
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
|
||||||
|
|
||||||
|
override fun afterTextChanged(s: Editable?) {
|
||||||
|
val text = s?.toString()
|
||||||
|
button.isEnabled = text != null && text.length >= 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
package org.koitharu.kotatsu.sync.ui
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import org.koitharu.kotatsu.base.ui.BaseViewModel
|
||||||
|
import org.koitharu.kotatsu.sync.data.SyncAuthApi
|
||||||
|
import org.koitharu.kotatsu.sync.domain.SyncAuthResult
|
||||||
|
import org.koitharu.kotatsu.utils.SingleLiveEvent
|
||||||
|
|
||||||
|
class SyncAuthViewModel(
|
||||||
|
private val api: SyncAuthApi,
|
||||||
|
) : BaseViewModel() {
|
||||||
|
|
||||||
|
val onTokenObtained = SingleLiveEvent<SyncAuthResult>()
|
||||||
|
|
||||||
|
fun obtainToken(email: String, password: String) {
|
||||||
|
launchLoadingJob(Dispatchers.Default) {
|
||||||
|
val token = api.authenticate(email, password)
|
||||||
|
val result = SyncAuthResult(email, password, token)
|
||||||
|
onTokenObtained.postCall(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,111 @@
|
|||||||
|
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 java.util.concurrent.Callable
|
||||||
|
import org.koin.android.ext.android.inject
|
||||||
|
import org.koitharu.kotatsu.core.db.*
|
||||||
|
|
||||||
|
abstract class SyncProvider : ContentProvider() {
|
||||||
|
|
||||||
|
private val database by inject<MangaDatabase>()
|
||||||
|
private val supportedTables = setOf(
|
||||||
|
TABLE_FAVOURITES,
|
||||||
|
TABLE_MANGA,
|
||||||
|
TABLE_TAGS,
|
||||||
|
TABLE_FAVOURITE_CATEGORIES,
|
||||||
|
TABLE_HISTORY,
|
||||||
|
TABLE_MANGA_TAGS,
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun onCreate(): Boolean {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun query(
|
||||||
|
uri: Uri,
|
||||||
|
projection: Array<out String>?,
|
||||||
|
selection: String?,
|
||||||
|
selectionArgs: Array<out String>?,
|
||||||
|
sortOrder: String?
|
||||||
|
): Cursor? = if (getTableName(uri) != null) {
|
||||||
|
val sqlQuery = SupportSQLiteQueryBuilder.builder(uri.lastPathSegment)
|
||||||
|
.columns(projection)
|
||||||
|
.selection(selection, selectionArgs)
|
||||||
|
.orderBy(sortOrder)
|
||||||
|
.create()
|
||||||
|
database.openHelper.readableDatabase.query(sqlQuery)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getType(uri: Uri): String? {
|
||||||
|
return getTableName(uri)?.let { "vnd.android.cursor.dir/" }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun insert(uri: Uri, values: ContentValues?): Uri? {
|
||||||
|
val table = getTableName(uri)
|
||||||
|
if (values == null || table == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val db = database.openHelper.writableDatabase
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
package org.koitharu.kotatsu.sync.ui.favourites
|
||||||
|
|
||||||
|
import android.accounts.Account
|
||||||
|
import android.content.AbstractThreadedSyncAdapter
|
||||||
|
import android.content.ContentProviderClient
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SyncResult
|
||||||
|
import android.os.Bundle
|
||||||
|
import org.koitharu.kotatsu.sync.domain.SyncController
|
||||||
|
import org.koitharu.kotatsu.sync.domain.SyncHelper
|
||||||
|
import org.koitharu.kotatsu.utils.ext.onError
|
||||||
|
|
||||||
|
class FavouritesSyncAdapter(context: Context) : AbstractThreadedSyncAdapter(context, true) {
|
||||||
|
|
||||||
|
override fun onPerformSync(
|
||||||
|
account: Account,
|
||||||
|
extras: Bundle,
|
||||||
|
authority: String,
|
||||||
|
provider: ContentProviderClient,
|
||||||
|
syncResult: SyncResult,
|
||||||
|
) {
|
||||||
|
val syncHelper = SyncHelper(context, account, provider)
|
||||||
|
runCatching {
|
||||||
|
syncHelper.syncFavourites(syncResult)
|
||||||
|
SyncController(context).setLastSync(account, authority, System.currentTimeMillis())
|
||||||
|
}.onFailure(syncResult::onError)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,28 @@
|
|||||||
|
package org.koitharu.kotatsu.sync.ui.history
|
||||||
|
|
||||||
|
import android.accounts.Account
|
||||||
|
import android.content.AbstractThreadedSyncAdapter
|
||||||
|
import android.content.ContentProviderClient
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SyncResult
|
||||||
|
import android.os.Bundle
|
||||||
|
import org.koitharu.kotatsu.sync.domain.SyncController
|
||||||
|
import org.koitharu.kotatsu.sync.domain.SyncHelper
|
||||||
|
import org.koitharu.kotatsu.utils.ext.onError
|
||||||
|
|
||||||
|
class HistorySyncAdapter(context: Context) : AbstractThreadedSyncAdapter(context, true) {
|
||||||
|
|
||||||
|
override fun onPerformSync(
|
||||||
|
account: Account,
|
||||||
|
extras: Bundle,
|
||||||
|
authority: String,
|
||||||
|
provider: ContentProviderClient,
|
||||||
|
syncResult: SyncResult,
|
||||||
|
) {
|
||||||
|
val syncHelper = SyncHelper(context, account, provider)
|
||||||
|
runCatching {
|
||||||
|
syncHelper.syncHistory(syncResult)
|
||||||
|
SyncController(context).setLastSync(account, authority, System.currentTimeMillis())
|
||||||
|
}.onFailure(syncResult::onError)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
package org.koitharu.kotatsu.utils
|
||||||
|
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.koitharu.kotatsu.core.network.CommonHeaders.CONTENT_ENCODING
|
||||||
|
|
||||||
|
class GZipInterceptor : Interceptor {
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val newRequest = chain.request().newBuilder()
|
||||||
|
newRequest.addHeader(CONTENT_ENCODING, "gzip")
|
||||||
|
return chain.proceed(newRequest.build())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
package org.koitharu.kotatsu.utils.ext
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.database.Cursor
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
fun Cursor.toJson(): JSONObject {
|
||||||
|
val jo = JSONObject()
|
||||||
|
for (i in 0 until columnCount) {
|
||||||
|
val name = getColumnName(i)
|
||||||
|
when (getType(i)) {
|
||||||
|
Cursor.FIELD_TYPE_STRING -> jo.put(name, getString(i))
|
||||||
|
Cursor.FIELD_TYPE_FLOAT -> jo.put(name, getDouble(i))
|
||||||
|
Cursor.FIELD_TYPE_INTEGER -> jo.put(name, getLong(i))
|
||||||
|
Cursor.FIELD_TYPE_NULL -> jo.put(name, null)
|
||||||
|
Cursor.FIELD_TYPE_BLOB -> jo.put(name, getBlob(i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return jo
|
||||||
|
}
|
||||||
|
|
||||||
|
fun JSONObject.toContentValues(): ContentValues {
|
||||||
|
val cv = ContentValues(length())
|
||||||
|
for (key in keys()) {
|
||||||
|
val name = key.escapeName()
|
||||||
|
when (val value = get(key)) {
|
||||||
|
JSONObject.NULL, "null", null -> cv.putNull(name)
|
||||||
|
is String -> cv.put(name, value)
|
||||||
|
is Float -> cv.put(name, value)
|
||||||
|
is Double -> cv.put(name, value)
|
||||||
|
is Int -> cv.put(name, value)
|
||||||
|
is Long -> cv.put(name, value)
|
||||||
|
else -> throw IllegalArgumentException("Value $value cannot be putted in ContentValues")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cv
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.escapeName() = "`$this`"
|
||||||
@ -1,10 +1,20 @@
|
|||||||
|
|
||||||
package org.koitharu.kotatsu.utils.ext
|
package org.koitharu.kotatsu.utils.ext
|
||||||
|
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import okhttp3.Response
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import org.koitharu.kotatsu.parsers.util.parseJson
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
|
||||||
private val TYPE_JSON = "application/json".toMediaType()
|
private val TYPE_JSON = "application/json".toMediaType()
|
||||||
|
|
||||||
fun JSONObject.toRequestBody() = toString().toRequestBody(TYPE_JSON)
|
fun JSONObject.toRequestBody() = toString().toRequestBody(TYPE_JSON)
|
||||||
|
|
||||||
|
fun Response.parseJsonOrNull(): JSONObject? {
|
||||||
|
return if (code == HttpURLConnection.HTTP_NO_CONTENT) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
parseJson()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:tint="?colorControlNormal"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000"
|
||||||
|
android:pathData="M12,18A6,6 0 0,1 6,12C6,11 6.25,10.03 6.7,9.2L5.24,7.74C4.46,8.97 4,10.43 4,12A8,8 0 0,0 12,20V23L16,19L12,15M12,4V1L8,5L12,9V6A6,6 0 0,1 18,12C18,13 17.75,13.97 17.3,14.8L18.76,16.26C19.54,15.03 20,13.57 20,12A8,8 0 0,0 12,4Z" />
|
||||||
|
</vector>
|
||||||
@ -0,0 +1,178 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="@dimen/screen_padding">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textView_title"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:drawablePadding="16dp"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:text="@string/sync_title"
|
||||||
|
android:textAppearance="?textAppearanceHeadline5"
|
||||||
|
app:drawableTint="?colorPrimary"
|
||||||
|
app:drawableTopCompat="@drawable/ic_sync"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
<ViewSwitcher
|
||||||
|
android:id="@+id/switcher"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<RelativeLayout
|
||||||
|
android:id="@+id/page_email"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textView_subtitle"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_alignParentStart="true"
|
||||||
|
android:layout_alignParentTop="true"
|
||||||
|
android:layout_alignParentEnd="true"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:text="@string/email_enter_hint"
|
||||||
|
android:textAppearance="?textAppearanceSubtitle1" />
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/layout_email"
|
||||||
|
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_below="@id/textView_subtitle"
|
||||||
|
android:layout_alignParentStart="true"
|
||||||
|
android:layout_alignParentEnd="true"
|
||||||
|
android:layout_marginTop="30dp"
|
||||||
|
app:errorIconDrawable="@null"
|
||||||
|
app:helperText="You can sign in into an existing account or create a new one"
|
||||||
|
app:hintEnabled="false">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/edit_email"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:autofillHints="emailAddress"
|
||||||
|
android:imeOptions="actionDone"
|
||||||
|
android:inputType="textEmailAddress"
|
||||||
|
android:singleLine="true"
|
||||||
|
android:textSize="16sp"
|
||||||
|
tools:text="test@mail.com" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/button_cancel"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_alignParentStart="true"
|
||||||
|
android:layout_alignParentBottom="true"
|
||||||
|
android:text="@android:string/cancel" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/button_next"
|
||||||
|
style="@style/Widget.Material3.Button.TonalButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_alignParentEnd="true"
|
||||||
|
android:layout_alignParentBottom="true"
|
||||||
|
android:enabled="false"
|
||||||
|
android:text="@string/next"
|
||||||
|
tools:ignore="RelativeOverlap" />
|
||||||
|
|
||||||
|
</RelativeLayout>
|
||||||
|
|
||||||
|
<RelativeLayout
|
||||||
|
android:id="@+id/page_password"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textView_subtitle_2"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_alignParentStart="true"
|
||||||
|
android:layout_alignParentTop="true"
|
||||||
|
android:layout_alignParentEnd="true"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:text="@string/enter_email_text"
|
||||||
|
android:textAppearance="?textAppearanceSubtitle1" />
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/layout_password"
|
||||||
|
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_below="@id/textView_subtitle_2"
|
||||||
|
android:layout_alignParentStart="true"
|
||||||
|
android:layout_alignParentEnd="true"
|
||||||
|
android:layout_marginTop="30dp"
|
||||||
|
app:endIconMode="password_toggle"
|
||||||
|
app:errorIconDrawable="@null"
|
||||||
|
app:helperText="You can sign in into an existing account or create a new one"
|
||||||
|
app:hintEnabled="false">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/edit_password"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:autofillHints="password"
|
||||||
|
android:imeOptions="actionDone"
|
||||||
|
android:inputType="textPassword"
|
||||||
|
android:maxLength="24"
|
||||||
|
android:singleLine="true"
|
||||||
|
android:textSize="16sp"
|
||||||
|
tools:text="qwerty" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/button_back"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_alignParentStart="true"
|
||||||
|
android:layout_alignParentBottom="true"
|
||||||
|
android:text="@string/back" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/button_done"
|
||||||
|
style="@style/Widget.Material3.Button.TonalButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_alignParentEnd="true"
|
||||||
|
android:layout_alignParentBottom="true"
|
||||||
|
android:enabled="false"
|
||||||
|
android:text="@string/done"
|
||||||
|
tools:ignore="RelativeOverlap" />
|
||||||
|
|
||||||
|
</RelativeLayout>
|
||||||
|
</ViewSwitcher>
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/layout_progress"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:indeterminate="true" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<account-authenticator
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:accountPreferences="@xml/pref_sync"
|
||||||
|
android:accountType="@string/account_type_sync"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name" />
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<PreferenceScreen />
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:accountType="@string/account_type_sync"
|
||||||
|
android:allowParallelSyncs="false"
|
||||||
|
android:contentAuthority="org.koitharu.kotatsu.favourites"
|
||||||
|
android:isAlwaysSyncable="true"
|
||||||
|
android:supportsUploading="true"
|
||||||
|
android:userVisible="true" />
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:accountType="@string/account_type_sync"
|
||||||
|
android:allowParallelSyncs="false"
|
||||||
|
android:contentAuthority="org.koitharu.kotatsu.history"
|
||||||
|
android:isAlwaysSyncable="true"
|
||||||
|
android:supportsUploading="true"
|
||||||
|
android:userVisible="true" />
|
||||||
Loading…
Reference in New Issue