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
2 changes: 2 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import com.ichi2.anki.logging.FragmentLifecycleLogger
import com.ichi2.anki.logging.LogType
import com.ichi2.anki.logging.ProductionCrashReportingTree
import com.ichi2.anki.logging.RobolectricDebugTree
import com.ichi2.anki.navigation.initializeNavigator
import com.ichi2.anki.observability.ChangeManager
import com.ichi2.anki.preferences.SharedPreferencesProvider
import com.ichi2.anki.preferences.sharedPrefs
Expand Down Expand Up @@ -136,6 +137,7 @@ open class AnkiDroidApp :
ChangeManager.subscribe(this)

initializeAcraCrashReporter()
initializeNavigator()
val logType = LogType.value
when (logType) {
LogType.DEBUG -> Timber.plant(DebugTree())
Expand Down
2 changes: 2 additions & 0 deletions AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ import com.ichi2.anki.android.input.ShortcutGroup
import com.ichi2.anki.android.input.shortcut
import com.ichi2.anki.common.annotations.NeedsTest
import com.ichi2.anki.common.crashreporting.CrashReportService
import com.ichi2.anki.common.destinations.navigate
import com.ichi2.anki.common.time.TimeManager
import com.ichi2.anki.common.utils.annotation.KotlinCleanup
import com.ichi2.anki.compat.CompatHelper.Companion.getSerializableCompat
Expand Down Expand Up @@ -780,6 +781,7 @@ open class DeckPicker :
viewModel.emptyCardsNotification.launchCollectionInLifecycleScope(::onCardsEmptied)
viewModel.flowOfDeckCountsChanged.launchCollectionInLifecycleScope(::onDeckCountsChanged)
viewModel.flowOfDestination.launchCollectionInLifecycleScope(::onDestinationChanged)
viewModel.flowOfNavigate.launchCollectionInLifecycleScope { navigate(it) }
viewModel.flowOfExportDeck.launchCollectionInLifecycleScope(::onExportDeck)
viewModel.flowOfCreateShortcut.launchCollectionInLifecycleScope(::createIcon)
viewModel.flowOfDisableShortcuts.launchCollectionInLifecycleScope(::disableDeckAndChildrenShortcuts)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,53 +1,22 @@
/*
* Copyright (c) 2025 David Allison <davidallisongithub@gmail.com>
*
* This program is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3 of the License, or (at your option) any later
* version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <http://www.gnu.org/licenses/>.
*/
// SPDX-License-Identifier: GPL-3.0-or-later

package com.ichi2.anki.browser

import android.content.Context
import android.content.Intent
import com.ichi2.anki.CardBrowser
import com.ichi2.anki.libanki.CardId
import com.ichi2.anki.libanki.DeckId
import com.ichi2.anki.utils.Destination
import com.ichi2.anki.common.destinations.BrowserDestination

/**
* Opens the [CardBrowser]
*/
sealed interface BrowserDestination : Destination {
data class ToDeck(
val deckId: DeckId,
) : BrowserDestination {
override fun toIntent(context: Context): Intent =
/** Builds the [Intent] that launches [CardBrowser] for this destination. */
fun BrowserDestination.toIntent(context: Context): Intent =
when (this) {
is BrowserDestination.ToDeck ->
Intent(context, CardBrowser::class.java).apply {
putExtra(CardBrowserViewModel.EXTRA_DECK_ID, deckId)
}
}

/**
* Opens the [CardBrowser] scoped to [deckId], auto-scrolling to [cardId]
* if the card is present on the deck.
*/
data class ScrollToCard(
val deckId: DeckId,
val cardId: CardId,
) : BrowserDestination {
override fun toIntent(context: Context): Intent =
is BrowserDestination.ScrollToCard ->
Intent(context, CardBrowser::class.java).apply {
putExtra(CardBrowserViewModel.EXTRA_DECK_ID, deckId)
putExtra(CardBrowserViewModel.EXTRA_CARD_ID_KEY, cardId)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import com.ichi2.anki.DeckPicker
import com.ichi2.anki.InitialActivity
import com.ichi2.anki.OnErrorListener
import com.ichi2.anki.PermissionSet
import com.ichi2.anki.browser.BrowserDestination
import com.ichi2.anki.common.destinations.BrowserDestination
import com.ichi2.anki.configureRenderingMode
import com.ichi2.anki.launchCatchingIO
import com.ichi2.anki.libanki.CardId
Expand Down Expand Up @@ -70,6 +70,7 @@ import kotlinx.coroutines.withContext
import net.ankiweb.rsdroid.RustCleanup
import net.ankiweb.rsdroid.exceptions.BackendNetworkException
import timber.log.Timber
import com.ichi2.anki.common.destinations.Destination as NavigateDestination

/**
* ViewModel for the [DeckPicker]
Expand Down Expand Up @@ -135,6 +136,7 @@ class DeckPickerViewModel :
val deckDeletedNotification = MutableSharedFlow<DeckDeletionResult>(extraBufferCapacity = 1)
val emptyCardsNotification = MutableSharedFlow<EmptyCardsResult>(extraBufferCapacity = 1)
val flowOfDestination = MutableSharedFlow<Destination>(extraBufferCapacity = 1)
val flowOfNavigate = MutableSharedFlow<NavigateDestination>(extraBufferCapacity = 1)
override val onError = MutableSharedFlow<String>(extraBufferCapacity = 1)
val flowOfExportDeck = MutableSharedFlow<DeckId>()
val flowOfCreateShortcut = MutableSharedFlow<ShortcutData>()
Expand Down Expand Up @@ -291,7 +293,7 @@ class DeckPickerViewModel :
fun browseCards(deckId: DeckId) =
launchCatchingIO {
withCol { decks.select(deckId) }
flowOfDestination.emit(BrowserDestination.ToDeck(deckId))
flowOfNavigate.emit(BrowserDestination.ToDeck(deckId))
}

fun addNote(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: GPL-3.0-or-later

package com.ichi2.anki.navigation

import android.app.Application
import android.content.Context
import android.content.Intent
import com.ichi2.anki.browser.toIntent
import com.ichi2.anki.common.destinations.BrowserDestination
import com.ichi2.anki.common.destinations.Destination
import com.ichi2.anki.common.destinations.Navigator

/** AnkiDroid's [Navigator] implementation. */
object AnkiDroidNavigator : Navigator {
private lateinit var appContext: Context

fun initialize(application: Application) {
appContext = application
}

override fun toIntent(destination: Destination): Intent =
when (destination) {
is BrowserDestination -> destination.toIntent(appContext)
}
}

/** Initializes the AnkiDroid navigator and wires it up as the global [Navigator]. */
context(application: Application)
fun initializeNavigator() {
AnkiDroidNavigator.initialize(application)
Navigator.register(AnkiDroidNavigator)
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ import com.ichi2.anki.DispatchKeyEventListener
import com.ichi2.anki.Flag
import com.ichi2.anki.R
import com.ichi2.anki.android.AnkiShakeDetector
import com.ichi2.anki.android.back.doubleBackPressCallback
import com.ichi2.anki.cardviewer.Gesture
import com.ichi2.anki.common.annotations.NeedsTest
import com.ichi2.anki.common.destinations.navigate
import com.ichi2.anki.common.utils.android.isRobolectric
import com.ichi2.anki.databinding.FragmentReviewerBinding
import com.ichi2.anki.dialogs.showDeckOptionsSelectionDialog
Expand Down Expand Up @@ -205,6 +205,10 @@ class ReviewerFragment :
binding.rootLayout.requestFocus()
}

viewModel.navigateFlow.collectIn(lifecycleScope) { destination ->
navigate(destination)
}

viewModel.destinationFlow.collectIn(lifecycleScope) { destination ->
if (destination is DeckOptionsDestination && destination.options.size > 1) {
requireContext().showDeckOptionsSelectionDialog(destination.options) { selectedOption ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ import com.ichi2.anki.CollectionManager.withCol
import com.ichi2.anki.Flag
import com.ichi2.anki.Reviewer
import com.ichi2.anki.asyncIO
import com.ichi2.anki.browser.BrowserDestination
import com.ichi2.anki.cardviewer.SingleCardSide
import com.ichi2.anki.common.annotations.NeedsTest
import com.ichi2.anki.common.destinations.BrowserDestination
import com.ichi2.anki.launchCatchingIO
import com.ichi2.anki.libanki.Card
import com.ichi2.anki.libanki.CardId
Expand Down Expand Up @@ -82,6 +82,7 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import org.intellij.lang.annotations.Language
import timber.log.Timber
import com.ichi2.anki.common.destinations.Destination as NavigateDestination

class ReviewerViewModel(
savedStateHandle: SavedStateHandle,
Expand Down Expand Up @@ -113,6 +114,7 @@ class ReviewerViewModel(
val onTypedAnswerResultFlow = MutableSharedFlow<CompletableDeferred<String>>()
val onCardUpdatedFlow = MutableSharedFlow<Unit>()
val destinationFlow = MutableSharedFlow<Destination>()
val navigateFlow = MutableSharedFlow<NavigateDestination>()
val editNoteTagsFlow = MutableSharedFlow<NoteId>()
val setDueDateFlow = MutableSharedFlow<CardId>()
val resetProgressFlow = MutableSharedFlow<Unit>()
Expand Down Expand Up @@ -348,7 +350,7 @@ class ReviewerViewModel(
val cardId = currentCard.await().id
val destination = BrowserDestination.ScrollToCard(deckId, cardId)
Timber.i("Launching 'browse options' for deck %d", deckId)
destinationFlow.emit(destination)
navigateFlow.emit(destination)
}

private suspend fun deleteNote() {
Expand Down
19 changes: 4 additions & 15 deletions AnkiDroid/src/main/java/com/ichi2/anki/utils/Destination.kt
Original file line number Diff line number Diff line change
@@ -1,23 +1,12 @@
/*
* Copyright (c) 2025 Brayan Oliveira <brayandso.dev@gmail.com>
*
* This program is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3 of the License, or (at your option) any later
* version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <http://www.gnu.org/licenses/>.
*/
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: Copyright (c) 2025 Brayan Oliveira <brayandso.dev@gmail.com>

package com.ichi2.anki.utils

import android.content.Context
import android.content.Intent

// TODO: Replace with com.ichi2.anki.common.destinations.Destination + navigate(). See #20558.
interface Destination {
fun toIntent(context: Context): Intent
}
12 changes: 9 additions & 3 deletions AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import com.ichi2.anki.dialogs.DeckPickerContextMenuResult
import com.ichi2.anki.dialogs.setDeckPickerContextMenuResult
import com.ichi2.anki.dialogs.utils.title
import com.ichi2.anki.libanki.DeckId
import com.ichi2.anki.navigation.AnkiDroidNavigator
import com.ichi2.anki.observability.ChangeManager
import com.ichi2.anki.preferences.sharedPrefs
import com.ichi2.anki.settings.Prefs
Expand All @@ -48,6 +49,7 @@ import com.ichi2.testutils.grantWritePermissions
import com.ichi2.testutils.revokeWritePermissions
import com.ichi2.testutils.withDeniedPermissions
import com.ichi2.testutils.withWritePermissions
import kotlinx.coroutines.flow.merge
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.containsInAnyOrder
import org.hamcrest.Matchers.containsString
Expand Down Expand Up @@ -426,12 +428,16 @@ class DeckPickerTest : RobolectricTest() {
option: ContextMenuOption,
deckId: DeckId,
): Intent {
var result: Destination? = null
viewModel.flowOfDestination.test(1.seconds) {
var result: Any? = null
merge(viewModel.flowOfDestination, viewModel.flowOfNavigate).test(1.seconds) {
selectContextMenuOption(option, deckId)
result = awaitItem()
}
return result!!.toIntent(this)
return when (val emitted = result!!) {
is Destination -> emitted.toIntent(this)
is com.ichi2.anki.common.destinations.Destination -> AnkiDroidNavigator.toIntent(emitted)
else -> error("Unexpected destination type: $emitted")
}
}

val didA = addDeck("Deck 1")
Expand Down
4 changes: 3 additions & 1 deletion anki-common/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// SPDX-FileCopyrightText: 2026 David Allison <davidallisongithub@gmail.com>
// SPDX-License-Identifier: GPL-3.0-or-later

import com.android.build.api.dsl.LibraryExtension
Expand All @@ -17,4 +16,7 @@ configure<LibraryExtension> {
dependencies {
implementation(project(":common"))
implementation(project(":libanki"))

implementation(libs.androidx.activity)
implementation(libs.androidx.fragment.ktx)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// SPDX-License-Identifier: GPL-3.0-or-later

package com.ichi2.anki.common.destinations

import com.ichi2.anki.libanki.CardId
import com.ichi2.anki.libanki.DeckId

/** Opens the Card Browser. */
// TODO: A number of destination are undefined - grep for CardBrowser::class (#20558)
sealed class BrowserDestination : Destination() {
/** Opens the Card Browser scoped to [deckId]. */
data class ToDeck(
val deckId: DeckId,
) : BrowserDestination()

/**
* Opens the Card Browser scoped to [deckId], auto-scrolling to [cardId]
* if the card is present on the deck.
*/
data class ScrollToCard(
val deckId: DeckId,
val cardId: CardId,
) : BrowserDestination()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// SPDX-License-Identifier: GPL-3.0-or-later

package com.ichi2.anki.common.destinations

/**
* A target screen that can be navigated to.
*
* Concrete destinations are grouped under a [Destination] subclass per screen.
*
* To navigate to a destination, call [navigate].
*
* @see BrowserDestination - example destination subclass
* @see Navigator - singleton registration and intent building
*/
sealed class Destination
// See `AnkiDroidNavigator` for all destinations
// it is not technically feasible for them to be listed here
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// SPDX-License-Identifier: GPL-3.0-or-later

package com.ichi2.anki.common.destinations

import android.app.Activity
import android.content.Intent
import androidx.activity.result.ActivityResultLauncher
import androidx.fragment.app.Fragment

// TODO: Move this into anki-common:android after libanki becomes a java-library

/**
* Global navigation instance, set during app initialization.
*
* Accessible via [navigate].
*/
private lateinit var navigatorInstance: Navigator

/**
* Resolves a [Destination] to an [Intent].
*
* Implementations should use the application `Context` internally.
*/
interface Navigator {
// the intent constructor only uses context for the package name,
// so using the app context is acceptable.
fun toIntent(destination: Destination): Intent

companion object {
/**
* Use during app startup to set the global [Navigator] instance.
*
* Placed on the companion object, so calers may use `Navigator.register` to avoid
* collisions with other top-level `register` functions.
*/
fun register(navigator: Navigator) {
navigatorInstance = navigator
}
}
}

/** Starts the activity corresponding to [destination]. */
context(activity: Activity)
fun navigate(destination: Destination) {
activity.startActivity(navigatorInstance.toIntent(destination))
}

/** Starts the activity corresponding to [destination] from the host of this Fragment. */
context(fragment: Fragment)
fun navigate(destination: Destination) {
fragment.requireActivity().startActivity(navigatorInstance.toIntent(destination))
}

/** Launches [destination] via an [ActivityResultLauncher], so the caller can observe the result. */
context(launcher: ActivityResultLauncher<Intent>)
fun navigate(destination: Destination) {
launcher.launch(navigatorInstance.toIntent(destination))
}
Loading