From c3b68a53d48af3054bc89b908ab1b3aa705ce2b7 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:52:42 +0530 Subject: [PATCH] feat(sync): add a media sync status sheet There was no way to see whether media had finished syncing, so a missing image left people guessing. Record when each media sync ends and whether it finished, and show it in a sheet opened from the sync button: long-press it, or tap it while media is syncing. The sheet replaces the media sync log dialog, shows progress while syncing, and offers Sync now once the sync has ended. Part of 22591 Assisted-by: Claude Opus 5.5 --- .../main/java/com/ichi2/anki/DeckPicker.kt | 15 +- .../src/main/java/com/ichi2/anki/Sync.kt | 59 ------ .../java/com/ichi2/anki/SyncActionProvider.kt | 27 ++- .../java/com/ichi2/anki/settings/Prefs.kt | 14 ++ .../ichi2/anki/sync/MediaSyncBottomSheet.kt | 182 ++++++++++++++++++ .../com/ichi2/anki/sync/MediaSyncStatus.kt | 108 +++++++++++ .../com/ichi2/anki/worker/SyncMediaWorker.kt | 4 + .../drawable/bg_media_sync_status_icon.xml | 6 + .../fragment_media_sync_bottom_sheet.xml | 115 +++++++++++ .../java/com/ichi2/anki/DeckPickerTest.kt | 28 +++ .../ichi2/anki/FragmentLifecycleFixture.kt | 2 + .../com/ichi2/anki/SyncActionProviderTest.kt | 33 ++++ .../MediaSyncBottomSheetScreenshotTest.kt | 36 ++++ .../anki/sync/MediaSyncBottomSheetTest.kt | 116 +++++++++++ .../ichi2/anki/sync/MediaSyncStatusTest.kt | 125 ++++++++++++ .../anki/worker/SyncMediaWorkerResultTest.kt | 86 +++++++++ .../src/main/res/values/04-network.xml | 17 ++ .../src/main/res/values/preferences_sync.xml | 6 + 18 files changed, 916 insertions(+), 63 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/sync/MediaSyncBottomSheet.kt create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/sync/MediaSyncStatus.kt create mode 100644 AnkiDroid/src/main/res/drawable/bg_media_sync_status_icon.xml create mode 100644 AnkiDroid/src/main/res/layout/fragment_media_sync_bottom_sheet.xml create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/SyncActionProviderTest.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/sync/MediaSyncBottomSheetScreenshotTest.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/sync/MediaSyncBottomSheetTest.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/sync/MediaSyncStatusTest.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/worker/SyncMediaWorkerResultTest.kt create mode 100644 common/android/src/main/res/values/preferences_sync.xml diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index d52ef721781f..568f33421758 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -187,6 +187,7 @@ import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.study.StudyAvailability import com.ichi2.anki.study.StudyCompletionUi import com.ichi2.anki.study.currentDeckStudyState +import com.ichi2.anki.sync.MediaSyncBottomSheet import com.ichi2.anki.sync.MeteredSyncPolicy import com.ichi2.anki.sync.launchCatchingRequiringOneWaySyncDiscardUndo import com.ichi2.anki.sync.syncStatus @@ -1296,6 +1297,11 @@ open class DeckPicker : val progressIndicator = syncItem.actionView ?.findViewById(R.id.progress_indicator) + (MenuItemCompat.getActionProvider(syncItem) as? SyncActionProvider) + ?.setLongClickAction(getString(CommonString.media_sync_status_title)) { + Timber.i("DeckPicker:: Sync button long pressed") + showMediaSyncStatus() + } val workManager = WorkManager.getInstance(this) val flow = workManager.getWorkInfosForUniqueWorkFlow(UniqueWorkNames.SYNC_MEDIA) @@ -1315,6 +1321,11 @@ open class DeckPicker : } } + private fun showMediaSyncStatus() { + if (supportFragmentManager.findFragmentByTag(MediaSyncBottomSheet.TAG) != null) return + MediaSyncBottomSheet().show(supportFragmentManager, MediaSyncBottomSheet.TAG) + } + private fun setupSearchIcon(menuItem: MenuItem) { menuItem.setOnActionExpandListener( object : MenuItem.OnActionExpandListener { @@ -1479,9 +1490,7 @@ open class DeckPicker : toolbarSearchItem?.collapseActionView() val actionProvider = MenuItemCompat.getActionProvider(item) as? SyncActionProvider if (actionProvider?.isProgressShown == true) { - launchCatchingTask { - monitorMediaSync(this@DeckPicker) - } + showMediaSyncStatus() } else { sync() } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt b/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt index d6333a2f2e87..5967d54cdc02 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt @@ -4,12 +4,10 @@ package com.ichi2.anki import androidx.annotation.StringRes -import androidx.appcompat.app.AlertDialog import androidx.work.NetworkType import anki.collection.Progress import anki.sync.SyncCollectionResponse import anki.sync.syncAuth -import com.google.android.material.snackbar.Snackbar import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.CollectionManager.withColExclusive @@ -25,23 +23,12 @@ import com.ichi2.anki.sync.MeteredSyncPolicy import com.ichi2.anki.sync.SyncAuth import com.ichi2.anki.sync.fullUploadOrDownload import com.ichi2.anki.sync.syncCollection -import com.ichi2.anki.ui.internationalization.sentenceCase import com.ichi2.anki.worker.SyncMediaWorker import com.ichi2.preferences.VersatileTextWithASwitchPreference import com.ichi2.utils.NetworkUtils -import com.ichi2.utils.dismissSafely -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import net.ankiweb.rsdroid.Backend -import net.ankiweb.rsdroid.exceptions.BackendInterruptedException import net.ankiweb.rsdroid.exceptions.BackendSyncException import timber.log.Timber -import kotlin.time.Duration.Companion.milliseconds object SyncPreferences { const val PREF_CURRENT_SYNC_URI = "currentSyncUri" @@ -359,52 +346,6 @@ fun shouldFetchMedia(): Boolean { (shouldFetchMedia == ShouldFetchMedia.ONLY_UNMETERED && !NetworkUtils.isActiveNetworkMetered()) } -suspend fun monitorMediaSync(deckPicker: DeckPicker) { - val backend = CollectionManager.getBackend() - val scope = CoroutineScope(Dispatchers.IO) - var isAborted = false - - val dialog = - withContext(Dispatchers.Main) { - AlertDialog - .Builder(deckPicker) - .setTitle(with(deckPicker) { TR.sentenceCase.mediaSyncLog }) - .setMessage("") - .setPositiveButton(CommonString.dialog_continue) { _, _ -> - scope.cancel() - }.setNegativeButton(TR.syncAbortButton()) { _, _ -> - isAborted = true - cancelMediaSync(backend) - }.show() - } - - fun showMessage(msg: String) = deckPicker.showSnackbar(msg, Snackbar.LENGTH_SHORT) - - scope.launch { - try { - while (true) { - // this will throw if the sync exited with an error - val resp = backend.mediaSyncStatus() - if (!resp.active) { - break - } - val text = resp.progress.run { "$added\n$removed\n$checked" } - dialog.setMessage(text) - delay(100.milliseconds) - } - showMessage(if (isAborted) TR.syncMediaAborted() else TR.syncMediaComplete()) - } catch (_: BackendInterruptedException) { - showMessage(TR.syncMediaAborted()) - } catch (_: CancellationException) { - // do nothing - } catch (_: Exception) { - showMessage(TR.syncMediaFailed()) - } finally { - dialog.dismissSafely() - } - } -} - /** * Show a simple snackbar message or notification if the activity is not in foreground * @param messageResource String resource for message diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/SyncActionProvider.kt b/AnkiDroid/src/main/java/com/ichi2/anki/SyncActionProvider.kt index 5f947c8cd81d..61c290ac8924 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/SyncActionProvider.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/SyncActionProvider.kt @@ -9,6 +9,8 @@ import android.view.LayoutInflater import android.view.MenuItem import android.view.View import androidx.appcompat.widget.AppCompatImageButton +import androidx.core.view.ViewCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat import androidx.core.view.isVisible import com.google.android.material.progressindicator.LinearProgressIndicator import com.ichi2.anki.compat.setTooltipTextCompat @@ -21,6 +23,8 @@ class SyncActionProvider( private var progressIndicator: LinearProgressIndicator? = null private var syncButton: AppCompatImageButton? = null + private var longClickAction: (() -> Unit)? = null + private var longClickListener: View.OnLongClickListener? = null val isProgressShown: Boolean get() = progressIndicator?.isVisible == true @@ -36,6 +40,15 @@ class SyncActionProvider( val view = inflater.inflate(R.layout.view_sync_progress_layout, null) progressIndicator = view.findViewById(R.id.progress_indicator) + longClickListener = + View.OnLongClickListener { + val action = longClickAction + if (!forItem.isEnabled || action == null) { + return@OnLongClickListener false + } + action() + true + } syncButton = view.findViewById(R.id.button).apply { setOnClickListener { @@ -44,12 +57,24 @@ class SyncActionProvider( } activity.onOptionsItemSelected(forItem) } + setOnLongClickListener(longClickListener) } return view } fun setTooltipText(value: CharSequence) { - syncButton?.setTooltipTextCompat(value) + syncButton?.run { + setTooltipTextCompat(value) + setOnLongClickListener(longClickListener) + } + } + + fun setLongClickAction( + label: CharSequence, + action: () -> Unit, + ) { + longClickAction = action + syncButton?.let { ViewCompat.replaceAccessibilityAction(it, AccessibilityActionCompat.ACTION_LONG_CLICK, label, null) } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/settings/Prefs.kt b/AnkiDroid/src/main/java/com/ichi2/anki/settings/Prefs.kt index 1be09801f0ba..87affa4d2b2c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/settings/Prefs.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/settings/Prefs.kt @@ -18,6 +18,7 @@ import com.ichi2.anki.common.preferences.NotificationPreferences import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.common.utils.isRunningAsUnitTest import com.ichi2.anki.libanki.DeckId +import com.ichi2.anki.libanki.EpochMilliseconds import com.ichi2.anki.preferences.SharedPreferencesProvider import com.ichi2.anki.settings.enums.AppTheme import com.ichi2.anki.settings.enums.DayTheme @@ -270,6 +271,19 @@ open class PrefsRepository protected constructor( val shouldFetchMedia: ShouldFetchMedia get() = getEnum(R.string.sync_fetch_media_key, ShouldFetchMedia.ALWAYS) + val lastMediaSyncTime: EpochMilliseconds by longPref(CommonPreferenceKey.last_media_sync_time_key, defaultValue = 0L) + val lastMediaSyncFailed by booleanPref(CommonPreferenceKey.last_media_sync_failed_key, defaultValue = false) + + fun recordMediaSync( + time: EpochMilliseconds, + failed: Boolean, + ) { + sharedPrefs.edit { + putLong(key(CommonPreferenceKey.last_media_sync_time_key), time) + putBoolean(key(CommonPreferenceKey.last_media_sync_failed_key), failed) + } + } + var networkTimeoutSecs by intPref(R.string.sync_io_timeout_secs_key, defaultValue = 60) //region Custom sync server diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/sync/MediaSyncBottomSheet.kt b/AnkiDroid/src/main/java/com/ichi2/anki/sync/MediaSyncBottomSheet.kt new file mode 100644 index 000000000000..eaa841c8d052 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/sync/MediaSyncBottomSheet.kt @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.sync + +import android.content.res.ColorStateList +import android.os.Bundle +import android.text.format.DateUtils +import android.view.View +import androidx.annotation.AttrRes +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.core.graphics.ColorUtils +import androidx.core.view.ViewCompat +import androidx.core.view.isVisible +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.work.WorkInfo +import androidx.work.WorkManager +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.color.MaterialColors +import com.ichi2.anki.CollectionManager +import com.ichi2.anki.CollectionManager.TR +import com.ichi2.anki.CommonString +import com.ichi2.anki.R +import com.ichi2.anki.account.AccountActivity +import com.ichi2.anki.databinding.FragmentMediaSyncBottomSheetBinding +import com.ichi2.anki.launchCatchingTask +import com.ichi2.anki.libanki.EpochMilliseconds +import com.ichi2.anki.syncAuth +import com.ichi2.anki.utils.ext.behavior +import com.ichi2.anki.worker.SyncMediaWorker +import com.ichi2.anki.worker.UniqueWorkNames +import dev.androidbroadcast.vbpd.viewBinding +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber +import com.google.android.material.R as MaterialR +import com.ichi2.anki.common.android.R as CommonR + +class MediaSyncBottomSheet : BottomSheetDialogFragment(R.layout.fragment_media_sync_bottom_sheet) { + internal val binding by viewBinding(FragmentMediaSyncBottomSheetBinding::bind) + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + behavior.apply { + state = BottomSheetBehavior.STATE_EXPANDED + skipCollapsed = true + } + ViewCompat.setAccessibilityHeading(binding.title, true) + binding.syncNow.setOnClickListener { startMediaSync() } + binding.logIn.setOnClickListener { + Timber.i("MediaSyncBottomSheet: Log in pressed") + startActivity(AccountActivity.getIntent(requireContext())) + } + binding.abort.text = TR.syncAbortButton() + binding.abort.setOnClickListener { + Timber.i("MediaSyncBottomSheet: Abort pressed") + WorkManager.getInstance(requireContext()).cancelUniqueWork(UniqueWorkNames.SYNC_MEDIA) + } + + val owner = viewLifecycleOwner + val workInfos = WorkManager.getInstance(requireContext()).getWorkInfosForUniqueWorkFlow(UniqueWorkNames.SYNC_MEDIA) + owner.lifecycleScope.launch { + owner.repeatOnLifecycle(Lifecycle.State.STARTED) { + workInfos.collectLatest { infos -> + val work = infos.lastOrNull() + showStatus(work) + if (work?.state == WorkInfo.State.RUNNING) { + val backend = CollectionManager.getBackend() + showMediaSyncProgress({ withContext(Dispatchers.IO) { backend.mediaSyncStatus() } }) { text -> + binding.progress.text = text + binding.progress.isVisible = true + } + } + } + } + } + } + + private fun showStatus(work: WorkInfo?) { + val status = mediaSyncStatus(work) + val color = MaterialColors.getColor(binding.icon, status.colorAttr) + binding.icon.setImageResource(status.iconRes) + binding.icon.imageTintList = ColorStateList.valueOf(color) + binding.icon.backgroundTintList = ColorStateList.valueOf(ColorUtils.setAlphaComponent(color, ICON_BACKGROUND_ALPHA)) + binding.title.setText(status.titleRes) + binding.summary.text = status.summary() + binding.progressIndicator.isVisible = status == MediaSyncStatus.Syncing + binding.progress.isVisible = false + binding.syncNow.isVisible = status.canSync + binding.logIn.isVisible = status == MediaSyncStatus.NotLoggedIn + binding.abort.isVisible = status.canAbort + } + + private fun startMediaSync() { + Timber.i("MediaSyncBottomSheet: Sync now pressed") + val auth = syncAuth() ?: return + val context = requireContext() + with(context) { + MeteredSyncPolicy.confirmThen(onDialogShown = {}) { permission -> + val networkType = MeteredSyncPolicy.getNetworkTypeRequiredForSync(forMedia = true, permission = permission) + launchCatchingTask { SyncMediaWorker.start(context, auth, networkType) } + } + } + } + + @get:DrawableRes + private val MediaSyncStatus.iconRes: Int + get() = + when (this) { + is MediaSyncStatus.Completed -> R.drawable.ic_done + is MediaSyncStatus.Failed -> R.drawable.ic_sync_problem + MediaSyncStatus.WaitingForUnmeteredNetwork -> R.drawable.baseline_wifi_24 + MediaSyncStatus.NotLoggedIn, + MediaSyncStatus.Off, + MediaSyncStatus.Syncing, + MediaSyncStatus.WaitingForNetwork, + MediaSyncStatus.NotRecorded, + -> R.drawable.ic_sync + } + + @get:AttrRes + private val MediaSyncStatus.colorAttr: Int + get() = + when (this) { + is MediaSyncStatus.Failed -> CommonR.attr.badgeWarningColor + is MediaSyncStatus.Completed, + MediaSyncStatus.Syncing, + MediaSyncStatus.NotRecorded, + -> androidx.appcompat.R.attr.colorPrimary + MediaSyncStatus.NotLoggedIn, + MediaSyncStatus.Off, + MediaSyncStatus.WaitingForNetwork, + MediaSyncStatus.WaitingForUnmeteredNetwork, + -> MaterialR.attr.colorOnSurfaceVariant + } + + @get:StringRes + private val MediaSyncStatus.titleRes: Int + get() = + when (this) { + MediaSyncStatus.NotLoggedIn -> CommonString.sync_account_summ_logged_out + MediaSyncStatus.Off -> CommonString.media_sync_off_title + MediaSyncStatus.Syncing -> CommonString.media_sync_syncing_title + MediaSyncStatus.WaitingForNetwork -> CommonString.media_sync_waiting_for_network_title + MediaSyncStatus.WaitingForUnmeteredNetwork -> CommonString.media_sync_waiting_for_wifi_title + MediaSyncStatus.NotRecorded -> CommonString.media_sync_not_recorded_title + is MediaSyncStatus.Failed -> CommonString.media_sync_incomplete_title + is MediaSyncStatus.Completed -> CommonString.media_sync_up_to_date_title + } + + private fun MediaSyncStatus.summary(): String = + when (this) { + MediaSyncStatus.NotLoggedIn -> getString(CommonString.media_sync_not_logged_in_summary) + MediaSyncStatus.Off -> getString(CommonString.media_sync_off_summary) + MediaSyncStatus.Syncing -> getString(CommonString.media_sync_syncing_summary) + MediaSyncStatus.WaitingForNetwork -> getString(CommonString.media_sync_waiting_for_network_summary) + MediaSyncStatus.WaitingForUnmeteredNetwork -> getString(CommonString.media_sync_waiting_for_wifi_summary) + MediaSyncStatus.NotRecorded -> getString(CommonString.media_sync_not_recorded_summary) + is MediaSyncStatus.Failed -> getString(CommonString.media_sync_incomplete_summary, formatSyncTime(time)) + is MediaSyncStatus.Completed -> getString(CommonString.media_sync_up_to_date_summary, formatSyncTime(time)) + } + + private fun formatSyncTime(time: EpochMilliseconds): String = + DateUtils.formatDateTime( + requireContext(), + time, + DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_ABBREV_MONTH, + ) + + companion object { + const val TAG = "MediaSyncBottomSheet" + private const val ICON_BACKGROUND_ALPHA = 0x29 + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/sync/MediaSyncStatus.kt b/AnkiDroid/src/main/java/com/ichi2/anki/sync/MediaSyncStatus.kt new file mode 100644 index 000000000000..fa1afee5779c --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/sync/MediaSyncStatus.kt @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.sync + +import androidx.work.NetworkType +import androidx.work.WorkInfo +import anki.sync.MediaSyncStatusResponse +import com.ichi2.anki.common.time.TimeManager +import com.ichi2.anki.isLoggedIn +import com.ichi2.anki.libanki.EpochMilliseconds +import com.ichi2.anki.settings.Prefs +import com.ichi2.anki.settings.enums.ShouldFetchMedia +import com.ichi2.utils.NetworkUtils +import kotlinx.coroutines.delay +import net.ankiweb.rsdroid.BackendException +import timber.log.Timber +import kotlin.time.Duration.Companion.milliseconds + +sealed interface MediaSyncStatus { + data object NotLoggedIn : MediaSyncStatus + + data object Off : MediaSyncStatus + + data object Syncing : MediaSyncStatus + + data object WaitingForNetwork : MediaSyncStatus + + data object WaitingForUnmeteredNetwork : MediaSyncStatus + + data object NotRecorded : MediaSyncStatus + + data class Failed( + val time: EpochMilliseconds, + ) : MediaSyncStatus + + data class Completed( + val time: EpochMilliseconds, + ) : MediaSyncStatus + + val canSync: Boolean + get() = this is Failed || this is Completed || this == NotRecorded + + val canAbort: Boolean + get() = this == Syncing || this == WaitingForNetwork || this == WaitingForUnmeteredNetwork + + companion object { + fun from( + isLoggedIn: Boolean, + fetchMedia: ShouldFetchMedia, + workState: WorkInfo.State?, + workNeedsUnmeteredNetwork: Boolean, + isOnline: Boolean, + isNetworkMetered: Boolean, + lastAttempt: MediaSyncAttempt?, + ): MediaSyncStatus = + when { + !isLoggedIn -> NotLoggedIn + workState == WorkInfo.State.RUNNING -> Syncing + workState == WorkInfo.State.ENQUEUED && !isOnline -> WaitingForNetwork + workState == WorkInfo.State.ENQUEUED && workNeedsUnmeteredNetwork && isNetworkMetered -> WaitingForUnmeteredNetwork + workState == WorkInfo.State.ENQUEUED -> Syncing + fetchMedia == ShouldFetchMedia.NEVER -> Off + lastAttempt == null -> NotRecorded + lastAttempt.failed -> Failed(lastAttempt.time) + else -> Completed(lastAttempt.time) + } + } +} + +data class MediaSyncAttempt( + val time: EpochMilliseconds, + val failed: Boolean, +) + +val lastMediaSyncAttempt: MediaSyncAttempt? + get() = Prefs.lastMediaSyncTime.takeIf { it > 0 }?.let { MediaSyncAttempt(it, Prefs.lastMediaSyncFailed) } + +fun recordMediaSyncAttempt(failed: Boolean) = Prefs.recordMediaSync(TimeManager.time.intTimeMS(), failed) + +fun mediaSyncStatus(work: WorkInfo?): MediaSyncStatus = + MediaSyncStatus.from( + isLoggedIn = isLoggedIn(), + fetchMedia = Prefs.shouldFetchMedia, + workState = work?.state, + workNeedsUnmeteredNetwork = work?.constraints?.requiredNetworkType == NetworkType.UNMETERED, + isOnline = NetworkUtils.isOnline, + isNetworkMetered = NetworkUtils.isActiveNetworkMetered(), + lastAttempt = lastMediaSyncAttempt, + ) + +suspend fun showMediaSyncProgress( + readStatus: suspend () -> MediaSyncStatusResponse, + show: (String) -> Unit, +) { + while (true) { + val response = + try { + readStatus() + } catch (_: BackendException) { + Timber.w("media sync progress unavailable") + null + } + if (response != null && response.active) { + show(response.progress.run { "$added\n$removed\n$checked" }) + } + delay(100.milliseconds) + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncMediaWorker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncMediaWorker.kt index 6c0034dc4f5f..ceafab9a2530 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncMediaWorker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncMediaWorker.kt @@ -33,6 +33,7 @@ import com.ichi2.anki.notifications.NotificationId import com.ichi2.anki.receiver.CopyToClipboardReceiver import com.ichi2.anki.sync.MeteredSyncPolicy import com.ichi2.anki.sync.SyncAuth +import com.ichi2.anki.sync.recordMediaSyncAttempt import com.ichi2.anki.sync.syncMedia import com.ichi2.anki.ui.internationalization.sentenceCase import com.ichi2.anki.utils.ext.trySetForeground @@ -87,12 +88,14 @@ class SyncMediaWorker( } } catch (cancellationException: CancellationException) { Timber.w(cancellationException, "SyncMediaWorker cancelled (user tapped Cancel or WorkManager cancelled)") + recordMediaSyncAttempt(failed = true) notificationManager?.cancel(NotificationId.SYNC_MEDIA) Timber.d("SyncMediaWorker: progress notification cancelled after worker cancellation") cancelMediaSync(CollectionManager.getBackend()) throw cancellationException } catch (throwable: Throwable) { Timber.w(throwable, "SyncMediaWorker failed") + recordMediaSyncAttempt(failed = true) notify { // TODO: add a contentIntent, so tapping the notification opens the app // (ideally showing the media sync log: an AlertDialog in DeckPicker) @@ -114,6 +117,7 @@ class SyncMediaWorker( Timber.d("SyncMediaWorker: showing failure notification") return Result.failure() } + recordMediaSyncAttempt(failed = false) Timber.d("SyncMediaWorker: cancelling progress notification (sync completed)") notificationManager?.cancel(NotificationId.SYNC_MEDIA) diff --git a/AnkiDroid/src/main/res/drawable/bg_media_sync_status_icon.xml b/AnkiDroid/src/main/res/drawable/bg_media_sync_status_icon.xml new file mode 100644 index 000000000000..9ec65cd77e85 --- /dev/null +++ b/AnkiDroid/src/main/res/drawable/bg_media_sync_status_icon.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/AnkiDroid/src/main/res/layout/fragment_media_sync_bottom_sheet.xml b/AnkiDroid/src/main/res/layout/fragment_media_sync_bottom_sheet.xml new file mode 100644 index 000000000000..fbf04597f773 --- /dev/null +++ b/AnkiDroid/src/main/res/layout/fragment_media_sync_bottom_sheet.xml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt index 3869c10bebfd..daa599104a46 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt @@ -31,6 +31,7 @@ import androidx.core.view.ContentInfoCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.children +import androidx.core.view.isVisible import androidx.core.view.size import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager @@ -58,6 +59,7 @@ import com.ichi2.anki.common.utils.android.getResFromAttr import com.ichi2.anki.common.utils.annotation.KotlinCleanup import com.ichi2.anki.common.utils.ext.getParcelableExtraCompat import com.ichi2.anki.databinding.ActivityHomescreenBinding +import com.ichi2.anki.databinding.ViewSyncProgressLayoutBinding import com.ichi2.anki.deckpicker.DeckFilters import com.ichi2.anki.deckpicker.DeckPickerViewModel import com.ichi2.anki.deckpicker.SyncIconState @@ -84,6 +86,7 @@ import com.ichi2.anki.preferences.PreferencesActivity import com.ichi2.anki.reviewreminders.ScheduleRemindersFragment import com.ichi2.anki.settings.Prefs import com.ichi2.anki.snackbar.showSnackbar +import com.ichi2.anki.sync.MediaSyncBottomSheet import com.ichi2.anki.ui.RecyclerFastScroller import com.ichi2.anki.ui.internationalization.sentenceCase import com.ichi2.anki.ui.windows.permissions.PermissionsActivity @@ -1930,6 +1933,31 @@ class DeckPickerTest : RobolectricTest() { assertThat(menu.findItem(R.id.action_sync), nullValue()) } + @Test + fun `long pressing the sync button shows the media sync status`() = + deckPicker { + createMenuJob?.join() + + syncButton().button.performLongClick() + advanceRobolectricLooper() + + assertNotNull(supportFragmentManager.findFragmentByTag(MediaSyncBottomSheet.TAG)) + } + + @Test + fun `tapping the sync button during a media sync shows the media sync status`() = + deckPicker { + createMenuJob?.join() + syncButton().progressIndicator.isVisible = true + + syncButton().button.performClick() + advanceRobolectricLooper() + + assertNotNull(supportFragmentManager.findFragmentByTag(MediaSyncBottomSheet.TAG)) + } + + private fun DeckPicker.syncButton() = ViewSyncProgressLayoutBinding.bind(assertNotNull(menu().findItem(R.id.action_sync).actionView)) + @Test fun `fatal error arriving after menu creation blanks the options menu`() = deckPickerEx { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/FragmentLifecycleFixture.kt b/AnkiDroid/src/test/java/com/ichi2/anki/FragmentLifecycleFixture.kt index 62377a2f8e7b..2ac0bef0dec2 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/FragmentLifecycleFixture.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/FragmentLifecycleFixture.kt @@ -23,6 +23,7 @@ import com.ichi2.anki.preferences.SyncSettingsFragment import com.ichi2.anki.reviewreminders.AddEditReminderDialog import com.ichi2.anki.reviewreminders.ReviewReminderScope import com.ichi2.anki.reviewreminders.ScheduleRemindersFragment +import com.ichi2.anki.sync.MediaSyncBottomSheet import com.ichi2.anki.ui.windows.managespace.ManageSpaceFragment /** A screen and its required setup for [FragmentCoroutineLifecycleTest]. */ @@ -47,6 +48,7 @@ fun fragmentLifecycleFixtures(): List = FragmentLifecycleFixture("AdvancedSettings") { AdvancedSettingsFragment() }, FragmentLifecycleFixture("DeveloperOptions") { DeveloperOptionsFragment() }, FragmentLifecycleFixture("SyncSettings") { SyncSettingsFragment() }, + FragmentLifecycleFixture("MediaSync") { MediaSyncBottomSheet() }, FragmentLifecycleFixture("About") { AboutFragment() }, FragmentLifecycleFixture("ScheduleReminders") { ScheduleRemindersFragment() }, FragmentLifecycleFixture("FilteredDeckOptions") { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/SyncActionProviderTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/SyncActionProviderTest.kt new file mode 100644 index 000000000000..691f026251df --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/SyncActionProviderTest.kt @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki + +import androidx.appcompat.widget.Toolbar +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.databinding.ViewSyncProgressLayoutBinding +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.annotation.Config +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [25, 36]) +class SyncActionProviderTest : RobolectricTest() { + @Test + fun `long press runs the action after the tooltip is set`() { + val controller = Robolectric.buildActivity(AnkiActivity::class.java).setup() + saveControllerForCleanup(controller) + val activity = controller.get() + val item = Toolbar(activity).menu.add("Sync") + val provider = SyncActionProvider(activity) + val button = ViewSyncProgressLayoutBinding.bind(provider.onCreateActionView(item)).button + var longPressed = false + provider.setLongClickAction("Media sync") { longPressed = true } + provider.setTooltipText("Sync") + + button.performLongClick() + + assertTrue(longPressed, "the long press action should run") + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/sync/MediaSyncBottomSheetScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/sync/MediaSyncBottomSheetScreenshotTest.kt new file mode 100644 index 000000000000..0ee581971af3 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/sync/MediaSyncBottomSheetScreenshotTest.kt @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.sync + +import com.ichi2.anki.R +import com.ichi2.anki.ScreenshotTest +import com.ichi2.anki.databinding.ViewSyncProgressLayoutBinding +import com.ichi2.anki.settings.Prefs +import com.ichi2.anki.withDeckPicker +import com.ichi2.testutils.ext.menu +import org.junit.Test +import java.util.TimeZone +import kotlin.test.assertNotNull + +class MediaSyncBottomSheetScreenshotTest : ScreenshotTest() { + @Test + fun `media sync incomplete`() { + val defaultTimeZone = TimeZone.getDefault() + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + try { + Prefs.hkey = "test" + Prefs.recordMediaSync(time = 1_700_000_000_000L, failed = true) + withDeckPicker(deckCount = 1, withCards = true) { deckPicker -> + val syncButton = ViewSyncProgressLayoutBinding.bind(assertNotNull(deckPicker.menu().findItem(R.id.action_sync).actionView)) + syncButton.button.performLongClick() + advanceRobolectricLooperUntil { + val sheet = deckPicker.supportFragmentManager.findFragmentByTag(MediaSyncBottomSheet.TAG) as? MediaSyncBottomSheet + sheet?.view != null && sheet.binding.summary.length() > 0 + } + captureScreen("media_sync_incomplete") + } + } finally { + TimeZone.setDefault(defaultTimeZone) + } + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/sync/MediaSyncBottomSheetTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/sync/MediaSyncBottomSheetTest.kt new file mode 100644 index 000000000000..fd75eb930d0a --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/sync/MediaSyncBottomSheetTest.kt @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.sync + +import android.text.format.DateUtils +import androidx.core.view.isVisible +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.work.WorkInfo +import androidx.work.WorkManager +import com.ichi2.anki.CommonString +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.account.AccountActivity +import com.ichi2.anki.settings.Prefs +import com.ichi2.anki.worker.UniqueWorkNames +import com.ichi2.testutils.launchFragment +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf + +@RunWith(AndroidJUnit4::class) +class MediaSyncBottomSheetTest : RobolectricTest() { + @Test + fun `logged out offers a log in instead of a sync`() { + withMediaSyncSheet { + assertThat(binding.title.text.toString(), equalTo(getString(CommonString.sync_account_summ_logged_out))) + assertThat(binding.summary.text.toString(), equalTo(getString(CommonString.media_sync_not_logged_in_summary))) + assertThat(binding.logIn.isVisible, equalTo(true)) + assertThat(binding.syncNow.isVisible, equalTo(false)) + assertThat(binding.abort.isVisible, equalTo(false)) + } + } + + @Test + fun `log in opens the account screen`() { + withMediaSyncSheet { + binding.logIn.performClick() + + val started = shadowOf(requireActivity()).nextStartedActivity + assertThat(started.component?.className, equalTo(AccountActivity::class.java.name)) + } + } + + @Test + fun `an incomplete media sync offers a sync`() { + Prefs.hkey = "test" + Prefs.recordMediaSync(TIME, failed = true) + withMediaSyncSheet { + val formattedTime = + DateUtils.formatDateTime( + requireContext(), + TIME, + DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_ABBREV_MONTH, + ) + assertThat(binding.title.text.toString(), equalTo(getString(CommonString.media_sync_incomplete_title))) + assertThat(binding.summary.text.toString(), equalTo(getString(CommonString.media_sync_incomplete_summary, formattedTime))) + assertThat(binding.syncNow.isVisible, equalTo(true)) + assertThat(binding.logIn.isVisible, equalTo(false)) + assertThat(binding.abort.isVisible, equalTo(false)) + } + } + + @Test + fun `sync now starts a media sync`() { + Prefs.hkey = "test" + Prefs.allowSyncOnMeteredConnections = true + Prefs.recordMediaSync(TIME, failed = true) + withMediaSyncSheet { + binding.syncNow.performClick() + advanceRobolectricLooperUntil { binding.abort.isVisible } + + assertThat(mediaSyncStates(), equalTo(listOf(WorkInfo.State.ENQUEUED))) + assertThat(binding.syncNow.isVisible, equalTo(false)) + assertThat(binding.abort.isVisible, equalTo(true)) + } + } + + @Test + fun `abort cancels a pending media sync`() { + Prefs.hkey = "test" + Prefs.allowSyncOnMeteredConnections = true + Prefs.recordMediaSync(TIME, failed = true) + withMediaSyncSheet { + binding.syncNow.performClick() + advanceRobolectricLooperUntil { binding.abort.isVisible } + + binding.abort.performClick() + advanceRobolectricLooperUntil { binding.syncNow.isVisible } + + assertThat(mediaSyncStates(), equalTo(listOf(WorkInfo.State.CANCELLED))) + assertThat(binding.syncNow.isVisible, equalTo(true)) + assertThat(binding.abort.isVisible, equalTo(false)) + } + } + + private fun mediaSyncStates() = + WorkManager + .getInstance(targetContext) + .getWorkInfosForUniqueWork(UniqueWorkNames.SYNC_MEDIA) + .get() + .map { it.state } + + private fun withMediaSyncSheet(block: MediaSyncBottomSheet.() -> Unit) { + launchFragment { MediaSyncBottomSheet() }.use { scenario -> + scenario.onFragment { sheet -> + advanceRobolectricLooperUntil { sheet.binding.summary.length() > 0 } + sheet.block() + } + } + } + + companion object { + private const val TIME = 1_700_000_000_000L + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/sync/MediaSyncStatusTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/sync/MediaSyncStatusTest.kt new file mode 100644 index 000000000000..8f75e4bde0c2 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/sync/MediaSyncStatusTest.kt @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.sync + +import androidx.work.WorkInfo +import anki.sync.mediaSyncProgress +import anki.sync.mediaSyncStatusResponse +import com.ichi2.anki.settings.enums.ShouldFetchMedia +import com.ichi2.anki.sync.MediaSyncStatus.Completed +import com.ichi2.anki.sync.MediaSyncStatus.Failed +import com.ichi2.anki.sync.MediaSyncStatus.NotLoggedIn +import com.ichi2.anki.sync.MediaSyncStatus.NotRecorded +import com.ichi2.anki.sync.MediaSyncStatus.Off +import com.ichi2.anki.sync.MediaSyncStatus.Syncing +import com.ichi2.anki.sync.MediaSyncStatus.WaitingForNetwork +import com.ichi2.anki.sync.MediaSyncStatus.WaitingForUnmeteredNetwork +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.junit.Test +import kotlin.time.Duration.Companion.seconds + +class MediaSyncStatusTest { + @Test + fun `being logged out hides every other status`() { + assertThat(status(isLoggedIn = false, workState = WorkInfo.State.RUNNING, lastAttempt = completed), equalTo(NotLoggedIn)) + } + + @Test + fun `running or queued work is syncing`() { + assertThat(status(workState = WorkInfo.State.RUNNING, lastAttempt = failed), equalTo(Syncing)) + assertThat(status(workState = WorkInfo.State.ENQUEUED), equalTo(Syncing)) + assertThat(status(workState = WorkInfo.State.ENQUEUED, workNeedsUnmeteredNetwork = true), equalTo(Syncing)) + } + + @Test + fun `work needing an unmetered network waits on a metered one`() { + assertThat( + status(workState = WorkInfo.State.ENQUEUED, workNeedsUnmeteredNetwork = true, isNetworkMetered = true), + equalTo(WaitingForUnmeteredNetwork), + ) + } + + @Test + fun `queued work waits for a connection while offline`() { + assertThat(status(workState = WorkInfo.State.ENQUEUED, isOnline = false), equalTo(WaitingForNetwork)) + assertThat( + status(workState = WorkInfo.State.ENQUEUED, workNeedsUnmeteredNetwork = true, isOnline = false), + equalTo(WaitingForNetwork), + ) + } + + @Test + fun `media sync turned off shows once nothing is pending`() { + assertThat(status(fetchMedia = ShouldFetchMedia.NEVER, lastAttempt = completed), equalTo(Off)) + assertThat(status(fetchMedia = ShouldFetchMedia.NEVER, workState = WorkInfo.State.RUNNING), equalTo(Syncing)) + } + + @Test + fun `the last attempt decides once the work has finished`() { + for (finished in listOf(null, WorkInfo.State.SUCCEEDED, WorkInfo.State.FAILED, WorkInfo.State.CANCELLED)) { + assertThat(status(workState = finished), equalTo(NotRecorded)) + assertThat(status(workState = finished, lastAttempt = failed), equalTo(Failed(TIME))) + assertThat(status(workState = finished, lastAttempt = completed), equalTo(Completed(TIME))) + } + } + + @Test + fun `only settled statuses offer a sync`() { + val offered = listOf(Completed(TIME), Failed(TIME), NotRecorded) + val notOffered = listOf(NotLoggedIn, Off, Syncing, WaitingForNetwork, WaitingForUnmeteredNetwork) + assertThat(offered.map { it.canSync }, equalTo(listOf(true, true, true))) + assertThat(notOffered.map { it.canSync }, equalTo(listOf(false, false, false, false, false))) + } + + @Test + fun `only pending statuses offer an abort`() { + val offered = listOf(Syncing, WaitingForNetwork, WaitingForUnmeteredNetwork) + val notOffered = listOf(NotLoggedIn, Off, NotRecorded, Failed(TIME), Completed(TIME)) + assertThat(offered.map { it.canAbort }, equalTo(listOf(true, true, true))) + assertThat(notOffered.map { it.canAbort }, equalTo(listOf(false, false, false, false, false))) + } + + @Test + fun `progress shows once the sync becomes active`() = + runTest { + val inactive = mediaSyncStatusResponse { active = false } + val active = + mediaSyncStatusResponse { + this.active = true + progress = + mediaSyncProgress { + added = "Added: 1" + removed = "Removed: 0" + checked = "Checked: 2" + } + } + val samples = ArrayDeque(listOf(inactive, active)) + val shown = mutableListOf() + + val polling = launch { showMediaSyncProgress({ samples.removeFirstOrNull() ?: active }) { shown += it } } + advanceTimeBy(1.seconds) + polling.cancel() + + assertThat(shown.firstOrNull(), equalTo("Added: 1\nRemoved: 0\nChecked: 2")) + } + + private fun status( + isLoggedIn: Boolean = true, + fetchMedia: ShouldFetchMedia = ShouldFetchMedia.ALWAYS, + workState: WorkInfo.State? = null, + workNeedsUnmeteredNetwork: Boolean = false, + isOnline: Boolean = true, + isNetworkMetered: Boolean = false, + lastAttempt: MediaSyncAttempt? = null, + ) = MediaSyncStatus.from(isLoggedIn, fetchMedia, workState, workNeedsUnmeteredNetwork, isOnline, isNetworkMetered, lastAttempt) + + companion object { + private const val TIME = 1_700_000_000_000L + private val completed = MediaSyncAttempt(TIME, failed = false) + private val failed = MediaSyncAttempt(TIME, failed = true) + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/worker/SyncMediaWorkerResultTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/worker/SyncMediaWorkerResultTest.kt new file mode 100644 index 000000000000..3b6055e17b06 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/worker/SyncMediaWorkerResultTest.kt @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.worker + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.work.ListenableWorker.Result +import androidx.work.testing.TestListenableWorkerBuilder +import anki.sync.syncAuth +import com.ichi2.anki.CollectionManager +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.common.time.TimeManager +import com.ichi2.anki.sync.SyncAuth +import com.ichi2.anki.sync.lastMediaSyncAttempt +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import net.ankiweb.rsdroid.Backend +import net.ankiweb.rsdroid.BackendFactory +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.greaterThan +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull + +@RunWith(AndroidJUnit4::class) +class SyncMediaWorkerResultTest : RobolectricTest() { + private var syncError: Throwable? = null + + @Before + fun fakeMediaSync() { + runBlocking { CollectionManager.discardBackend() } + BackendFactory.setOverride { + object : Backend() { + override fun syncMedia(input: anki.sync.SyncAuth) { + syncError?.let { throw it } + } + } + } + } + + @After + fun resetBackendFactory() { + BackendFactory.setOverride(null) + } + + @Test + fun `a finished media sync is recorded as completed`() = + runTest { + val before = TimeManager.time.intTimeMS() + assertThat(mediaSyncWorker().doWork(), equalTo(Result.success())) + assertAttemptRecorded(after = before, failed = false) + } + + @Test + fun `a failed media sync is recorded as incomplete`() = + runTest { + syncError = IllegalStateException("network down") + val before = TimeManager.time.intTimeMS() + assertThat(mediaSyncWorker().doWork(), equalTo(Result.failure())) + assertAttemptRecorded(after = before, failed = true) + } + + @Test + fun `a cancelled media sync is recorded as incomplete`() = + runTest { + syncError = CancellationException("cancelled from the notification") + val before = TimeManager.time.intTimeMS() + assertFailsWith { mediaSyncWorker().doWork() } + assertAttemptRecorded(after = before, failed = true) + } + + private fun mediaSyncWorker() = + TestListenableWorkerBuilder(targetContext, SyncAuth(syncAuth { hkey = "test" }).toWorkData()).build() + + private fun assertAttemptRecorded( + after: Long, + failed: Boolean, + ) { + val attempt = assertNotNull(lastMediaSyncAttempt) + assertThat(attempt.failed, equalTo(failed)) + assertThat(attempt.time, greaterThan(after)) + } +} diff --git a/common/android/src/main/res/values/04-network.xml b/common/android/src/main/res/values/04-network.xml index 658d95841b38..dfd69124b4f6 100644 --- a/common/android/src/main/res/values/04-network.xml +++ b/common/android/src/main/res/values/04-network.xml @@ -47,4 +47,21 @@ Sync (one-way) Sync (log in) + Media sync + Media is syncing + This can take a while + Waiting for a connection + Media will sync when you’re back online + Waiting for Wi-Fi + You’re on mobile data + Media sync is off + ‘Fetch media on sync’ is set to Never + Media hasn’t synced yet + Sync to bring your media up to date + Media sync incomplete + Last attempt %s + Media is up to date + Last synced %s + Media syncs with your AnkiWeb account + Sync now diff --git a/common/android/src/main/res/values/preferences_sync.xml b/common/android/src/main/res/values/preferences_sync.xml new file mode 100644 index 000000000000..f09daace0a66 --- /dev/null +++ b/common/android/src/main/res/values/preferences_sync.xml @@ -0,0 +1,6 @@ + + + + lastMediaSyncTime + lastMediaSyncFailed +