Skip to content
Draft
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
2 changes: 2 additions & 0 deletions AnkiDroid/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,8 @@ dependencies {
// for testing flows
testImplementation libs.cashapp.turbine

androidTestImplementation testFixtures(project(":anki-common"))

// May need a resolution strategy for support libs to our versions
androidTestImplementation libs.androidx.espresso.core
androidTestImplementation(libs.androidx.espresso.contrib) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import android.database.Cursor
import android.database.CursorWindow
import android.net.Uri
import anki.cards.FsrsMemoryState
import anki.collection.OpChanges
import anki.notetypes.StockNotetype
import com.ichi2.anki.CollectionManager
import com.ichi2.anki.Flag
Expand All @@ -37,6 +36,7 @@ import com.ichi2.anki.libanki.backend.BackendUtils
import com.ichi2.anki.libanki.exception.ConfirmModSchemaException
import com.ichi2.anki.libanki.getStockNotetype
import com.ichi2.anki.libanki.sched.Scheduler
import com.ichi2.anki.observability.ChangeCounter
import com.ichi2.anki.observability.ChangeManager
import com.ichi2.anki.provider.pureAnswer
import com.ichi2.anki.testutil.DatabaseUtils.cursorFillWindow
Expand Down Expand Up @@ -2475,7 +2475,7 @@ class ContentProviderTest : InstrumentedTest() {

@Test
fun testInsertNotifiesUI() {
val counter = TestSubscriber()
val counter = ChangeCounter()
ChangeManager.subscribe(counter)
try {
val mid = noteTypeId
Expand Down Expand Up @@ -2504,7 +2504,7 @@ class ContentProviderTest : InstrumentedTest() {
ContentValues().apply {
put(FlashCardsContract.Note.TAGS, "new_tag")
}
val counter = TestSubscriber()
val counter = ChangeCounter()
ChangeManager.subscribe(counter)
try {
contentResolver.update(uri, values, null, null)
Expand All @@ -2516,7 +2516,7 @@ class ContentProviderTest : InstrumentedTest() {

@Test
fun testUpdateNonExistentNoteDoesNotNotifyUI() {
val counter = TestSubscriber()
val counter = ChangeCounter()
ChangeManager.awaitPendingOpChanges()
ChangeManager.subscribe(counter)
try {
Expand All @@ -2531,7 +2531,7 @@ class ContentProviderTest : InstrumentedTest() {
}

ChangeManager.awaitPendingOpChanges()
assertEquals("UI should not be notified if update is failed", 0, counter.count)
assertEquals("UI should not be notified if update is failed", 0, counter.changeCount)
} finally {
ChangeManager.unsubscribe(counter)
}
Expand All @@ -2541,7 +2541,7 @@ class ContentProviderTest : InstrumentedTest() {
fun testDeleteNotifiesUI() {
val noteId = createdNotes.first().lastPathSegment!!.toLong()
val uri = Uri.withAppendedPath(FlashCardsContract.Note.CONTENT_URI, noteId.toString())
val counter = TestSubscriber()
val counter = ChangeCounter()
ChangeManager.subscribe(counter)
try {
contentResolver.delete(uri, null, null)
Expand All @@ -2553,7 +2553,7 @@ class ContentProviderTest : InstrumentedTest() {

@Test
fun testDeleteNonExistentNoteDoesNotNotifyUI() {
val counter = TestSubscriber()
val counter = ChangeCounter()
ChangeManager.awaitPendingOpChanges()
ChangeManager.subscribe(counter)
try {
Expand All @@ -2562,15 +2562,15 @@ class ContentProviderTest : InstrumentedTest() {
assertEquals("It should return 0 for non-existent note", 0, deletedCount)

ChangeManager.awaitPendingOpChanges()
assertEquals("UI should not be notify if nothing was deleted", 0, counter.count)
assertEquals("UI should not be notify if nothing was deleted", 0, counter.changeCount)
} finally {
ChangeManager.unsubscribe(counter)
}
}

@Test
fun testBulkInsertNotifiesUI() {
val counter = TestSubscriber()
val counter = ChangeCounter()
ChangeManager.subscribe(counter)
try {
val mid = noteTypeId
Expand All @@ -2595,14 +2595,14 @@ class ContentProviderTest : InstrumentedTest() {

@Test
fun testBulkInsertEmptyListDoesNotNotifyUI() {
val counter = TestSubscriber()
val counter = ChangeCounter()
ChangeManager.awaitPendingOpChanges()
ChangeManager.subscribe(counter)
try {
contentResolver.bulkInsert(FlashCardsContract.Note.CONTENT_URI, emptyArray())

ChangeManager.awaitPendingOpChanges()
assertEquals("UI should not be notified for empty bulk insert", 0, counter.count)
assertEquals("UI should not be notified for empty bulk insert", 0, counter.changeCount)
} finally {
ChangeManager.unsubscribe(counter)
}
Expand Down Expand Up @@ -2692,26 +2692,14 @@ class ContentProviderTest : InstrumentedTest() {
}
}

// TODO: PERF: use TestChangeSubscriber once we've moved to testFixtures
private class TestSubscriber : ChangeManager.Subscriber {
var count = 0

override fun opExecuted(
changes: OpChanges,
handler: Any?,
) {
count++
}
}

private fun assertNotificationReceived(subscriber: TestSubscriber) {
private fun assertNotificationReceived(subscriber: ChangeCounter) {
val timeout = 5000L
val startTime = TimeManager.time.intTimeMS()
while (subscriber.count == 0 && TimeManager.time.intTimeMS() - startTime < timeout) {
while (!subscriber.hasChanges && TimeManager.time.intTimeMS() - startTime < timeout) {
Thread.sleep(100)
}

assertTrue("UI should be notified of the change", subscriber.count > 0)
assertTrue("UI should be notified of the change", subscriber.hasChanges)
}

private val contentResolver: ContentResolver
Expand Down
36 changes: 0 additions & 36 deletions AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,6 @@ import com.ichi2.anki.dialogs.DatabaseErrorDialog
import com.ichi2.anki.dialogs.DatabaseErrorDialog.DatabaseErrorDialogType
import com.ichi2.anki.exception.StorageAccessException
import com.ichi2.anki.exception.StorageNotConfiguredException
import com.ichi2.anki.libanki.exception.InvalidSearchException
import com.ichi2.anki.pages.fromCurrentDeck
import com.ichi2.anki.pages.toIntent
import com.ichi2.anki.snackbar.showSnackbar
import com.ichi2.anki.startup.redirectToMainEntryPoint
Expand Down Expand Up @@ -72,8 +70,6 @@ import net.ankiweb.rsdroid.exceptions.BackendNetworkException
import net.ankiweb.rsdroid.exceptions.BackendSyncException
import org.jetbrains.annotations.VisibleForTesting
import timber.log.Timber
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds

Expand All @@ -86,38 +82,6 @@ var ioDispatcher: CoroutineDispatcher = Dispatchers.IO
@VisibleForTesting
var throwOnShowError = false

/**
* Runs a suspend function that catches any uncaught errors and reports them to the user.
* Errors from the backend contain localized text that is often suitable to show to the user as-is.
* Other errors should ideally be handled in the block.
*
* @param context Coroutine context passed to [launch]
* @param errorMessageHandler Called after an exception is caught and logged, input is either
* `Exception.localizedMessage` or `Exception.toString()`
* @param block code to execute inside [launch]
*/
fun CoroutineScope.launchCatching(
context: CoroutineContext = EmptyCoroutineContext,
errorMessageHandler: suspend (String) -> Unit,
block: suspend CoroutineScope.() -> Unit,
): Job =
launch(context) {
try {
block()
} catch (cancellationException: CancellationException) {
// CancellationException should be re-thrown to propagate it to the parent coroutine
throw cancellationException
} catch (exception: Exception) {
Timber.w(exception)
val message =
when (exception) {
is BackendException, is InvalidSearchException -> exception.localizedMessage
else -> null
} ?: exception.toString()
errorMessageHandler.invoke(message)
}
}

interface OnErrorListener {
val onError: MutableSharedFlow<String>
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,8 @@ package com.ichi2.anki.pages

import android.content.Context
import android.content.Intent
import androidx.annotation.CheckResult
import com.ichi2.anki.CollectionManager.withCol
import com.ichi2.anki.common.destinations.DeckOptionsDestination
import com.ichi2.anki.filtered.FilteredDeckOptionsFragment
import com.ichi2.anki.libanki.DeckId

/** Builds the [Intent] that opens the deck options screen for this destination. */
fun DeckOptionsDestination.toIntent(context: Context): Intent =
Expand All @@ -18,19 +15,3 @@ fun DeckOptionsDestination.toIntent(context: Context): Intent =
} else {
DeckOptions.getIntent(context, deckId)
}

suspend fun DeckOptionsDestination.Companion.fromDeckId(deckId: DeckId): DeckOptionsDestination =
DeckOptionsDestination(
deckId = deckId,
isFiltered = withCol { decks.isFiltered(deckId) },
)

@CheckResult
suspend fun DeckOptionsDestination.Companion.fromCurrentDeck(): DeckOptionsDestination =
withCol {
val deckId = decks.getCurrentId()
DeckOptionsDestination(
deckId = deckId,
isFiltered = decks.isFiltered(deckId),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import com.ichi2.anki.common.destinations.toIntent
import com.ichi2.anki.isCollectionEmpty
import com.ichi2.anki.libanki.DeckId
import com.ichi2.anki.libanki.Decks.Companion.NOT_FOUND_DECK_ID
import com.ichi2.anki.pages.fromDeckId
import com.ichi2.widget.ACTION_UPDATE_WIDGET
import com.ichi2.widget.AnalyticsWidgetProvider
import com.ichi2.widget.AppWidgetId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import com.ichi2.anki.common.destinations.ReviewDeckDestination
import com.ichi2.anki.common.destinations.toIntent
import com.ichi2.anki.isCollectionEmpty
import com.ichi2.anki.libanki.DeckId
import com.ichi2.anki.pages.fromDeckId
import com.ichi2.widget.ACTION_UPDATE_WIDGET
import com.ichi2.widget.AnalyticsWidgetProvider
import com.ichi2.widget.AppWidgetId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import anki.scheduler.CardAnswer.Rating
import app.cash.turbine.test
import com.ichi2.anki.CollectionManager.withCol
import com.ichi2.testutils.ensureOpsExecuted
import com.ichi2.anki.observability.ensureOpsExecuted
import kotlinx.coroutines.joinAll
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.instanceOf
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,16 +66,16 @@ import com.ichi2.anki.model.SelectableDeck
import com.ichi2.anki.model.SortType
import com.ichi2.anki.model.cardBrowserNoSorting
import com.ichi2.anki.noteeditor.NoteEditorLauncher
import com.ichi2.anki.observability.ensureNoOpsExecuted
import com.ichi2.anki.observability.ensureOpWithHandler
import com.ichi2.anki.observability.ensureOpsExecuted
import com.ichi2.anki.servicelayer.NoteService
import com.ichi2.anki.setFlagFilterSync
import com.ichi2.anki.settings.Prefs
import com.ichi2.anki.utils.ext.ignoreAccentsInSearch
import com.ichi2.testutils.IntentAssert
import com.ichi2.testutils.JvmTest
import com.ichi2.testutils.createTransientDirectory
import com.ichi2.testutils.ensureNoOpsExecuted
import com.ichi2.testutils.ensureOpWithHandler
import com.ichi2.testutils.ensureOpsExecuted
import com.ichi2.testutils.ext.reopenWithLanguage
import com.ichi2.testutils.mockIt
import kotlinx.coroutines.CancellationException
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import com.ichi2.anki.libanki.Consts
import com.ichi2.anki.libanki.DeckId
import com.ichi2.anki.libanki.Note
import com.ichi2.anki.libanki.emptyCids
import com.ichi2.testutils.ensureOpsExecuted
import com.ichi2.anki.observability.ensureOpsExecuted
import org.hamcrest.CoreMatchers.not
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.equalTo
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.ichi2.anki.RobolectricTest
import com.ichi2.anki.dialogs.utils.AnKingTags
import com.ichi2.testutils.ensureOpsExecuted
import com.ichi2.anki.observability.ensureOpsExecuted
import kotlinx.coroutines.flow.first
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.containsInAnyOrder
Expand Down
10 changes: 10 additions & 0 deletions anki-common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,17 @@ dependencies {
implementation(libs.jakewharton.timber)
implementation(libs.kotlinx.coroutines.core)

testImplementation(testFixtures(project(":anki-common")))
testImplementation(libs.hamcrest)
testImplementation(libs.junit.jupiter)
testImplementation(libs.junit.platform.launcher)
testImplementation(libs.junit.vintage.engine)
testImplementation(libs.kotlin.reflect)
testImplementation(libs.kotlinx.coroutines.test)

testFixturesImplementation(project(":common:android"))
testFixturesImplementation(libs.androidx.core.ktx)
testFixturesImplementation(libs.androidx.test.core)
testFixturesImplementation(libs.jakewharton.timber)
testFixturesImplementation(libs.kotlin.test)
}
46 changes: 46 additions & 0 deletions anki-common/src/main/kotlin/com/ichi2/anki/LaunchCatching.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: Copyright (c) 2023 Brayan Oliveira <69634269+brayandso@users.noreply.github.com>

package com.ichi2.anki

import com.ichi2.anki.libanki.exception.InvalidSearchException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import net.ankiweb.rsdroid.BackendException
import timber.log.Timber
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext

/**
* Runs a suspend function that catches any uncaught errors and reports them to the user.
* Errors from the backend contain localized text that is often suitable to show to the user as-is.
* Other errors should ideally be handled in the block.
*
* @param context Coroutine context passed to [launch]
* @param errorMessageHandler Called after an exception is caught and logged, input is either
* `Exception.localizedMessage` or `Exception.toString()`
* @param block code to execute inside [launch]
*/
fun CoroutineScope.launchCatching(
context: CoroutineContext = EmptyCoroutineContext,
errorMessageHandler: suspend (String) -> Unit,
block: suspend CoroutineScope.() -> Unit,
): Job =
launch(context) {
try {
block()
} catch (cancellationException: CancellationException) {
// CancellationException should be re-thrown to propagate it to the parent coroutine
throw cancellationException
} catch (exception: Exception) {
Timber.w(exception)
val message =
when (exception) {
is BackendException, is InvalidSearchException -> exception.localizedMessage
else -> null
} ?: exception.toString()
errorMessageHandler.invoke(message)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

package com.ichi2.anki.common.destinations

import androidx.annotation.CheckResult
import com.ichi2.anki.CollectionManager.withCol
import com.ichi2.anki.libanki.DeckId

/**
Expand All @@ -15,7 +17,23 @@ data class DeckOptionsDestination(
val isFiltered: Boolean,
val options: List<DeckOptionsEntry> = emptyList(),
) : Destination() {
companion object
companion object {
suspend fun fromDeckId(deckId: DeckId): DeckOptionsDestination =
DeckOptionsDestination(
deckId = deckId,
isFiltered = withCol { decks.isFiltered(deckId) },
)

@CheckResult
suspend fun fromCurrentDeck(): DeckOptionsDestination =
withCol {
val deckId = decks.getCurrentId()
DeckOptionsDestination(
deckId = deckId,
isFiltered = decks.isFiltered(deckId),
)
}
}
}

/**
Expand Down
Loading
Loading