From c87f21d93a8cdb8212dc12b4059a7ab381195096 Mon Sep 17 00:00:00 2001 From: Uwe Trottmann Date: Wed, 28 Feb 2024 18:06:08 +0100 Subject: [PATCH] Sync: handle and cooperate on interruptions --- .../shows/tools/AddUpdateShowTools.kt | 5 + .../seriesguide/shows/tools/ShowSync.kt | 32 ++-- .../seriesguide/sync/SgSyncAdapter.kt | 173 +++++++++++------- .../battlelancer/seriesguide/sync/TmdbSync.kt | 2 + 4 files changed, 126 insertions(+), 86 deletions(-) diff --git a/app/src/main/java/com/battlelancer/seriesguide/shows/tools/AddUpdateShowTools.kt b/app/src/main/java/com/battlelancer/seriesguide/shows/tools/AddUpdateShowTools.kt index 6b9d4fe9f1..12a3277749 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/shows/tools/AddUpdateShowTools.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/shows/tools/AddUpdateShowTools.kt @@ -421,7 +421,11 @@ class AddUpdateShowTools @Inject constructor( /** * Updates a show. Adds new, updates changed and removes orphaned episodes. + * + * This runs coroutines blocking the current thread (see [updateWatchProviderMappings]). + * If it is interrupted, [InterruptedException] is thrown. */ + @Throws(InterruptedException::class) fun updateShow(showId: Long): UpdateResult { val helper = SgRoomDatabase.getInstance(context).sgShow2Helper() val show = helper.getShow(showId) @@ -523,6 +527,7 @@ class AddUpdateShowTools @Inject constructor( /** * Download and store watch provider mappings if a streaming search region is configured. */ + @Throws(InterruptedException::class) private fun updateWatchProviderMappings(showId: Long, showTmdbId: Int) { val region = StreamingSearch.getCurrentRegionOrNull(context) ?: return runBlocking { diff --git a/app/src/main/java/com/battlelancer/seriesguide/shows/tools/ShowSync.kt b/app/src/main/java/com/battlelancer/seriesguide/shows/tools/ShowSync.kt index fb4ef27a7a..0557fcb4c1 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/shows/tools/ShowSync.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/shows/tools/ShowSync.kt @@ -49,6 +49,7 @@ class ShowSync( * On network errors retries a few times to update a show before failing. */ @SuppressLint("TimberExceptionLogging") + @Throws(InterruptedException::class) fun sync( context: Context, currentTime: Long, @@ -70,12 +71,15 @@ class ShowSync( return UpdateResult.INCOMPLETE } + if (Thread.interrupted()) throw InterruptedException() + // This can fail due to // - network error (not connected, unknown host, time out) => abort and try again // - API error (parsing error, other error) => abort, report and try again later // - show does no longer exist => ignore and continue // - database error => abort, report and try again later // Note: reporting is done where the exception occurs. + // If this thread is interrupted throws InterruptedException result = showTools.updateShow(showId) if (result is ApiErrorRetry) { @@ -89,22 +93,14 @@ class ShowSync( return UpdateResult.INCOMPLETE } else { // Back off, then try again. - try { - // Wait for 2^n seconds + random milliseconds, - // with n starting at 0 (so 1 s + random ms) - val n = networkErrors - 1 - Thread.sleep( - (2.0.pow(n)).toLong() * DateUtils.SECOND_IN_MILLIS - + Random.nextInt(0, 1000) - ) - } catch (e: InterruptedException) { - // This can happen if the system has decided to interrupt the sync - // thread (see AbstractThreadedSyncAdapter class documentation), - // just try again later. - Timber.v("Wait for retry interrupted by system, trying again later.") - progress.setImportantErrorIfNone("Interrupted by system, trying again later.") - return UpdateResult.INCOMPLETE - } + // Wait for 2^n seconds + random milliseconds, + // with n starting at 0 (so 1 s + random ms) + val n = networkErrors - 1 + // If this thread is interrupted throws InterruptedException + Thread.sleep( + (2.0.pow(n)).toLong() * DateUtils.SECOND_IN_MILLIS + + Random.nextInt(0, 1000) + ) } } else if (networkErrors > 0) { // Reduce counter on each successful update. @@ -127,6 +123,7 @@ class ShowSync( "Show '%s' removed from TMDB (id %s), maybe search for a replacement and remove it." ) } + is ApiErrorRetry -> throw IllegalStateException("Should retry and not handle result.") is ApiErrorStop -> { // API error, do not continue and try again later. @@ -140,6 +137,7 @@ class ShowSync( ) return UpdateResult.INCOMPLETE } + DatabaseError -> { // Database error, do not continue and try again later. setImportantMessageIfNone( @@ -185,10 +183,12 @@ class ShowSync( } listOf(showId) } + SyncType.FULL -> { // get all show IDs for a full update SgRoomDatabase.getInstance(context).sgShow2Helper().getShowIdsLong() } + SyncType.DELTA -> getShowsToDeltaUpdate(context, currentTime) else -> throw IllegalArgumentException("Sync type $syncType is not supported.") } diff --git a/app/src/main/java/com/battlelancer/seriesguide/sync/SgSyncAdapter.kt b/app/src/main/java/com/battlelancer/seriesguide/sync/SgSyncAdapter.kt index 930277add8..d3b6326ea1 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/sync/SgSyncAdapter.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/sync/SgSyncAdapter.kt @@ -20,7 +20,7 @@ import com.battlelancer.seriesguide.SgApp import com.battlelancer.seriesguide.backend.HexagonTools import com.battlelancer.seriesguide.backend.settings.HexagonSettings import com.battlelancer.seriesguide.jobs.NetworkJobProcessor -import com.battlelancer.seriesguide.lists.ListsTools2.migrateTvdbShowListItemsToTmdbIds +import com.battlelancer.seriesguide.lists.ListsTools2 import com.battlelancer.seriesguide.movies.tools.MovieTools import com.battlelancer.seriesguide.notifications.NotificationService import com.battlelancer.seriesguide.provider.SeriesGuideDatabase @@ -102,94 +102,127 @@ class SgSyncAdapter(context: Context) : AbstractThreadedSyncAdapter(context, tru // from here on we need more sophisticated abort handling, so keep track of errors val progress = SyncProgress() + try { + sync(showSync, currentTime, progress) + } catch (e: InterruptedException) { + // This can happen if the system has decided to interrupt the sync + // thread (see AbstractThreadedSyncAdapter class documentation), + // just try again later. + Timber.v("Sync interrupted by system, trying again later.") + progress.setImportantErrorIfNone("Interrupted by system, trying again later.") + } + progress.publishFinished() + } + + @Throws(InterruptedException::class) + private fun sync(showSync: ShowSync, currentTime: Long, progress: SyncProgress) { progress.publish(SyncProgress.Step.TMDB) // Get latest TMDb configuration. val tmdbSync = TmdbSync(context, tmdbConfigService.get(), movieTools.get()) - val prefs = PreferenceManager.getDefaultSharedPreferences(context) tmdbSync.updateConfigurationAndWatchProviders(progress) + if (Thread.interrupted()) throw InterruptedException() + // Update show data. // If failed for at least one show, do not proceed with other sync steps to avoid - // syncing with outdated show data. - // Note: it is still NOT guaranteed show data is up-to-date before syncing because a show - // does not get updated if it was recently (see ShowSync selecting which shows to update). - var resultCode = showSync.sync(context, currentTime, progress) - Timber.d("Syncing: TMDB shows...DONE") - if (resultCode == null || resultCode == UpdateResult.INCOMPLETE) { - progress.recordError() - progress.publishFinished() - if (showSync.isSyncMultiple) { - updateTimeAndFailedCounter(prefs, resultCode) - } - return // Try again later. - } - - // do some more things if this is not a quick update - if (showSync.isSyncMultiple) { - // update data of to be released movies - if (!tmdbSync.updateMovies(progress)) { + // syncing with outdated show data. However, renew the search table and trigger the + // notification service if at least one show was updated. + // Note: it is still NOT guaranteed show data is up-to-date before syncing with Cloud/Trakt + // because a show does not get updated if it was recently (see ShowSync selecting which + // shows to update). + var hasAddedShows = false + try { + var resultCode = showSync.sync(context, currentTime, progress) + Timber.d("Syncing: TMDB shows...DONE") + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + if (resultCode == null || resultCode == UpdateResult.INCOMPLETE) { progress.recordError() + if (showSync.isSyncMultiple) { + updateTimeAndFailedCounter(prefs, resultCode) + } + return // Try again later. } - Timber.d("Syncing: TMDB...DONE") - - // sync with hexagon - var hasAddedShows = false - val isHexagonEnabled = HexagonSettings.isEnabled(context) - if (isHexagonEnabled) { - val resultHexagonSync = HexagonSync( - context, - hexagonTools.get(), movieTools.get(), progress - ).sync() - hasAddedShows = resultHexagonSync.hasAddedShows - // don't overwrite failure - if (resultCode == UpdateResult.SUCCESS) { - resultCode = if (resultHexagonSync.success) { - UpdateResult.SUCCESS - } else { - UpdateResult.INCOMPLETE + + // do some more things if this is not a quick update + if (showSync.isSyncMultiple) { + if (Thread.interrupted()) throw InterruptedException() + + // update data of to be released movies + if (!tmdbSync.updateMovies(progress)) { + progress.recordError() + } + Timber.d("Syncing: TMDB...DONE") + + if (Thread.interrupted()) throw InterruptedException() + + // sync with hexagon + val isHexagonEnabled = HexagonSettings.isEnabled(context) + if (isHexagonEnabled) { + val resultHexagonSync = HexagonSync( + context, + hexagonTools.get(), movieTools.get(), progress + ).sync() + hasAddedShows = resultHexagonSync.hasAddedShows + // don't overwrite failure + if (resultCode == UpdateResult.SUCCESS) { + resultCode = if (resultHexagonSync.success) { + UpdateResult.SUCCESS + } else { + UpdateResult.INCOMPLETE + } } + Timber.d("Syncing: Hexagon...DONE") + } else { + Timber.d("Syncing: Hexagon...SKIP") } - Timber.d("Syncing: Hexagon...DONE") - } else { - Timber.d("Syncing: Hexagon...SKIP") - } - // Migrate legacy list items - // Note: might send to Hexagon, so make sure to sync lists with Hexagon before - migrateTvdbShowListItemsToTmdbIds(context) - - // sync with trakt (only ratings if hexagon is enabled) - if (TraktCredentials.get(context).hasCredentials()) { - val resultTraktSync = TraktSync( - context, movieTools.get(), - traktSync.get(), progress - ).sync(currentTime, isHexagonEnabled) - // don't overwrite failure - if (resultCode == UpdateResult.SUCCESS) { - resultCode = resultTraktSync + if (Thread.interrupted()) throw InterruptedException() + + // Migrate legacy list items + // Note: might send to Hexagon, so make sure to sync lists with Hexagon before + ListsTools2.migrateTvdbShowListItemsToTmdbIds(context) + + if (Thread.interrupted()) throw InterruptedException() + + // sync with trakt (only ratings if hexagon is enabled) + if (TraktCredentials.get(context).hasCredentials()) { + val resultTraktSync = TraktSync( + context, movieTools.get(), + traktSync.get(), progress + ).sync(currentTime, isHexagonEnabled) + // don't overwrite failure + if (resultCode == UpdateResult.SUCCESS) { + resultCode = resultTraktSync + } + Timber.d("Syncing: trakt...DONE") + } else { + Timber.d("Syncing: trakt...SKIP") } - Timber.d("Syncing: trakt...DONE") - } else { - Timber.d("Syncing: trakt...SKIP") - } - // renew search table if shows were updated and it will not be renewed by add task - if (showSync.hasUpdatedShows() && !hasAddedShows) { - SeriesGuideDatabase.rebuildFtsTable(context) - } + if (Thread.interrupted()) throw InterruptedException() - // update next episodes for all shows - TaskManager.getInstance().tryNextEpisodeUpdateTask(context) + // update next episodes for all shows + TaskManager.getInstance().tryNextEpisodeUpdateTask(context) - updateTimeAndFailedCounter(prefs, resultCode) - } + updateTimeAndFailedCounter(prefs, resultCode) + } - // There could have been new episodes added after an update - NotificationService.trigger(context) + Timber.i("Syncing: %s", resultCode.toString()) + } finally { + // Finish some things even if interrupted - Timber.i("Syncing: %s", resultCode.toString()) - progress.publishFinished() + // Renew search table if shows were updated and it will not be renewed by add task, + // but as this is a little costly only do it when doing the less frequent multiple + // shows sync. + if (showSync.isSyncMultiple && showSync.hasUpdatedShows() && !hasAddedShows) { + SeriesGuideDatabase.rebuildFtsTable(context) + } + // There could have been new episodes added after an update + if (showSync.hasUpdatedShows()) { + NotificationService.trigger(context) + } + } } private fun updateTimeAndFailedCounter( diff --git a/app/src/main/java/com/battlelancer/seriesguide/sync/TmdbSync.kt b/app/src/main/java/com/battlelancer/seriesguide/sync/TmdbSync.kt index 91d465b597..8bb106d562 100644 --- a/app/src/main/java/com/battlelancer/seriesguide/sync/TmdbSync.kt +++ b/app/src/main/java/com/battlelancer/seriesguide/sync/TmdbSync.kt @@ -24,6 +24,7 @@ class TmdbSync internal constructor( private val movieTools: MovieTools ) { + @Throws(InterruptedException::class) fun updateConfigurationAndWatchProviders(progress: SyncProgress) { if (TmdbSettings.isConfigurationUpToDate(context)) { return @@ -39,6 +40,7 @@ class TmdbSync internal constructor( // Show watch providers StreamingSearch.getCurrentRegionOrNull(context)?.also { // Note: only updating for shows to keep local watch provider filter up-to-date + // If this thread is interrupted throws InterruptedException val providersUpdated = runBlocking(SgApp.SINGLE) { StreamingSearch .updateWatchProviders(context, SgWatchProvider.Type.SHOWS, it)