Fix synchronization

This commit is contained in:
Koitharu
2023-04-15 16:56:27 +03:00
parent ffd31dbea9
commit 85710acb3a
13 changed files with 86 additions and 91 deletions

View File

@@ -1,4 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_type_sync" translatable="false">org.kotatsu.debug.sync</string>
<string name="sync_authority_history" translatable="false">org.koitharu.kotatsu.debug.history</string>
<string name="sync_authority_favourites" translatable="false">org.koitharu.kotatsu.debug.favourites</string>
</resources>

View File

@@ -227,13 +227,13 @@
</provider>
<provider
android:name="org.koitharu.kotatsu.sync.ui.favourites.FavouritesSyncProvider"
android:authorities="${applicationId}.favourites"
android:authorities="@string/sync_authority_favourites"
android:exported="false"
android:label="@string/favourites"
android:syncable="true" />
<provider
android:name="org.koitharu.kotatsu.sync.ui.history.HistorySyncProvider"
android:authorities="${applicationId}.history"
android:authorities="@string/sync_authority_history"
android:exported="false"
android:label="@string/history"
android:syncable="true" />

View File

@@ -8,7 +8,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.github.AppUpdateRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -16,7 +15,6 @@ import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.prefs.observeAsLiveData
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.sync.domain.SyncController
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData
@@ -27,8 +25,6 @@ class MainViewModel @Inject constructor(
private val historyRepository: HistoryRepository,
private val appUpdateRepository: AppUpdateRepository,
private val trackingRepository: TrackingRepository,
syncController: SyncController,
database: MangaDatabase,
private val settings: AppSettings,
) : BaseViewModel() {
@@ -61,9 +57,6 @@ class MainViewModel @Inject constructor(
launchJob {
appUpdateRepository.fetchUpdate()
}
launchJob {
syncController.requestFullSyncAndGc(database)
}
}
fun openLastReader() {

View File

@@ -34,6 +34,7 @@ import org.koitharu.kotatsu.shelf.domain.ShelfContent
import org.koitharu.kotatsu.shelf.domain.ShelfRepository
import org.koitharu.kotatsu.shelf.domain.ShelfSection
import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel
import org.koitharu.kotatsu.sync.domain.SyncController
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData
@@ -46,6 +47,7 @@ class ShelfViewModel @Inject constructor(
private val favouritesRepository: FavouritesRepository,
private val trackingRepository: TrackingRepository,
private val settings: AppSettings,
syncController: SyncController,
networkState: NetworkState,
) : BaseViewModel(), ListExtraProvider {
@@ -63,6 +65,12 @@ class ShelfViewModel @Inject constructor(
emit(listOf(e.toErrorState(canRetry = false)))
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
init {
launchJob(Dispatchers.Default) {
syncController.requestFullSync()
}
}
override suspend fun getCounter(mangaId: Long): Int {
return if (settings.isTrackerEnabled) {
trackingRepository.getNewChaptersCount(mangaId)

View File

@@ -3,20 +3,21 @@ 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.collection.ArrayMap
import androidx.room.InvalidationTracker
import androidx.room.withTransaction
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
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.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES
@@ -25,24 +26,21 @@ import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import org.koitharu.kotatsu.utils.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 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)
private val defaultGcPeriod = TimeUnit.DAYS.toMillis(2) // gc period if sync disabled
override fun onInvalidated(tables: Set<String>) {
requestSync(
@@ -57,79 +55,52 @@ class SyncController @Inject constructor(
return rawValue.toLongOrNull() ?: 0L
}
fun setLastSync(account: Account, authority: String, time: Long) {
val key = "last_sync_" + authority.substringAfterLast('.')
am.setUserData(account, key, time.toString())
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, db = null)
}
suspend fun requestFullSyncAndGc(database: MangaDatabase) = withContext(Dispatchers.Default) {
requestSyncImpl(favourites = true, history = true, db = database)
requestSyncImpl(favourites = true, history = true)
}
private fun requestSync(favourites: Boolean, history: Boolean) = processLifecycleScope.launch(Dispatchers.Default) {
requestSyncImpl(favourites = favourites, history = history, db = null)
requestSyncImpl(favourites = favourites, history = history)
}
private suspend fun requestSyncImpl(favourites: Boolean, history: Boolean, db: MangaDatabase?) = mutex.withLock {
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)
db.gc(favourites, history)
return
}
var gcHistory = false
var gcFavourites = false
if (favourites) {
if (ContentResolver.getSyncAutomatically(account, AUTHORITY_FAVOURITES)) {
scheduleSync(account, AUTHORITY_FAVOURITES)
if (ContentResolver.getSyncAutomatically(account, authorityFavourites)) {
ContentResolver.requestSync(account, authorityFavourites, Bundle.EMPTY)
} else {
gcFavourites = true
}
}
if (history) {
if (ContentResolver.getSyncAutomatically(account, AUTHORITY_HISTORY)) {
scheduleSync(account, AUTHORITY_HISTORY)
if (ContentResolver.getSyncAutomatically(account, authorityHistory)) {
ContentResolver.requestSync(account, authorityHistory, Bundle.EMPTY)
} else {
gcHistory = true
}
}
if (db != null && (gcHistory || gcFavourites)) {
if (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()
}
@@ -144,4 +115,14 @@ class SyncController @Inject constructor(
favouriteCategoriesDao.gc(deletedAt)
}
}
companion object {
@JvmStatic
fun setLastSync(context: Context, account: Account, authority: String, time: Long) {
val key = "last_sync_" + authority.substringAfterLast('.')
val am = AccountManager.get(context)
am.setUserData(account, key, time.toString())
}
}
}

View File

@@ -1,7 +1,12 @@
package org.koitharu.kotatsu.sync.domain
import android.accounts.Account
import android.content.*
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
@@ -11,7 +16,12 @@ 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.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.parsers.util.json.mapJSONTo
import org.koitharu.kotatsu.sync.data.SyncAuthApi
import org.koitharu.kotatsu.sync.data.SyncAuthenticator
@@ -23,9 +33,6 @@ 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"
/**
@@ -38,6 +45,8 @@ class SyncHelper(
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 httpClient = OkHttpClient.Builder()
.authenticator(SyncAuthenticator(context, account, SyncAuthApi(context, OkHttpClient())))
.addInterceptor(SyncInterceptor(context, account))
@@ -86,13 +95,13 @@ class SyncHelper(
}
private fun upsertHistory(json: JSONArray, timestamp: Long): Array<ContentProviderResult> {
val uri = uri(AUTHORITY_HISTORY, TABLE_HISTORY)
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"), AUTHORITY_HISTORY))
operations.addAll(upsertManga(jo.removeJSONObject("manga"), authorityHistory))
ContentProviderOperation.newInsert(uri)
.withValues(jo.toContentValues())
.build()
@@ -101,7 +110,7 @@ class SyncHelper(
}
private fun upsertFavouriteCategories(json: JSONArray, timestamp: Long): Array<ContentProviderResult> {
val uri = uri(AUTHORITY_FAVOURITES, TABLE_FAVOURITE_CATEGORIES)
val uri = uri(authorityFavourites, TABLE_FAVOURITE_CATEGORIES)
val operations = ArrayList<ContentProviderOperation>()
operations += ContentProviderOperation.newDelete(uri)
.withSelection("created_at < ?", arrayOf(timestamp.toString()))
@@ -115,13 +124,13 @@ class SyncHelper(
}
private fun upsertFavourites(json: JSONArray, timestamp: Long): Array<ContentProviderResult> {
val uri = uri(AUTHORITY_FAVOURITES, TABLE_FAVOURITES)
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"), AUTHORITY_FAVOURITES))
operations.addAll(upsertManga(jo.removeJSONObject("manga"), authorityFavourites))
ContentProviderOperation.newInsert(uri)
.withValues(jo.toContentValues())
.build()
@@ -142,25 +151,25 @@ class SyncHelper(
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()
.build(),
)
return result
}
private fun getHistory(): JSONArray {
return provider.query(AUTHORITY_HISTORY, TABLE_HISTORY).use { cursor ->
return provider.query(authorityHistory, 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")))
jo.put("manga", getManga(authorityHistory, jo.getLong("manga_id")))
json.put(jo)
} while (cursor.moveToNext())
}
@@ -169,12 +178,12 @@ class SyncHelper(
}
private fun getFavourites(): JSONArray {
return provider.query(AUTHORITY_FAVOURITES, TABLE_FAVOURITES).use { cursor ->
return provider.query(authorityFavourites, 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")))
jo.put("manga", getManga(authorityFavourites, jo.getLong("manga_id")))
json.put(jo)
} while (cursor.moveToNext())
}
@@ -183,7 +192,7 @@ class SyncHelper(
}
private fun getFavouriteCategories(): JSONArray {
return provider.query(AUTHORITY_FAVOURITES, TABLE_FAVOURITE_CATEGORIES).use { cursor ->
return provider.query(authorityFavourites, TABLE_FAVOURITE_CATEGORIES).use { cursor ->
val json = JSONArray()
if (cursor.moveToFirst()) {
do {
@@ -247,15 +256,15 @@ class SyncHelper(
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)
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(AUTHORITY_HISTORY, TABLE_HISTORY), selection, args)
provider.delete(uri(authorityHistory, TABLE_HISTORY), selection, args)
}
private fun ContentProviderClient.query(authority: String, table: String): Cursor {

View File

@@ -27,7 +27,7 @@ class FavouritesSyncAdapter(context: Context) : AbstractThreadedSyncAdapter(cont
val syncHelper = SyncHelper(context, account, provider)
runCatchingCancellable {
syncHelper.syncFavourites(syncResult)
SyncController(context).setLastSync(account, authority, System.currentTimeMillis())
SyncController.setLastSync(context, account, authority, System.currentTimeMillis())
}.onFailure(syncResult::onError)
}
}

View File

@@ -27,7 +27,7 @@ class HistorySyncAdapter(context: Context) : AbstractThreadedSyncAdapter(context
val syncHelper = SyncHelper(context, account, provider)
runCatchingCancellable {
syncHelper.syncHistory(syncResult)
SyncController(context).setLastSync(account, authority, System.currentTimeMillis())
SyncController.setLastSync(context, account, authority, System.currentTimeMillis())
}.onFailure(syncResult::onError)
}
}

View File

@@ -15,6 +15,8 @@
<string name="mal_clientId" translatable="false">6cd8e6349e9a36bc1fc1ab97703c9fd1</string>
<string name="acra_login" translatable="false">SxhkCVnqVLbGogvi</string>
<string name="acra_password" translatable="false">xPDACTLHnHU9Nfjv</string>
<string name="sync_authority_history" translatable="false">org.koitharu.kotatsu.history</string>
<string name="sync_authority_favourites" translatable="false">org.koitharu.kotatsu.favourites</string>
<string-array name="values_theme" translatable="false">
<item>-1</item>
<item>1</item>

View File

@@ -3,5 +3,5 @@
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" />
android:icon="@mipmap/ic_launcher_round"
android:label="@string/app_name" />

View File

@@ -3,7 +3,7 @@
xmlns:android="http://schemas.android.com/apk/res/android"
android:accountType="@string/account_type_sync"
android:allowParallelSyncs="false"
android:contentAuthority="${applicationId}.favourites"
android:contentAuthority="@string/sync_authority_favourites"
android:isAlwaysSyncable="true"
android:supportsUploading="true"
android:userVisible="true" />

View File

@@ -3,7 +3,7 @@
xmlns:android="http://schemas.android.com/apk/res/android"
android:accountType="@string/account_type_sync"
android:allowParallelSyncs="false"
android:contentAuthority="${applicationId}.history"
android:contentAuthority="@string/sync_authority_history"
android:isAlwaysSyncable="true"
android:supportsUploading="true"
android:userVisible="true" />