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
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// SPDX-License-Identifier: GPL-3.0-or-later

package com.ichi2.anki

import android.app.Activity
import android.content.Intent
import android.os.SystemClock
import androidx.core.net.toUri
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.ichi2.anki.tests.InstrumentedTest
import com.ichi2.anki.testutil.GrantStoragePermission
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.equalTo
import org.hamcrest.Matchers.instanceOf
import org.hamcrest.Matchers.not
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.test.fail
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds

/**
* Sending the `anki://x-callback-url/browser?search=` deep link opens a search in [CardBrowser].
*/
@RunWith(AndroidJUnit4::class)
class CardBrowserDeepLinkTest : InstrumentedTest() {
@get:Rule
val storagePermission = GrantStoragePermission.instance

@Test
fun deepLinkOpensCardBrowserWithSearch() {
addNoteUsingBasicNoteType("dog", "barks")
addNoteUsingBasicNoteType("cat", "meows")

// implicit VIEW intent, constrained to AnkiDroid so the OS resolves it without a chooser
val deepLink =
Intent(Intent.ACTION_VIEW, "anki://x-callback-url/browser?search=dog".toUri()).apply {
setPackage(testContext.packageName)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
testContext.startActivity(deepLink)

waitForActivity<CardBrowser> {
assertThat(
"the deep link opens the browser on its search",
viewModel.searchTerms,
equalTo("dog"),
)
}
}

/**
* The deep link opens [CardBrowser] standalone, so pressing 'back' closes it.
*/
@Test
fun backFromBrowserClosesTheActivity() {
addNoteUsingBasicNoteType("dog", "barks")

val deepLink =
Intent(Intent.ACTION_VIEW, "anki://x-callback-url/browser?search=dog".toUri()).apply {
setPackage(testContext.packageName)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
testContext.startActivity(deepLink)

val browser = awaitResumed<CardBrowser>()
val instrumentation = InstrumentationRegistry.getInstrumentation()
instrumentation.runOnMainSync { browser.onBackPressedDispatcher.onBackPressed() }
instrumentation.waitForIdleSync()

assertThat("'back' closes the browser", browser.isFinishing, equalTo(true))
assertThat(
"'back' does not surface DeckPicker beneath the browser",
TestUtils.activityInstance,
not(instanceOf(DeckPicker::class.java)),
)
}

/**
* Waits for an activity of type [T] to resume (the end of the deep-link chain), runs [block]
* against it on the main thread, then finishes its task. Fails if [T] does not resume in time.
*/
private inline fun <reified T : Activity> waitForActivity(
timeout: Duration = 10.seconds,
crossinline block: T.() -> Unit,
) {
val resolved = awaitResumed<T>(timeout)
val instrumentation = InstrumentationRegistry.getInstrumentation()
try {
instrumentation.runOnMainSync { resolved.block() }
} finally {
instrumentation.runOnMainSync { resolved.finishAndRemoveTask() }
}
}

/**
* Polls until an activity of type [T] is resumed and returns it. Fails if [T] does not resume
* within [timeout].
*/
private inline fun <reified T : Activity> awaitResumed(timeout: Duration = 10.seconds): T {
val deadline = SystemClock.uptimeMillis() + timeout.inWholeMilliseconds
val instrumentation = InstrumentationRegistry.getInstrumentation()
var activity = TestUtils.activityInstance as? T

while (activity == null && SystemClock.uptimeMillis() < deadline) {
instrumentation.waitForIdleSync()
SystemClock.sleep(50)
activity = TestUtils.activityInstance as? T
}
return activity
?: fail("Timed out waiting for ${T::class.java.simpleName}; resumed = ${TestUtils.activityInstance}")
}
}
15 changes: 10 additions & 5 deletions AnkiDroid/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -329,17 +329,22 @@
android:name="com.ichi2.anki.CardBrowser"
android:label="@string/card_browser"
android:theme="@style/Theme_Dark.Launcher"
android:exported="true"
android:exported="false"
android:parentActivityName=".DeckPicker"
>
<intent-filter android:label="@string/card_browser">
/>
<!-- `anki://x-callback-url/browser` deep link. -->
<activity-alias
android:name="com.ichi2.anki.CardBrowserDeepLink"
android:label="@string/card_browser"
android:targetActivity="com.ichi2.anki.IntentHandler"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<!-- Accepts URIs that begin with "anki://x-callback-url/browser” -->
<data android:scheme="anki" android:host="x-callback-url" android:path="/browser"/>
</intent-filter>
</activity>
</activity-alias>
<activity
android:name=".notetype.ManageNotetypes"
android:label="@string/model_browser_label"
Expand Down
3 changes: 2 additions & 1 deletion AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidJsAPI.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import com.ichi2.anki.AnkiDroidJsAPIConstants.ANKI_JS_ERROR_CODE_SUSPEND_CARD
import com.ichi2.anki.AnkiDroidJsAPIConstants.ANKI_JS_ERROR_CODE_SUSPEND_NOTE
import com.ichi2.anki.AnkiDroidJsAPIConstants.flagCommands
import com.ichi2.anki.CollectionManager.withCol
import com.ichi2.anki.browser.CardBrowserViewModel
import com.ichi2.anki.browser.search.SearchString
import com.ichi2.anki.cardviewer.ViewerCommand
import com.ichi2.anki.common.annotations.NeedsTest
Expand Down Expand Up @@ -307,7 +308,7 @@ open class AnkiDroidJsAPI(
val intent =
Intent(context, CardBrowser::class.java).apply {
putExtra("currentCard", currentCard.id)
putExtra("search_query", apiParams)
putExtra(CardBrowserViewModel.EXTRA_SEARCH_QUERY, apiParams)
}
activity.startActivity(intent)
convertToByteArray(apiContract, true)
Expand Down
5 changes: 3 additions & 2 deletions AnkiDroid/src/main/java/com/ichi2/anki/BackendImporting.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import anki.collection.OpChangesOnly
import anki.import_export.ImportAnkiPackageRequest
import anki.search.SearchNode
import com.ichi2.anki.CollectionManager.withCol
import com.ichi2.anki.browser.CardBrowserViewModel
import com.ichi2.anki.libanki.importCsvRaw
import com.ichi2.anki.observability.undoableOp
import kotlinx.coroutines.Dispatchers
Expand Down Expand Up @@ -74,8 +75,8 @@ suspend fun FragmentActivity.searchInBrowser(input: ByteArray): ByteArray {
val searchString = withCol { buildSearchString(listOf(SearchNode.parseFrom(input))) }
val starterIntent =
Intent(this, CardBrowser::class.java).apply {
putExtra("search_query", searchString)
putExtra("all_decks", true)
putExtra(CardBrowserViewModel.EXTRA_SEARCH_QUERY, searchString)
putExtra(CardBrowserViewModel.EXTRA_ALL_DECKS, true)
}
startActivity(starterIntent)
return input
Expand Down
33 changes: 32 additions & 1 deletion AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ class IntentHandler : AbstractIntentHandler() {
}
LaunchType.SYNC -> runIfStoragePermissions { handleSyncIntent(reloadIntent, action) }
LaunchType.REVIEW -> runIfStoragePermissions { handleReviewIntent(reloadIntent, intent) }
LaunchType.OPEN_BROWSER -> runIfStoragePermissions { handleBrowserIntent(intent) }
LaunchType.DEFAULT_START_APP_IF_NEW -> {
Timber.d("onCreate() performing default action")
launchDeckPickerIfNoOtherTasks(reloadIntent)
Expand Down Expand Up @@ -152,6 +153,21 @@ class IntentHandler : AbstractIntentHandler() {
}
}

/**
* Opens [CardBrowser] standalone in response to `anki://x-callback-url/browser`.
*/
private fun handleBrowserIntent(intent: Intent) {
Timber.i("Handling intent to open the Card Browser")
val browserIntent =
Intent(this, CardBrowser::class.java).apply {
action = Intent.ACTION_VIEW
data = intent.data
}
// 'back' should close this activity.
startActivity(browserIntent)
finish()
}

private fun handleReviewIntent(
reloadIntent: Intent,
reviewerIntent: Intent,
Expand Down Expand Up @@ -328,6 +344,9 @@ class IntentHandler : AbstractIntentHandler() {

SYNC,
REVIEW,

/** `anki://x-callback-url/browser` deep link */
OPEN_BROWSER,
COPY_DEBUG_INFO,
}

Expand Down Expand Up @@ -366,11 +385,22 @@ class IntentHandler : AbstractIntentHandler() {
return granted
}

/** Whether this is the `anki://x-callback-url/browser` deep link that opens the [CardBrowser]. */
private fun Intent.isBrowserDeepLink(): Boolean {
val data = data ?: return false
return action == Intent.ACTION_VIEW &&
data.scheme == "anki" &&
data.host == "x-callback-url" &&
data.path == "/browser"
}

@VisibleForTesting
@CheckResult
fun getLaunchType(intent: Intent): LaunchType {
val action = intent.action
return if (action == Intent.ACTION_SEND || (Intent.ACTION_VIEW == action && isValidViewIntent(intent))) {
return if (intent.isBrowserDeepLink()) {
LaunchType.OPEN_BROWSER
} else if (action == Intent.ACTION_SEND || (Intent.ACTION_VIEW == action && isValidViewIntent(intent))) {
val mimeType = intent.resolveMimeType()
when {
mimeType?.startsWith("image/") == true -> LaunchType.IMAGE_IMPORT
Expand Down Expand Up @@ -418,6 +448,7 @@ class IntentHandler : AbstractIntentHandler() {
LaunchType.TEXT_IMPORT,
LaunchType.IMAGE_IMPORT,
LaunchType.SHARED_TEXT,
LaunchType.OPEN_BROWSER,
-> true
LaunchType.COPY_DEBUG_INFO -> false
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ fun Intent.toCardBrowserLaunchOptions(): CardBrowserLaunchOptions? {
}

// for intent coming from search query js api
getStringExtra("search_query")?.let {
return CardBrowserLaunchOptions.SearchQueryJs(it, getBooleanExtra("all_decks", false))
getStringExtra(CardBrowserViewModel.EXTRA_SEARCH_QUERY)?.let {
return CardBrowserLaunchOptions.SearchQueryJs(it, getBooleanExtra(CardBrowserViewModel.EXTRA_ALL_DECKS, false))
}

getLongExtra(CardBrowserViewModel.EXTRA_CARD_ID_KEY)?.let { cardId ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1545,6 +1545,12 @@ class CardBrowserViewModel(
/** Intent extra carrying a [CardId] to auto-scroll to once the browser opens. */
const val EXTRA_CARD_ID_KEY = "cardId"

/** Intent extra (`String`) carrying the search to run. */
const val EXTRA_SEARCH_QUERY = "search_query"

/** Intent extra (`Boolean`) for whether [EXTRA_SEARCH_QUERY] should search all decks. */
const val EXTRA_ALL_DECKS = "all_decks"

/** Prevents one-shot extras from being re-applied after process death. */
private const val STATE_LAUNCH_INTENT_CONSUMED = "launchIntentConsumed"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import com.ichi2.anki.CardBrowser
import com.ichi2.anki.CollectionManager.TR
import com.ichi2.anki.DeckPicker
import com.ichi2.anki.R
import com.ichi2.anki.browser.CardBrowserViewModel
import com.ichi2.anki.databinding.DialogEmptyCardsBinding
import com.ichi2.anki.dialogs.EmptyCardsUiState.EmptyCardsSearchFailure
import com.ichi2.anki.dialogs.EmptyCardsUiState.EmptyCardsSearchResult
Expand Down Expand Up @@ -254,8 +255,8 @@ class EmptyCardsDialogFragment : DialogFragment() {
) : ClickableSpan() {
override fun onClick(widget: View) {
val browserSearchIntent = Intent(context, CardBrowser::class.java)
browserSearchIntent.putExtra("search_query", "nid:$nid")
browserSearchIntent.putExtra("all_decks", true)
browserSearchIntent.putExtra(CardBrowserViewModel.EXTRA_SEARCH_QUERY, "nid:$nid")
browserSearchIntent.putExtra(CardBrowserViewModel.EXTRA_ALL_DECKS, true)
context.startActivity(browserSearchIntent)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import com.google.android.material.textfield.TextInputLayout
import com.ichi2.anki.CardBrowser
import com.ichi2.anki.CollectionManager.TR
import com.ichi2.anki.R
import com.ichi2.anki.browser.CardBrowserViewModel
import com.ichi2.anki.databinding.FragmentFilteredDeckOptionsBinding
import com.ichi2.anki.dialogs.DiscardChangesDialog
import com.ichi2.anki.libanki.DeckId
Expand Down Expand Up @@ -151,7 +152,7 @@ class FilteredDeckOptionsFragment : Fragment(R.layout.fragment_filtered_deck_opt
}
if (state.browserQuery != null) {
val browserSearchIntent = Intent(context, CardBrowser::class.java)
browserSearchIntent.putExtra("search_query", state.browserQuery)
browserSearchIntent.putExtra(CardBrowserViewModel.EXTRA_SEARCH_QUERY, state.browserQuery)
startActivity(browserSearchIntent)
viewModel.clearSearchInBrowser()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ class ActivityStartupMetaTest : RobolectricTest() {
manifestActivities
.map { it.name }
.filter { it != "com.ichi2.anki.TestCardTemplatePreviewer" }
// activity aliases
.filter { it != "com.ichi2.anki.AnkiCardContextMenuAction" }
.filter { it != "com.ichi2.anki.CardBrowserDeepLink" }
// ACRA-specific
.filter { it != "com.ichi2.anki.analytics.AnkiDroidCrashReportDialog" }
.filter { !it.startsWith("androidx") }
.filter { !it.startsWith("org.acra") }
Expand Down
27 changes: 26 additions & 1 deletion AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,13 @@ import android.widget.SpinnerAdapter
import android.widget.TextView
import androidx.core.app.ActivityCompat
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.core.view.children
import androidx.core.view.get
import androidx.core.view.size
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onData
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions
Expand All @@ -49,7 +51,6 @@ import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import anki.search.BrowserRow
import anki.search.BrowserRow.Color
import com.ichi2.anki.CollectionManager
import com.ichi2.anki.CollectionManager.TR
import com.ichi2.anki.CollectionManager.withCol
import com.ichi2.anki.IntentHandler.Companion.grantedStoragePermissions
Expand Down Expand Up @@ -490,6 +491,30 @@ class CardBrowserTest : RobolectricTest() {
}
}

/**
* Sending the `anki://x-callback-url/browser?search=` deep link searches in [CardBrowser].
*/
@Test
fun browserDeepLinkOpensCardBrowserWithSearch() {
ensureCollectionLoadIsSynchronous()
addBasicNote("dog", "barks")
addBasicNote("cat", "meows")

val deepLink =
Intent(targetContext, CardBrowser::class.java).apply {
action = Intent.ACTION_VIEW
data = "anki://x-callback-url/browser?search=dog".toUri()
}

ActivityScenario.launch<CardBrowser>(deepLink).use { scenario ->
advanceRobolectricLooper()
scenario.onActivity { browser ->
assertThat("the deep link's search is applied", browser.viewModel.searchTerms, equalTo("dog"))
assertThat("only the matching note is shown", browser.viewModel.rowCount, equalTo(1))
}
}
}

@Test
fun tagWithBracketsDisplaysProperly() =
runTest {
Expand Down
Loading
Loading