From 85710acb3aebdad6a18383d6a24c05bacec8310e Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sat, 15 Apr 2023 16:56:27 +0300 Subject: [PATCH] Fix synchronization --- app/build.gradle | 4 +- app/src/debug/res/values/constants.xml | 2 + app/src/main/AndroidManifest.xml | 4 +- .../koitharu/kotatsu/main/ui/MainViewModel.kt | 7 -- .../kotatsu/shelf/ui/ShelfViewModel.kt | 8 ++ .../kotatsu/sync/domain/SyncController.kt | 89 ++++++++----------- .../kotatsu/sync/domain/SyncHelper.kt | 49 +++++----- .../ui/favourites/FavouritesSyncAdapter.kt | 2 +- .../sync/ui/history/HistorySyncAdapter.kt | 2 +- app/src/main/res/values/constants.xml | 2 + app/src/main/res/xml/authenticator_sync.xml | 4 +- app/src/main/res/xml/sync_favourites.xml | 2 +- app/src/main/res/xml/sync_history.xml | 2 +- 13 files changed, 86 insertions(+), 91 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 46c5b98b9..777a43e3c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -15,8 +15,8 @@ android { applicationId 'org.koitharu.kotatsu' minSdkVersion 21 targetSdkVersion 33 - versionCode 534 - versionName '5.0-b1' + versionCode 535 + versionName '5.0-b2' generatedDensities = [] testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/debug/res/values/constants.xml b/app/src/debug/res/values/constants.xml index 9950eed79..44e14a54f 100644 --- a/app/src/debug/res/values/constants.xml +++ b/app/src/debug/res/values/constants.xml @@ -1,4 +1,6 @@ org.kotatsu.debug.sync + org.koitharu.kotatsu.debug.history + org.koitharu.kotatsu.debug.favourites diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5893d9188..f70692fb5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -227,13 +227,13 @@ diff --git a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt index 978e0d235..06d20579f 100644 --- a/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/main/ui/MainViewModel.kt @@ -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() { diff --git a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfViewModel.kt b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfViewModel.kt index b8d203f85..590352a16 100644 --- a/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfViewModel.kt +++ b/app/src/main/java/org/koitharu/kotatsu/shelf/ui/ShelfViewModel.kt @@ -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) diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncController.kt b/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncController.kt index 58ff58b2f..dc85a124a 100644 --- a/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncController.kt +++ b/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncController.kt @@ -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, ) : 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(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) { 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 = 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()) + } + } } diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncHelper.kt b/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncHelper.kt index 79d8a1d90..8b514a0b3 100644 --- a/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncHelper.kt +++ b/app/src/main/java/org/koitharu/kotatsu/sync/domain/SyncHelper.kt @@ -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 { - val uri = uri(AUTHORITY_HISTORY, TABLE_HISTORY) + val uri = uri(authorityHistory, TABLE_HISTORY) val operations = ArrayList() 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 { - val uri = uri(AUTHORITY_FAVOURITES, TABLE_FAVOURITE_CATEGORIES) + val uri = uri(authorityFavourites, TABLE_FAVOURITE_CATEGORIES) val operations = ArrayList() operations += ContentProviderOperation.newDelete(uri) .withSelection("created_at < ?", arrayOf(timestamp.toString())) @@ -115,13 +124,13 @@ class SyncHelper( } private fun upsertFavourites(json: JSONArray, timestamp: Long): Array { - val uri = uri(AUTHORITY_FAVOURITES, TABLE_FAVOURITES) + val uri = uri(authorityFavourites, TABLE_FAVOURITES) val operations = ArrayList() 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 { diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncAdapter.kt index 8515f4735..cdeacf2db 100644 --- a/app/src/main/java/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/sync/ui/favourites/FavouritesSyncAdapter.kt @@ -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) } } diff --git a/app/src/main/java/org/koitharu/kotatsu/sync/ui/history/HistorySyncAdapter.kt b/app/src/main/java/org/koitharu/kotatsu/sync/ui/history/HistorySyncAdapter.kt index 8d0b3f4e8..d56529bb1 100644 --- a/app/src/main/java/org/koitharu/kotatsu/sync/ui/history/HistorySyncAdapter.kt +++ b/app/src/main/java/org/koitharu/kotatsu/sync/ui/history/HistorySyncAdapter.kt @@ -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) } } diff --git a/app/src/main/res/values/constants.xml b/app/src/main/res/values/constants.xml index cf8daf367..90dd945fb 100644 --- a/app/src/main/res/values/constants.xml +++ b/app/src/main/res/values/constants.xml @@ -15,6 +15,8 @@ 6cd8e6349e9a36bc1fc1ab97703c9fd1 SxhkCVnqVLbGogvi xPDACTLHnHU9Nfjv + org.koitharu.kotatsu.history + org.koitharu.kotatsu.favourites -1 1 diff --git a/app/src/main/res/xml/authenticator_sync.xml b/app/src/main/res/xml/authenticator_sync.xml index 371460404..d40082822 100644 --- a/app/src/main/res/xml/authenticator_sync.xml +++ b/app/src/main/res/xml/authenticator_sync.xml @@ -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" /> \ No newline at end of file + android:icon="@mipmap/ic_launcher_round" + android:label="@string/app_name" /> diff --git a/app/src/main/res/xml/sync_favourites.xml b/app/src/main/res/xml/sync_favourites.xml index 1365d5f03..8de3b0562 100644 --- a/app/src/main/res/xml/sync_favourites.xml +++ b/app/src/main/res/xml/sync_favourites.xml @@ -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" /> diff --git a/app/src/main/res/xml/sync_history.xml b/app/src/main/res/xml/sync_history.xml index 6bca603ea..cfc96d27f 100644 --- a/app/src/main/res/xml/sync_history.xml +++ b/app/src/main/res/xml/sync_history.xml @@ -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" />