diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt index d87518e7a39d..968ab1122d4c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt @@ -43,6 +43,7 @@ import com.ichi2.anki.common.utils.annotation.KotlinCleanup import com.ichi2.anki.compat.CompatHelper import com.ichi2.anki.contextmenu.AnkiCardContextMenu import com.ichi2.anki.contextmenu.CardBrowserContextMenu +import com.ichi2.anki.exception.CollectionLockedException import com.ichi2.anki.exception.StorageAccessException import com.ichi2.anki.exception.SystemStorageException import com.ichi2.anki.logging.FragmentLifecycleLogger @@ -181,6 +182,9 @@ open class AnkiDroidApp : initializeWidgetRepository() WidgetNotificationScheduler.register { scheduleNotification() } Animations.setPreferencesProvider { context -> PrefsRepository(context) } + CollectionLockedException.messageProvider = { + getString(CommonString.database_locked_summary_new, getString(CommonString.col_path)) + } val logType = LogType.value when (logType) { LogType.DEBUG -> Timber.plant(DebugTree()) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt index 058b76403b1d..ea4044e3b72d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt @@ -31,11 +31,14 @@ import com.ichi2.anki.common.annotations.UseContextParameter import com.ichi2.anki.common.coroutines.applicationScope import com.ichi2.anki.common.crashreporting.CrashReportService import com.ichi2.anki.common.destinations.DeckOptionsDestination +import com.ichi2.anki.common.destinations.PreferencesDestination import com.ichi2.anki.dialogs.DatabaseErrorDialog import com.ichi2.anki.dialogs.DatabaseErrorDialog.DatabaseErrorDialogType +import com.ichi2.anki.exception.CollectionLockedException import com.ichi2.anki.exception.StorageAccessException import com.ichi2.anki.exception.StorageNotConfiguredException import com.ichi2.anki.pages.toIntent +import com.ichi2.anki.preferences.toIntent import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.startup.redirectToMainEntryPoint import com.ichi2.anki.ui.internationalization.sentenceCase @@ -163,7 +166,9 @@ suspend fun FragmentActivity.runCatching( Timber.w(exc, errorMessage) if (!isFinishing) redirectToMainEntryPoint() } - is BackendNetworkException, is BackendSyncException, is StorageAccessException, is BackendCardTypeException -> { + is BackendNetworkException, is BackendSyncException, is StorageAccessException, is BackendCardTypeException, + is CollectionLockedException, + -> { // these exceptions do not generate worthwhile crash reports Timber.i("Showing error dialog but not sending a crash report.") showError(exc.localizedMessage!!, exc.toCrashReportData(this, reportException = false)) @@ -686,6 +691,20 @@ data class CrashReportData( } } + /** + * Opens settings at the Advanced screen, where changing the 'AnkiDroid directory' + * resolves a [CollectionLockedException] (#21051) + */ + data object OpenAdvancedSettings : HelpAction() { + override fun buttonText(context: Context): CharSequence = context.getString(CommonString.settings) + + override suspend fun execute(context: Context): Boolean { + Timber.i("Opening 'Advanced settings'") + context.startActivity(PreferencesDestination.Advanced.toIntent(context)) + return true + } + } + /** Opens 'Check Database' */ data object OpenCheckDatabase : HelpAction() { override fun buttonText(context: Context): CharSequence = with(context) { TR.sentenceCase.checkDatabase } @@ -714,6 +733,7 @@ data class CrashReportData( } if (link != null) return AnkiBackendLink(link) + if (e is CollectionLockedException) return OpenAdvancedSettings if (e.isInvalidFsrsParametersException()) return OpenDeckOptions if (e.isDeckNotFoundInLimitsMapException()) return OpenCheckDatabase diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt index 73df41266d34..5cfd63d28f17 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt @@ -24,6 +24,7 @@ import com.ichi2.anki.common.storage.StorageDecision import com.ichi2.anki.common.storage.isLegacyStorage import com.ichi2.anki.common.utils.android.SdCard import com.ichi2.anki.compat.CompatHelper.Companion.sdkVersion +import com.ichi2.anki.exception.CollectionLockedException import com.ichi2.anki.exception.StorageAccessException import com.ichi2.anki.servicelayer.PreferenceUpgradeService import com.ichi2.anki.servicelayer.PreferenceUpgradeService.setPreferencesUpToDate @@ -69,7 +70,7 @@ object InitialActivity { try { CollectionManager.getColUnsafe() return null - } catch (e: BackendException.BackendDbException.BackendDbLockedException) { + } catch (e: CollectionLockedException) { Timber.w(e) StartupFailure.DatabaseLocked } catch (e: BackendException.BackendDbException.BackendDbFileTooNewException) { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/account/LoginFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/account/LoginFragment.kt index 1a6dd2b8711a..3965a07e2e32 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/account/LoginFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/account/LoginFragment.kt @@ -30,11 +30,14 @@ import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputLayout import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CommonString +import com.ichi2.anki.CrashReportData.Companion.toCrashReportData import com.ichi2.anki.DeckPicker import com.ichi2.anki.R import com.ichi2.anki.account.AccountActivity.Companion.START_FROM_DECKPICKER import com.ichi2.anki.dialogs.help.HelpDialog +import com.ichi2.anki.exception.CollectionLockedException import com.ichi2.anki.getEndpoint +import com.ichi2.anki.showError import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.ui.internationalization.sentenceCase import com.ichi2.anki.utils.bottomCornerClearance @@ -244,7 +247,15 @@ class LoginFragment : Fragment(R.layout.fragment_my_account) { showLoginSuccessDialog() } is LoginState.Error -> { - showSnackbar(text = state.exception.message.toString()) + when (val exception = state.exception) { + // the locked-collection guidance is too long for a snackbar + is CollectionLockedException -> + requireContext().showError( + exception.localizedMessage!!, + exception.toCrashReportData(requireContext(), reportException = false), + ) + else -> showSnackbar(text = exception.message.toString()) + } } is LoginState.Idle -> { /* Not needed */ } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt index ce92f5375bfc..c89667b52a19 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt @@ -29,6 +29,8 @@ import com.ichi2.anki.R import com.ichi2.anki.ankiActivity import com.ichi2.anki.backend.DatabaseCorruption import com.ichi2.anki.backend.getDatabaseVersion +import com.ichi2.anki.common.destinations.PreferencesDestination +import com.ichi2.anki.common.destinations.navigate import com.ichi2.anki.common.storage.CollectionHelper import com.ichi2.anki.common.time.TimeManager import com.ichi2.anki.dialogs.DatabaseErrorDialog.DatabaseErrorDialogType.DIALOG_CONFIRM_DATABASE_CHECK @@ -355,13 +357,16 @@ class DatabaseErrorDialog : AsyncDialogFragment() { } } DIALOG_DB_LOCKED -> { - // If the database is locked, all we can do is ask the user to exit. alertDialog.show { title(CommonString.database_locked_title) message(text = message) positiveButton(CommonString.close) { closeCollectionAndFinish() } + // the user can change the 'AnkiDroid directory' to resolve the conflict + neutralButton(CommonString.settings) { + navigate(PreferencesDestination.Advanced) + } cancelable(false) } } @@ -585,7 +590,7 @@ class DatabaseErrorDialog : AsyncDialogFragment() { DIALOG_CONFIRM_DATABASE_CHECK -> res().getString(CommonString.check_db_warning) DIALOG_CONFIRM_RESTORE_BACKUP -> res().getString(CommonString.restore_backup) DIALOG_ONE_WAY_SYNC_FROM_SERVER -> res().getString(CommonString.backup_full_sync_from_server_question) - DIALOG_DB_LOCKED -> res().getString(CommonString.database_locked_summary) + DIALOG_DB_LOCKED -> res().getString(CommonString.database_locked_summary_new, res().getString(CommonString.col_path)) INCOMPATIBLE_DB_VERSION -> { var databaseVersion = -1 try { diff --git a/AnkiDroid/src/main/res/values/03-dialogs.xml b/AnkiDroid/src/main/res/values/03-dialogs.xml index d028635b97ae..a7b1a4124c4f 100644 --- a/AnkiDroid/src/main/res/values/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values/03-dialogs.xml @@ -119,7 +119,7 @@ Database Locked - The AnkiDroid database is in use by another application. Please close the other application then reopen AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ā€˜%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Incompatible Database Version diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt index 8282cf475a3c..8c56fa4b4368 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt @@ -8,6 +8,7 @@ import com.ichi2.anki.CollectionManager.tryWithCol import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.CollectionManager.withColExclusive import com.ichi2.anki.CollectionManager.withLeaseForTest +import com.ichi2.anki.exception.CollectionLockedException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @@ -19,7 +20,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import net.ankiweb.rsdroid.Backend -import net.ankiweb.rsdroid.BackendException import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo import org.hamcrest.Matchers.notNullValue @@ -395,7 +395,7 @@ class CollectionLeaseTest : RobolectricTest() { runTest { CollectionManager.emulatedOpenFailure = CollectionManager.CollectionOpenFailure.LOCKED try { - assertFailsWith { + assertFailsWith { withColExclusive(CollectionOperation.SYNC) { error("collection could not open") } } assertThat(collectionLease, nullValue()) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerImportTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerImportTest.kt index 16a6eef4aea0..a37280942234 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerImportTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerImportTest.kt @@ -5,8 +5,8 @@ package com.ichi2.anki import androidx.core.content.edit import androidx.test.ext.junit.runners.AndroidJUnit4 import com.ichi2.anki.common.storage.CollectionHelper +import com.ichi2.anki.exception.CollectionLockedException import net.ankiweb.rsdroid.BackendException -import net.ankiweb.rsdroid.BackendException.BackendDbException.BackendDbLockedException import net.ankiweb.rsdroid.BackendException.BackendImportException import net.ankiweb.rsdroid.exceptions.BackendIoException import net.ankiweb.rsdroid.exceptions.BackendSyncException @@ -66,7 +66,7 @@ class CollectionManagerImportTest : RobolectricTest() { val failure = assertFailsWith { CollectionManager.importColpkg(backup.path) } assertEquals(expectedMessage, failure.localizedMessage) - assertIs(failure.suppressed.single()) + assertIs(failure.suppressed.single()) } finally { CollectionManager.emulatedOpenFailure = null } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerStackTraceTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerStackTraceTest.kt index 1cf6d04952cd..7c3c407fa678 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerStackTraceTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerStackTraceTest.kt @@ -3,12 +3,14 @@ package com.ichi2.anki import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.exception.CollectionLockedException import kotlinx.coroutines.test.StandardTestDispatcher import net.ankiweb.rsdroid.BackendException.BackendDbException.BackendDbLockedException import org.junit.Test import org.junit.runner.RunWith import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) @@ -22,8 +24,9 @@ class CollectionManagerStackTraceTest : RobolectricTest() { try { CollectionManager.emulatedOpenFailure = CollectionManager.CollectionOpenFailure.LOCKED - val failure = assertFailsWith { requestCollection() } + val failure = assertFailsWith { requestCollection() } + assertIs(failure.cause) assertFalse(failure.stackTrace.any { it.methodName == "requestCollection" }) val caller = failure.suppressed.single() assertTrue(caller.stackTrace.any { it.methodName == "requestCollection" }) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionStorageModeTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionStorageModeTest.kt index 85e4729ed17d..83f638bfed51 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionStorageModeTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionStorageModeTest.kt @@ -3,9 +3,9 @@ package com.ichi2.anki import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.exception.CollectionLockedException import com.ichi2.anki.libanki.Collection import com.ichi2.anki.libanki.CollectionFiles -import net.ankiweb.rsdroid.BackendException.BackendDbException.BackendDbLockedException import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo import org.hamcrest.Matchers.instanceOf @@ -51,8 +51,8 @@ class CollectionStorageModeTest( fun `test access respects collection opening failures`() = runTest { withNullCollection { - assertThat(runCatching { col }.exceptionOrNull(), instanceOf(BackendDbLockedException::class.java)) - assertThat(runCatching { withCol { this } }.exceptionOrNull(), instanceOf(BackendDbLockedException::class.java)) + assertThat(runCatching { col }.exceptionOrNull(), instanceOf(CollectionLockedException::class.java)) + assertThat(runCatching { withCol { this } }.exceptionOrNull(), instanceOf(CollectionLockedException::class.java)) } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersRobolectricTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersRobolectricTest.kt index 49c7d798aacd..156e95ad488f 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersRobolectricTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersRobolectricTest.kt @@ -2,13 +2,23 @@ package com.ichi2.anki +import android.content.DialogInterface +import androidx.appcompat.app.AlertDialog import androidx.fragment.app.FragmentActivity import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.exception.CollectionLockedException import com.ichi2.anki.exception.StorageNotConfiguredException +import com.ichi2.anki.preferences.PreferencesActivity +import com.ichi2.testutils.BackendEmulatingOpenConflict +import kotlinx.coroutines.test.runTest +import org.hamcrest.CoreMatchers.containsString +import org.hamcrest.MatcherAssert.assertThat import org.junit.Test import org.junit.runner.RunWith import org.robolectric.Robolectric import org.robolectric.Shadows.shadowOf +import org.robolectric.shadows.ShadowDialog import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -34,4 +44,73 @@ class CoroutineHelpersRobolectricTest : RobolectricTest() { assertEquals(IntentHandler::class.qualifiedName, redirect.component?.className) } } + + /** + * #21051: the collection lock is normally held by a second AnkiDroid install sharing the + * AnkiDroid folder. The backend's 'Anki already open' text doesn't explain this on Android, + * where the other app is invisible: [CollectionLockedException] carries guidance naming the + * likely cause instead. + */ + @Test + fun `launchCatchingTask explains a locked collection`() = + withLockedCollection { + throwOnShowError = false + val controller = Robolectric.buildActivity(FragmentActivity::class.java).also(::saveControllerForCleanup) + val activity = controller.create().get() + activity.setTheme(R.style.Theme_Light) + + activity.launchCatchingTask { withCol { } } + advanceRobolectricLooper() + + assertThat(getAlertDialogText(true), containsString("Advanced settings")) + } + + /** See `launchCatchingTask explains a locked collection`: the ViewModel error funnel */ + @Test + fun `launchCatching explains a locked collection`() = + withLockedCollection { + runTest { + var message: String? = null + + launchCatching(errorMessageHandler = { message = it }) { withCol { } }.join() + + assertThat(message, containsString("Advanced settings")) + } + } + + /** + * The locked-collection dialog offers Settings, opened at the Advanced screen, where + * changing the 'AnkiDroid directory' resolves the conflict + */ + @Test + fun `a locked collection error links to Advanced settings`() = + withLockedCollection { + throwOnShowError = false + val controller = Robolectric.buildActivity(FragmentActivity::class.java).also(::saveControllerForCleanup) + val activity = controller.create().get() + activity.setTheme(R.style.Theme_Light) + + activity.launchCatchingTask { withCol { } } + advanceRobolectricLooper() + + val helpButton = (ShadowDialog.getLatestDialog() as AlertDialog).getButton(DialogInterface.BUTTON_NEUTRAL) + assertEquals("Settings", helpButton.text.toString()) + + helpButton.performClick() + advanceRobolectricLooper() + + val settings = shadowOf(activity).nextStartedActivity + assertNotNull(settings, "Advanced settings should be opened") + assertEquals(PreferencesActivity::class.qualifiedName, settings.component?.className) + } + + /** Emulates #21051: another AnkiDroid install holds the collection lock */ + private fun withLockedCollection(block: () -> Unit) { + BackendEmulatingOpenConflict.enable() + try { + block() + } finally { + BackendEmulatingOpenConflict.disable() + } + } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt index 1014f2a4a288..ad28eac592d8 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt @@ -4,6 +4,7 @@ package com.ichi2.anki import android.Manifest.permission.INTERNET import android.annotation.SuppressLint +import android.content.DialogInterface import android.content.Intent import android.content.SharedPreferences import android.content.pm.PackageManager @@ -54,6 +55,7 @@ import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.model.SelectableDeck import com.ichi2.anki.navigation.AnkiDroidNavigator import com.ichi2.anki.observability.ChangeManager +import com.ichi2.anki.preferences.PreferencesActivity import com.ichi2.anki.settings.Prefs import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.ui.RecyclerFastScroller @@ -317,6 +319,30 @@ class DeckPickerTest : RobolectricTest() { ) } + /** + * The 'Database Locked' dialog offers Settings, opened at the Advanced screen, where + * changing the 'AnkiDroid directory' resolves the conflict (#21051). Without it, the + * dialog's only button quits the app, contradicting the guidance in its own message. + */ + @Test + fun `database locked dialog links to Advanced settings`() { + val deckPicker = startRegularActivity() + deckPicker.showDatabaseErrorDialog(DatabaseErrorDialogType.DIALOG_DB_LOCKED) + deckPicker.supportFragmentManager.executePendingTransactions() + advanceRobolectricLooper() + + val settingsButton = (ShadowDialog.getLatestDialog() as AlertDialog).getButton(DialogInterface.BUTTON_NEUTRAL) + assertEquals("Settings", settingsButton.text.toString()) + + shadowOf(deckPicker).clearNextStartedActivities() + settingsButton.performClick() + advanceRobolectricLooper() + + val settings = shadowOf(deckPicker).nextStartedActivity + assertNotNull(settings, "Advanced settings should be opened") + assertEquals(PreferencesActivity::class.qualifiedName, settings.component?.className) + } + /** Until the storage setup flow exists (#19552), the user gets recovery options, not a crash */ @Test fun `storage undecided shows load-failure options rather than crashing`() { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt index ff9bf5eed72f..f4e2e1d294b4 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt @@ -385,7 +385,7 @@ open class RobolectricTest : } /** - * Emulates a null collection and a `BackendDbLockedException` while [block] runs, + * Emulates a null collection and a `CollectionLockedException` while [block] runs, * restoring normal collection behavior afterwards. * * @see enableNullCollection diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/account/LoginFragmentTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/account/LoginFragmentTest.kt new file mode 100644 index 000000000000..c527d3310765 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/account/LoginFragmentTest.kt @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.account + +import android.widget.Button +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.android.material.textfield.TextInputEditText +import com.ichi2.anki.R +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.throwOnShowError +import com.ichi2.testutils.BackendEmulatingOpenConflict +import com.ichi2.ui.TextInputEditField +import org.hamcrest.CoreMatchers.containsString +import org.hamcrest.MatcherAssert.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LoginFragmentTest : RobolectricTest() { + @Before + override fun setUp() { + super.setUp() + BackendEmulatingOpenConflict.enable() + } + + @After + override fun tearDown() { + super.tearDown() + BackendEmulatingOpenConflict.disable() + } + + /** + * #21051: the locked-collection guidance is multi-sentence, so it is shown in a dialog + * rather than truncated in the login screen's snackbar + */ + @Test + fun `login with a locked collection shows guidance`() { + throwOnShowError = false + val activity = startRegularActivity(AccountActivity.getIntent(targetContext)) + activity.findViewById(R.id.username).setText("user@example.com") + activity.findViewById(R.id.password).setText("hunter2") + + activity.findViewById