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 +