Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand Down
22 changes: 21 additions & 1 deletion AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -163,7 +166,9 @@ suspend fun <T> 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))
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
13 changes: 12 additions & 1 deletion AnkiDroid/src/main/java/com/ichi2/anki/account/LoginFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 */ }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion AnkiDroid/src/main/res/values/03-dialogs.xml
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@

<!-- Database Errors-->
<string name="database_locked_title">Database Locked</string>
<string name="database_locked_summary">The AnkiDroid database is in use by another application. Please close the other application then reopen AnkiDroid.</string>
<string name="database_locked_summary_new" comment="%s is the name of the 'AnkiDroid directory' setting">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.</string>


<string name="incompatible_database_version_title">Incompatible Database Version</string>
Expand Down
4 changes: 2 additions & 2 deletions AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -395,7 +395,7 @@ class CollectionLeaseTest : RobolectricTest() {
runTest {
CollectionManager.emulatedOpenFailure = CollectionManager.CollectionOpenFailure.LOCKED
try {
assertFailsWith<BackendException.BackendDbException.BackendDbLockedException> {
assertFailsWith<CollectionLockedException> {
withColExclusive(CollectionOperation.SYNC) { error("collection could not open") }
}
assertThat(collectionLease, nullValue())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -66,7 +66,7 @@ class CollectionManagerImportTest : RobolectricTest() {
val failure = assertFailsWith<BackendImportException> { CollectionManager.importColpkg(backup.path) }

assertEquals(expectedMessage, failure.localizedMessage)
assertIs<BackendDbLockedException>(failure.suppressed.single())
assertIs<CollectionLockedException>(failure.suppressed.single())
} finally {
CollectionManager.emulatedOpenFailure = null
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -22,8 +24,9 @@ class CollectionManagerStackTraceTest : RobolectricTest() {
try {
CollectionManager.emulatedOpenFailure = CollectionManager.CollectionOpenFailure.LOCKED

val failure = assertFailsWith<BackendDbLockedException> { requestCollection() }
val failure = assertFailsWith<CollectionLockedException> { requestCollection() }

assertIs<BackendDbLockedException>(failure.cause)
assertFalse(failure.stackTrace.any { it.methodName == "requestCollection" })
val caller = failure.suppressed.single()
assertTrue(caller.stackTrace.any { it.methodName == "requestCollection" })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
}
}
}
Loading
Loading