diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index f5a48763d561..12f2e1d93e33 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -25,7 +25,6 @@ package com.ichi2.anki -import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.content.SharedPreferences @@ -115,6 +114,8 @@ import com.ichi2.anki.deckpicker.DeckPickerViewModel.AnkiDroidEnvironment import com.ichi2.anki.deckpicker.DeckPickerViewModel.FlattenedDeckList import com.ichi2.anki.deckpicker.DeckPickerViewModel.StartupResponse import com.ichi2.anki.deckpicker.EmptyCardsResult +import com.ichi2.anki.deckpicker.OptionsMenuState +import com.ichi2.anki.deckpicker.SyncIconState import com.ichi2.anki.dialogs.AsyncDialogFragment import com.ichi2.anki.dialogs.BackupPromptDialog import com.ichi2.anki.dialogs.ConfirmationDialog @@ -204,7 +205,6 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import net.ankiweb.rsdroid.Translations -import net.ankiweb.rsdroid.exceptions.BackendNetworkException import org.json.JSONException import timber.log.Timber import java.io.File @@ -1301,45 +1301,7 @@ open class DeckPicker : @VisibleForTesting suspend fun updateMenuState() { - optionsMenuState = - withOpenColOrNull { - val searchIcon = decks.count() >= 10 - val undoLabel = undoLabel() - val undoAvailable = undoAvailable() - // besides checking for cards being available also consider if we have empty decks - val isColEmpty = isEmpty && decks.count() == 1 - // the correct sync status is fetched in the next call so "Normal" is used as a placeholder - // the sync status is calculated in the next call so "Normal" is used as a placeholder - OptionsMenuState(searchIcon, undoLabel, SyncIconState.Normal, undoAvailable, isColEmpty) - }?.let { (searchIcon, undoLabel, _, undoAvailable, isColEmpty) -> - val syncIcon = fetchSyncIconState() - OptionsMenuState(searchIcon, undoLabel, syncIcon, undoAvailable, isColEmpty) - } - } - - private suspend fun fetchSyncIconState(): SyncIconState { - if (!Prefs.displaySyncStatus) return SyncIconState.Normal - val auth = syncAuth() - if (auth == null) return SyncIconState.NotLoggedIn - return try { - // Use CollectionManager to ensure that this doesn't block 'deck count' tasks - // throws if a .colpkg import or similar occurs just before this call - val output = withContext(Dispatchers.IO) { CollectionManager.getBackend().syncStatus(auth) } - if (output.hasNewEndpoint() && output.newEndpoint.isNotEmpty()) { - Prefs.currentSyncUri = output.newEndpoint - } - when (output.required) { - SyncStatusResponse.Required.NO_CHANGES -> SyncIconState.Normal - SyncStatusResponse.Required.NORMAL_SYNC -> SyncIconState.PendingChanges - SyncStatusResponse.Required.FULL_SYNC -> SyncIconState.OneWay - SyncStatusResponse.Required.UNRECOGNIZED, null -> TODO("unexpected required response") - } - } catch (_: BackendNetworkException) { - SyncIconState.Normal - } catch (e: Exception) { - Timber.d(e, "error obtaining sync status: collection likely closed") - SyncIconState.Normal - } + optionsMenuState = viewModel.updateMenuState() } override fun onOptionsItemSelected(item: MenuItem): Boolean { @@ -1835,14 +1797,14 @@ open class DeckPicker : val current = VersionUtils.pkgVersionCode Timber.i("Current AnkiDroid version: %s", current) val previous: Long = - if (preferences.contains(UPGRADE_VERSION_KEY)) { + if (preferences.contains(DeckPickerViewModel.UPGRADE_VERSION_KEY)) { // Upgrading currently installed app - getPreviousVersion(preferences, current) + viewModel.getPreviousVersion(preferences, current) } else { // Fresh install current } - preferences.edit { putLong(UPGRADE_VERSION_KEY, current) } + preferences.edit { putLong(DeckPickerViewModel.UPGRADE_VERSION_KEY, current) } // Delete the media database made by any version before 2.3 beta due to upgrade errors. // It is rebuilt on the next sync or media check if (previous < 20300200) { @@ -1962,40 +1924,6 @@ open class DeckPicker : showDialogFragment(DeckPickerAnalyticsOptInDialog.newInstance()) } - @SuppressLint("UseKtx") // keep SharedPreferences.edit() instead of edit {} fot tests - fun getPreviousVersion( - preferences: SharedPreferences, - current: Long, - ): Long { - var previous: Long - try { - previous = preferences.getLong(UPGRADE_VERSION_KEY, current) - } catch (e: ClassCastException) { - Timber.w(e) - previous = - try { - // set 20900203 to default value, as it's the latest version that stores integer in shared prefs - preferences.getInt(UPGRADE_VERSION_KEY, 20900203).toLong() - } catch (cce: ClassCastException) { - Timber.w(cce) - // Previous versions stored this as a string. - val s = preferences.getString(UPGRADE_VERSION_KEY, "") - // The last version of AnkiDroid that stored this as a string was 2.0.2. - // We manually set the version here, but anything older will force a DB check. - if ("2.0.2" == s) { - 40 - } else { - 0 - } - } - Timber.d("Updating shared preferences stored key %s type to long", UPGRADE_VERSION_KEY) - // Expected Editor.putLong to be called later to update the value in shared prefs - preferences.edit().remove(UPGRADE_VERSION_KEY).apply() - } - Timber.i("Previous AnkiDroid version: %s", previous) - return previous - } - private fun undo() { launchCatchingTask { undoAndShowSnackbar() @@ -2508,7 +2436,6 @@ open class DeckPicker : */ const val RESULT_MEDIA_EJECTED = 202 const val RESULT_DB_ERROR = 203 - const val UPGRADE_VERSION_KEY = "lastUpgradeVersion" /** * If passed into the intent, the user should have been logged in and DeckPicker @@ -2581,21 +2508,6 @@ open class DeckPicker : * configured a moment later when the coroutine runs. To work around this, * the current state is stored in the deck picker so that we can redraw the * menu immediately. */ -data class OptionsMenuState( - val searchIcon: Boolean, - /** If undo is available, a string describing the action. */ - val undoLabel: String?, - val syncIcon: SyncIconState, - val undoAvailable: Boolean, - val isColEmpty: Boolean, -) - -enum class SyncIconState { - Normal, - PendingChanges, - OneWay, - NotLoggedIn, -} class CollectionLoadingErrorDialog : DialogHandlerMessage( diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt index 5dd45cc97d1f..e485d54b7461 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt @@ -16,6 +16,8 @@ package com.ichi2.anki.deckpicker +import android.annotation.SuppressLint +import android.content.SharedPreferences import android.os.Build import androidx.annotation.CheckResult import androidx.lifecycle.ViewModel @@ -23,9 +25,11 @@ import androidx.lifecycle.viewModelScope import anki.card_rendering.EmptyCardsReport import anki.collection.OpChanges import anki.i18n.GeneratedTranslations +import anki.sync.SyncStatusResponse import com.ichi2.anki.CollectionManager import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.CollectionManager.withOpenColOrNull import com.ichi2.anki.DeckPicker import com.ichi2.anki.InitialActivity import com.ichi2.anki.OnErrorListener @@ -38,6 +42,8 @@ import com.ichi2.anki.libanki.Consts import com.ichi2.anki.libanki.Consts.DEFAULT_DECK_ID import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.libanki.sched.DeckNode +import com.ichi2.anki.libanki.undoAvailable +import com.ichi2.anki.libanki.undoLabel import com.ichi2.anki.libanki.utils.extend import com.ichi2.anki.noteeditor.NoteEditorLauncher import com.ichi2.anki.notetype.ManageNoteTypesDestination @@ -45,7 +51,10 @@ import com.ichi2.anki.observability.undoableOp import com.ichi2.anki.pages.DeckOptionsDestination import com.ichi2.anki.performBackupInBackground import com.ichi2.anki.reviewreminders.ScheduleRemindersDestination +import com.ichi2.anki.settings.Prefs +import com.ichi2.anki.syncAuth import com.ichi2.anki.utils.Destination +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -54,7 +63,9 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import net.ankiweb.rsdroid.RustCleanup +import net.ankiweb.rsdroid.exceptions.BackendNetworkException import timber.log.Timber /** @@ -423,6 +434,89 @@ class DeckPickerViewModel : val empty = FlattenedDeckList(emptyList(), hasSubDecks = false) } } + + /** + * Fetches the current sync icon state for the menu + */ + suspend fun fetchSyncIconState(): SyncIconState { + if (!Prefs.displaySyncStatus) return SyncIconState.Normal + val auth = syncAuth() + if (auth == null) return SyncIconState.NotLoggedIn + return try { + // Use CollectionManager to ensure that this doesn't block 'deck count' tasks + // throws if a .colpkg import or similar occurs just before this call + val output = withContext(Dispatchers.IO) { CollectionManager.getBackend().syncStatus(auth) } + if (output.hasNewEndpoint() && output.newEndpoint.isNotEmpty()) { + Prefs.currentSyncUri = output.newEndpoint + } + when (output.required) { + SyncStatusResponse.Required.NO_CHANGES -> SyncIconState.Normal + SyncStatusResponse.Required.NORMAL_SYNC -> SyncIconState.PendingChanges + SyncStatusResponse.Required.FULL_SYNC -> SyncIconState.OneWay + SyncStatusResponse.Required.UNRECOGNIZED, null -> TODO("unexpected required response") + } + } catch (_: BackendNetworkException) { + SyncIconState.Normal + } catch (e: Exception) { + Timber.d(e, "error obtaining sync status: collection likely closed") + SyncIconState.Normal + } + } + + /** + * Updates the menu state with current collection information + */ + suspend fun updateMenuState(): OptionsMenuState? = + withOpenColOrNull { + val searchIcon = decks.count() >= 10 + val undoLabel = undoLabel() + val undoAvailable = undoAvailable() + // besides checking for cards being available also consider if we have empty decks + val isColEmpty = isEmpty && decks.count() == 1 + // the correct sync status is fetched in the next call so "Normal" is used as a placeholder + OptionsMenuState(searchIcon, undoLabel, SyncIconState.Normal, undoAvailable, isColEmpty) + }?.let { (searchIcon, undoLabel, _, undoAvailable, isColEmpty) -> + val syncIcon = fetchSyncIconState() + OptionsMenuState(searchIcon, undoLabel, syncIcon, undoAvailable, isColEmpty) + } + + @SuppressLint("UseKtx") + fun getPreviousVersion( + preferences: SharedPreferences, + current: Long, + ): Long { + var previous: Long + try { + previous = preferences.getLong(UPGRADE_VERSION_KEY, current) + } catch (e: ClassCastException) { + Timber.w(e) + previous = + try { + // set 20900203 to default value, as it's the latest version that stores integer in shared prefs + preferences.getInt(UPGRADE_VERSION_KEY, 20900203).toLong() + } catch (cce: ClassCastException) { + Timber.w(cce) + // Previous versions stored this as a string. + val s = preferences.getString(UPGRADE_VERSION_KEY, "") + // The last version of AnkiDroid that stored this as a string was 2.0.2. + // We manually set the version here, but anything older will force a DB check. + if ("2.0.2" == s) { + 40 + } else { + 0 + } + } + Timber.d("Updating shared preferences stored key %s type to long", UPGRADE_VERSION_KEY) + // Expected Editor.putLong to be called later to update the value in shared prefs + preferences.edit().remove(UPGRADE_VERSION_KEY).apply() + } + Timber.i("Previous AnkiDroid version: %s", previous) + return previous + } + + companion object { + const val UPGRADE_VERSION_KEY = "lastUpgradeVersion" + } } /** Result of [DeckPickerViewModel.deleteDeck] */ @@ -453,3 +547,20 @@ data class EmptyCardsResult( } fun DeckNode.onlyHasDefaultDeck() = children.singleOrNull()?.did == DEFAULT_DECK_ID + +enum class SyncIconState { + Normal, + PendingChanges, + OneWay, + NotLoggedIn, +} + +/** Menu state data for the options menu */ +data class OptionsMenuState( + val searchIcon: Boolean, + /** If undo is available, a string describing the action. */ + val undoLabel: String?, + val syncIcon: SyncIconState, + val undoAvailable: Boolean, + val isColEmpty: Boolean, +) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt index bf805eef6f3c..aaf3636e3ea1 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt @@ -19,6 +19,7 @@ import app.cash.turbine.test import com.ichi2.anki.common.annotations.NeedsTest import com.ichi2.anki.common.time.TimeManager import com.ichi2.anki.common.utils.annotation.KotlinCleanup +import com.ichi2.anki.deckpicker.DeckPickerViewModel import com.ichi2.anki.dialogs.DatabaseErrorDialog import com.ichi2.anki.dialogs.DatabaseErrorDialog.DatabaseErrorDialogType import com.ichi2.anki.dialogs.DeckPickerContextMenu @@ -91,24 +92,24 @@ class DeckPickerTest : RobolectricTest() { fun getPreviousVersionUpgradeFrom201to292() { val newVersion = 20900302 // 2.9.2 val preferences = mock(SharedPreferences::class.java) - whenever(preferences.getLong(DeckPicker.UPGRADE_VERSION_KEY, newVersion.toLong())) + whenever(preferences.getLong(DeckPickerViewModel.UPGRADE_VERSION_KEY, newVersion.toLong())) .thenThrow(ClassCastException::class.java) - whenever(preferences.getInt(DeckPicker.UPGRADE_VERSION_KEY, newVersion)) + whenever(preferences.getInt(DeckPickerViewModel.UPGRADE_VERSION_KEY, newVersion)) .thenThrow(ClassCastException::class.java) - whenever(preferences.getString(DeckPicker.UPGRADE_VERSION_KEY, "")) + whenever(preferences.getString(DeckPickerViewModel.UPGRADE_VERSION_KEY, "")) .thenReturn("2.0.1") val editor = mock(SharedPreferences.Editor::class.java) whenever(preferences.edit()).thenReturn(editor) val updated = mock(SharedPreferences.Editor::class.java) - whenever(editor.remove(DeckPicker.UPGRADE_VERSION_KEY)).thenReturn(updated) + whenever(editor.remove(DeckPickerViewModel.UPGRADE_VERSION_KEY)).thenReturn(updated) ActivityScenario.launch(DeckPicker::class.java).use { scenario -> scenario.onActivity { deckPicker: DeckPicker -> val previousVersion = - deckPicker.getPreviousVersion(preferences, newVersion.toLong()) + deckPicker.viewModel.getPreviousVersion(preferences, newVersion.toLong()) assertEquals(0, previousVersion) } } - verify(editor, times(1)).remove(DeckPicker.UPGRADE_VERSION_KEY) + verify(editor, times(1)).remove(DeckPickerViewModel.UPGRADE_VERSION_KEY) verify(updated, times(1)).apply() } @@ -117,23 +118,23 @@ class DeckPickerTest : RobolectricTest() { fun getPreviousVersionUpgradeFrom202to292() { val newVersion: Long = 20900302 // 2.9.2 val preferences = mock(SharedPreferences::class.java) - whenever(preferences.getLong(DeckPicker.UPGRADE_VERSION_KEY, newVersion)) + whenever(preferences.getLong(DeckPickerViewModel.UPGRADE_VERSION_KEY, newVersion)) .thenThrow(ClassCastException::class.java) - whenever(preferences.getInt(DeckPicker.UPGRADE_VERSION_KEY, 20900203)) + whenever(preferences.getInt(DeckPickerViewModel.UPGRADE_VERSION_KEY, 20900203)) .thenThrow(ClassCastException::class.java) - whenever(preferences.getString(DeckPicker.UPGRADE_VERSION_KEY, "")) + whenever(preferences.getString(DeckPickerViewModel.UPGRADE_VERSION_KEY, "")) .thenReturn("2.0.2") val editor = mock(SharedPreferences.Editor::class.java) whenever(preferences.edit()).thenReturn(editor) val updated = mock(SharedPreferences.Editor::class.java) - whenever(editor.remove(DeckPicker.UPGRADE_VERSION_KEY)).thenReturn(updated) + whenever(editor.remove(DeckPickerViewModel.UPGRADE_VERSION_KEY)).thenReturn(updated) ActivityScenario.launch(DeckPicker::class.java).use { scenario -> scenario.onActivity { deckPicker: DeckPicker -> - val previousVersion = deckPicker.getPreviousVersion(preferences, newVersion) + val previousVersion = deckPicker.viewModel.getPreviousVersion(preferences, newVersion) assertEquals(40, previousVersion) } } - verify(editor, times(1)).remove(DeckPicker.UPGRADE_VERSION_KEY) + verify(editor, times(1)).remove(DeckPickerViewModel.UPGRADE_VERSION_KEY) verify(updated, times(1)).apply() } @@ -143,21 +144,21 @@ class DeckPickerTest : RobolectricTest() { val prevVersion = 20800301 // 2.8.1 val newVersion: Long = 20900301 // 2.9.1 val preferences = mock(SharedPreferences::class.java) - whenever(preferences.getLong(DeckPicker.UPGRADE_VERSION_KEY, newVersion)) + whenever(preferences.getLong(DeckPickerViewModel.UPGRADE_VERSION_KEY, newVersion)) .thenThrow(ClassCastException::class.java) - whenever(preferences.getInt(DeckPicker.UPGRADE_VERSION_KEY, 20900203)) + whenever(preferences.getInt(DeckPickerViewModel.UPGRADE_VERSION_KEY, 20900203)) .thenReturn(prevVersion) val editor = mock(SharedPreferences.Editor::class.java) whenever(preferences.edit()).thenReturn(editor) val updated = mock(SharedPreferences.Editor::class.java) - whenever(editor.remove(DeckPicker.UPGRADE_VERSION_KEY)).thenReturn(updated) + whenever(editor.remove(DeckPickerViewModel.UPGRADE_VERSION_KEY)).thenReturn(updated) ActivityScenario.launch(DeckPicker::class.java).use { scenario -> scenario.onActivity { deckPicker: DeckPicker -> - val previousVersion = deckPicker.getPreviousVersion(preferences, newVersion) + val previousVersion = deckPicker.viewModel.getPreviousVersion(preferences, newVersion) assertEquals(prevVersion.toLong(), previousVersion) } } - verify(editor, times(1)).remove(DeckPicker.UPGRADE_VERSION_KEY) + verify(editor, times(1)).remove(DeckPickerViewModel.UPGRADE_VERSION_KEY) verify(updated, times(1)).apply() } @@ -166,17 +167,17 @@ class DeckPickerTest : RobolectricTest() { val prevVersion: Long = 20900301 // 2.9.1 val newVersion: Long = 20900302 // 2.9.2 val preferences = mock(SharedPreferences::class.java) - whenever(preferences.getLong(DeckPicker.UPGRADE_VERSION_KEY, newVersion)) + whenever(preferences.getLong(DeckPickerViewModel.UPGRADE_VERSION_KEY, newVersion)) .thenReturn(prevVersion) val editor = mock(SharedPreferences.Editor::class.java) whenever(preferences.edit()).thenReturn(editor) ActivityScenario.launch(DeckPicker::class.java).use { scenario -> scenario.onActivity { deckPicker: DeckPicker -> - val previousVersion = deckPicker.getPreviousVersion(preferences, newVersion) + val previousVersion = deckPicker.viewModel.getPreviousVersion(preferences, newVersion) assertEquals(prevVersion, previousVersion) } } - verify(editor, never()).remove(DeckPicker.UPGRADE_VERSION_KEY) + verify(editor, never()).remove(DeckPickerViewModel.UPGRADE_VERSION_KEY) } @Test