diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/CardBrowserDeepLinkTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/CardBrowserDeepLinkTest.kt new file mode 100644 index 000000000000..dfee086984f3 --- /dev/null +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/CardBrowserDeepLinkTest.kt @@ -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 { + 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() + 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 waitForActivity( + timeout: Duration = 10.seconds, + crossinline block: T.() -> Unit, + ) { + val resolved = awaitResumed(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 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}") + } +} diff --git a/AnkiDroid/src/main/AndroidManifest.xml b/AnkiDroid/src/main/AndroidManifest.xml index ccd5e56ca826..0d368efeb4ec 100644 --- a/AnkiDroid/src/main/AndroidManifest.xml +++ b/AnkiDroid/src/main/AndroidManifest.xml @@ -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" - > - + /> + + + - - + 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) @@ -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, @@ -328,6 +344,9 @@ class IntentHandler : AbstractIntentHandler() { SYNC, REVIEW, + + /** `anki://x-callback-url/browser` deep link */ + OPEN_BROWSER, COPY_DEBUG_INFO, } @@ -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 @@ -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 } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserLaunchOptions.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserLaunchOptions.kt index f92b1c3be9cb..29388e855e86 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserLaunchOptions.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserLaunchOptions.kt @@ -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 -> diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt index 18870e6c1b66..7813a3160229 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt @@ -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" diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EmptyCardsDialogFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EmptyCardsDialogFragment.kt index 2524604520f6..a205e73d1c36 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EmptyCardsDialogFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EmptyCardsDialogFragment.kt @@ -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 @@ -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) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsFragment.kt index f8b2c6afa2ed..4ca3f8bdfab6 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsFragment.kt @@ -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 @@ -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() } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ActivityStartupMetaTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ActivityStartupMetaTest.kt index 2e47ef8f4a97..be5724886bbf 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ActivityStartupMetaTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ActivityStartupMetaTest.kt @@ -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") } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt index a423b7e9dec6..6e3e5d44e5aa 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt @@ -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 @@ -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 @@ -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(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 { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ExternalEntryPointsTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ExternalEntryPointsTest.kt new file mode 100644 index 000000000000..cbbf7af1d836 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ExternalEntryPointsTest.kt @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.testutils.ExternalEntryPoints +import com.ichi2.testutils.ExternalEntryPoints.EntryPoint +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.containsInAnyOrder +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Pins the set of externally-reachable entry points (see [ExternalEntryPoints]) so the + * storage-decision work has a known, complete surface to test against. + * + * If you added an exported component, ensure you handle when storage is not yet configured. + */ +@RunWith(AndroidJUnit4::class) +class ExternalEntryPointsTest : RobolectricTest() { + @Test + fun entryPointsMatchKnownSet() { + assertThat( + ExternalEntryPoints.all(targetContext), + containsInAnyOrder(*EXPECTED.toTypedArray()), + ) + } + + companion object { + /** + * All externally-reachable entry points, consumed by per-scenario tests. + */ + internal val EXPECTED = + listOf( + // Activities reached via the launcher, deep links, share/import, PROCESS_TEXT or shortcuts + EntryPoint.Activity("com.ichi2.anki.IntentHandler"), + EntryPoint.Activity("com.ichi2.anki.IntentHandler2"), + EntryPoint.Activity("com.ichi2.anki.Reviewer"), + EntryPoint.ActivityAlias("com.ichi2.anki.AnkiCardContextMenuAction", "com.ichi2.anki.IntentHandler2"), + EntryPoint.ActivityAlias("com.ichi2.anki.CardBrowserDeepLink", "com.ichi2.anki.IntentHandler"), + EntryPoint.Activity("com.ichi2.anki.instantnoteeditor.InstantNoteEditorActivity"), + EntryPoint.Activity("com.ichi2.anki.ui.windows.managespace.ManageSpaceActivity"), + EntryPoint.Activity("com.ichi2.anki.ui.windows.permissions.AllPermissionsExplanationActivity"), + EntryPoint.WidgetConfig("com.ichi2.widget.deckpicker.DeckPickerWidgetConfig"), + EntryPoint.WidgetConfig("com.ichi2.widget.cardanalysis.CardAnalysisWidgetConfig"), + // Headless: reachable with no UI (third-party API, system broadcasts, widget updates) + EntryPoint.Provider("com.ichi2.anki.provider.CardContentProvider"), + EntryPoint.Receiver("com.ichi2.anki.receiver.SdCardReceiver"), + EntryPoint.Receiver("com.ichi2.widget.AddNoteWidget"), + EntryPoint.Receiver("com.ichi2.widget.AnkiDroidWidgetSmall"), + ) + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ExternalEntryPointsUndecidedStorageTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ExternalEntryPointsUndecidedStorageTest.kt new file mode 100644 index 000000000000..91b688971d88 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ExternalEntryPointsUndecidedStorageTest.kt @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki + +import android.app.Activity +import android.appwidget.AppWidgetManager +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.ContentProvider +import android.content.Intent +import android.os.Build +import androidx.core.net.toUri +import com.ichi2.anki.instantnoteeditor.InstantNoteEditorActivity +import com.ichi2.anki.provider.CardContentProvider +import com.ichi2.anki.storage.StorageDecision +import com.ichi2.testutils.ExternalEntryPoints.EntryPoint +import com.ichi2.testutils.grantPermissions +import com.ichi2.testutils.skipTest +import com.ichi2.widget.cardanalysis.CardAnalysisWidgetConfig +import com.ichi2.widget.deckpicker.DeckPickerWidgetConfig +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.Robolectric +import org.robolectric.Shadows.shadowOf +import org.robolectric.shadows.ShadowMediaPlayer +import kotlin.test.fail + +/** + * Handles the [CollectionHelper.storageDecision] returning [StorageDecision.Undecided]. + * + * Entry points which do not yet handle the scenario are skipped: see [notYetHandledEntryPoints]. + */ +@RunWith(ParameterizedRobolectricTestRunner::class) +class ExternalEntryPointsUndecidedStorageTest : RobolectricTest() { + @ParameterizedRobolectricTestRunner.Parameter + @JvmField // required for Parameter + var entryPoint: EntryPoint? = null + + // Only used for display, but needs to be defined + @ParameterizedRobolectricTestRunner.Parameter(1) + @JvmField // required for Parameter + @Suppress("unused") + var entryPointName: String? = null + + @Before + fun setStorageUndecided() { + CollectionHelper.storageDecisionTestOverride = StorageDecision.Undecided + } + + @After + fun resetStorageDecision() { + CollectionHelper.storageDecisionTestOverride = null + } + + @Before + fun notYetHandledEntryPoints() { + notYetHandled("setupFlags (onCreateOptionsMenu) reads flag names from the collection; the failure is unhandled") + notYetHandled("InstantEditorViewModel opens the collection when constructed; the failure is unhandled") + notYetHandled("onCreate calls isCollectionEmpty(); the failure is unhandled") + notYetHandled("onCreate calls isCollectionEmpty(); shows an error dialog, not the setup flow") + notYetHandled("query() opens the collection; the exception escapes over Binder, killing the process") + } + + private inline fun notYetHandled(reason: String) { + if (entryPoint!!.className == T::class.qualifiedName) { + skipTest("${T::class.simpleName} does not yet handle undecided storage: $reason") + } + } + + @Test + fun `startup is crash-free when storage is undecided`() { + when (val entryPoint = entryPoint!!) { + is EntryPoint.Activity -> launchActivity(entryPoint.className) + is EntryPoint.ActivityAlias -> launchActivity(entryPoint.targetActivity) + is EntryPoint.WidgetConfig -> launchActivity(entryPoint.className) + is EntryPoint.WidgetConfigAlias -> launchActivity(entryPoint.targetActivity) + is EntryPoint.Receiver -> sendBroadcast(entryPoint.className) + is EntryPoint.Provider -> queryContentProvider(entryPoint.className) + is EntryPoint.Service -> fail("define how to drive a Service entry point: $entryPoint") + } + advanceRobolectricLooper() + } + + /** + * The intent a component receives when triggered externally, based on its manifest + * `` (keyed on the component: aliases declare their own filters). + */ + private fun EntryPoint.externalIntent(): Intent = + when (className) { + // launcher icon tap + "com.ichi2.anki.IntentHandler" -> Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) + // share text with AnkiDroid + "com.ichi2.anki.IntentHandler2", "com.ichi2.anki.instantnoteeditor.InstantNoteEditorActivity" -> + Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, "dog") + "com.ichi2.anki.Reviewer" -> Intent(Intent.ACTION_VIEW) + // 'Anki Card' text selection menu entry + "com.ichi2.anki.AnkiCardContextMenuAction" -> + Intent(Intent.ACTION_PROCESS_TEXT).setType("text/plain").putExtra(Intent.EXTRA_PROCESS_TEXT, "dog") + "com.ichi2.anki.CardBrowserDeepLink" -> Intent(Intent.ACTION_VIEW, "anki://x-callback-url/browser?search=dog".toUri()) + // launched by the system's 'Manage space' settings button, with no action or extras + "com.ichi2.anki.ui.windows.managespace.ManageSpaceActivity" -> Intent() + // 'more info' button in the OS permission settings + "com.ichi2.anki.ui.windows.permissions.AllPermissionsExplanationActivity" -> + if (Build.VERSION.SDK_INT >= + Build.VERSION_CODES.Q + ) { + Intent(Intent.ACTION_VIEW_PERMISSION_USAGE) + } else { + TODO("VERSION.SDK_INT < Q") + } + // the widget host configures a newly added widget + "com.ichi2.widget.deckpicker.DeckPickerWidgetConfig", + "com.ichi2.widget.cardanalysis.CardAnalysisWidgetConfig", + -> + Intent(AppWidgetManager.ACTION_APPWIDGET_CONFIGURE).putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, 1) + "com.ichi2.anki.receiver.SdCardReceiver" -> Intent(Intent.ACTION_MEDIA_EJECT, "file:///storage/emulated/0".toUri()) + // the widget host redraws the widgets + "com.ichi2.widget.AddNoteWidget", "com.ichi2.widget.AnkiDroidWidgetSmall" -> + Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE).putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(1)) + else -> fail("define the externally-sent intent for new entry point: $this") + } + + private fun launchActivity(targetClassName: String) { + val activityClass = Class.forName(targetClassName).asSubclass(Activity::class.java) + if (AbstractFlashcardViewer::class.java.isAssignableFrom(activityClass)) { + // fixes 'Don't know what to do with dataSource...' inside Sounds.kt + ShadowMediaPlayer.setMediaInfoProvider { ShadowMediaPlayer.MediaInfo(1, 0) } + } + val controller = Robolectric.buildActivity(activityClass, entryPoint!!.externalIntent()) + saveControllerForCleanup(controller) + controller.setup() + } + + private fun sendBroadcast(className: String) { + val receiverClass = Class.forName(className).asSubclass(BroadcastReceiver::class.java) + val intent = entryPoint!!.externalIntent() + // the host only requests an update for widgets it has bound + intent.getIntArrayExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS)?.forEach { appWidgetId -> + shadowOf(AppWidgetManager.getInstance(targetContext)) + .bindAppWidgetId(appWidgetId, ComponentName(targetContext, receiverClass)) + } + receiverClass.getDeclaredConstructor().newInstance().onReceive(targetContext, intent) + } + + private fun queryContentProvider(className: String) { + grantPermissions(FlashCardsContract.READ_WRITE_PERMISSION) + val providerClass = Class.forName(className).asSubclass(ContentProvider::class.java) + val provider = + Robolectric + .buildContentProvider(providerClass) + .create(FlashCardsContract.AUTHORITY) + .get() + provider.query(FlashCardsContract.Note.CONTENT_URI, null, null, null, null)?.close() + } + + companion object { + @ParameterizedRobolectricTestRunner.Parameters(name = "{1}") + @JvmStatic // required for initParameters + fun initParameters(): Collection> = ExternalEntryPointsTest.EXPECTED.map { arrayOf(it, it.toString()) } + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/IntentHandlerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/IntentHandlerTest.kt index 936a159a419c..09d5f0e03441 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/IntentHandlerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/IntentHandlerTest.kt @@ -63,6 +63,17 @@ class IntentHandlerTest { assertThat(expected, equalTo(LaunchType.REVIEW)) } + @Test + fun browserDeepLinkReturnsOpenBrowser() { + // `anki://x-callback-url/browser` is routed through IntentHandler (via a manifest alias) so it + // passes the storage-decision gate before CardBrowser is opened. + val intent = Intent(Intent.ACTION_VIEW, "anki://x-callback-url/browser?search=dog".toUri()) + + val expected = getLaunchType(intent) + + assertThat(expected, equalTo(LaunchType.OPEN_BROWSER)) + } + @Test fun mainIntentStartsApp() { val intent = Intent(Intent.ACTION_MAIN) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserLaunchOptionsTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserLaunchOptionsTest.kt new file mode 100644 index 000000000000..b15b001cfd07 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserLaunchOptionsTest.kt @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.browser + +import android.content.Intent +import androidx.core.net.toUri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.browser.CardBrowserLaunchOptions.DeepLink +import com.ichi2.anki.browser.CardBrowserLaunchOptions.ScrollToCard +import com.ichi2.anki.browser.CardBrowserLaunchOptions.SearchQueryJs +import com.ichi2.anki.browser.CardBrowserLaunchOptions.SystemContextMenu +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * Tests [toCardBrowserLaunchOptions]: how an incoming [Intent] is mapped to the way + * [com.ichi2.anki.CardBrowser] should open. + * + * @see CardBrowserViewModelTest - option handling. + */ +@RunWith(AndroidJUnit4::class) +class CardBrowserLaunchOptionsTest { + @Test + fun `deep link uses the search query parameter`() { + val intent = Intent(Intent.ACTION_VIEW, "anki://x-callback-url/browser?search=dog".toUri()) + + assertEquals(DeepLink("dog"), intent.toCardBrowserLaunchOptions()) + } + + @Test + fun `search_query extra maps to SearchQueryJs`() { + val intent = + Intent().apply { + putExtra(CardBrowserViewModel.EXTRA_SEARCH_QUERY, "dog") + putExtra(CardBrowserViewModel.EXTRA_ALL_DECKS, true) + } + + assertEquals(SearchQueryJs("dog", allDecks = true), intent.toCardBrowserLaunchOptions()) + } + + @Test + fun `card id extra maps to ScrollToCard`() { + val intent = Intent().putExtra(CardBrowserViewModel.EXTRA_CARD_ID_KEY, 123L) + + assertEquals(ScrollToCard(123L), intent.toCardBrowserLaunchOptions()) + } + + @Test + fun `process text maps to SystemContextMenu`() { + val intent = + Intent(Intent.ACTION_PROCESS_TEXT) + .putExtra(Intent.EXTRA_PROCESS_TEXT, "dog") + + assertEquals(SystemContextMenu("dog"), intent.toCardBrowserLaunchOptions()) + } + + @Test + fun `empty intent has no launch options`() { + assertNull(Intent().toCardBrowserLaunchOptions()) + } + + @Test + fun `empty process text has no launch options`() { + assertNull(Intent(Intent.ACTION_PROCESS_TEXT).toCardBrowserLaunchOptions()) + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/testutils/ExternalEntryPoints.kt b/AnkiDroid/src/test/java/com/ichi2/testutils/ExternalEntryPoints.kt new file mode 100644 index 000000000000..2ed36b496d7c --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/testutils/ExternalEntryPoints.kt @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.testutils + +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import android.content.pm.ActivityInfo +import android.content.pm.ComponentInfo +import androidx.annotation.CheckResult +import com.ichi2.anki.compat.CompatHelper.Companion.getPackageInfoCompat +import com.ichi2.anki.compat.CompatHelper.Companion.queryIntentActivitiesCompat +import com.ichi2.anki.compat.GET_ACTIVITIES_L +import com.ichi2.anki.compat.GET_PROVIDERS_L +import com.ichi2.anki.compat.GET_RECEIVERS_L +import com.ichi2.anki.compat.GET_SERVICES_L +import com.ichi2.anki.compat.MATCH_ALL_L +import com.ichi2.anki.compat.PackageInfoFlagsCompat +import com.ichi2.anki.compat.ResolveInfoFlagsCompat + +/** + * The set of AnkiDroid components which can be triggered from **outside** a running AnkiDroid + * process: by the launcher, another app, the widget host, or the system. + * + * This exists so we can ensure a consistent & crash-free startup experience. + * + * @see com.ichi2.anki.ActivityStartupMetaTest for the equivalent activity-only coverage guard + */ +object ExternalEntryPoints { + /** + * A single externally-reachable manifest component, by declared kind. + */ + sealed class EntryPoint { + abstract val className: String + + data class Activity( + override val className: String, + ) : EntryPoint() + + /** + * An ``: reachable like an [Activity] but backed by no class of its own. + * + * @param className the name of the component + * @param targetActivity the name of the class of the activity to launch + */ + data class ActivityAlias( + override val className: String, + val targetActivity: String, + ) : EntryPoint() + + data class Service( + override val className: String, + ) : EntryPoint() + + data class Receiver( + override val className: String, + ) : EntryPoint() + + data class Provider( + override val className: String, + ) : EntryPoint() + + /** + * A widget-configuration activity, launched by the widget host via + * [AppWidgetManager.ACTION_APPWIDGET_CONFIGURE] even though it is not `exported`. + */ + data class WidgetConfig( + override val className: String, + ) : EntryPoint() + + /** + * A [WidgetConfig] declared as an `` forwarding to [targetActivity]. + * + * @param className the name of the component + * @param targetActivity the name of the class of the activity to launch + */ + data class WidgetConfigAlias( + override val className: String, + val targetActivity: String, + ) : EntryPoint() + + final override fun toString() = "${this::class.simpleName} $className" + } + + /** + * Every AnkiDroid-owned component (activity, service etc...) reachable from outside the app. + * + * See [EntryPoint] for component types. + * + * Excludes components included by libraries. + */ + @CheckResult + fun all(context: Context): List { + val flags = PackageInfoFlagsCompat.of(GET_ACTIVITIES_L or GET_PROVIDERS_L or GET_RECEIVERS_L or GET_SERVICES_L) + val packageInfo = + context.getPackageInfoCompat(context.packageName, flags) + ?: error("getPackageInfo failed") + + return buildList { + fun addExported( + components: Array?, + buildEntryPoint: (String) -> EntryPoint, + ) = components + .orEmpty() + .filter { it.exported && isAnkiDroid(it.name) } + .forEach { add(buildEntryPoint(it.name)) } + + packageInfo.activities + .orEmpty() + .filter { it.exported && isAnkiDroid(it.name) } + .forEach { + add( + it.toEntryPoint( + alias = EntryPoint::ActivityAlias, + activity = EntryPoint::Activity, + ), + ) + } + + addExported(packageInfo.services, EntryPoint::Service) + addExported(packageInfo.receivers, EntryPoint::Receiver) + addExported(packageInfo.providers, EntryPoint::Provider) + + // '`), otherwise [activity]. + */ + private fun ActivityInfo.toEntryPoint( + alias: (className: String, targetActivity: String) -> EntryPoint, + activity: (className: String) -> EntryPoint, + ): EntryPoint = targetActivity?.let { alias(name, it) } ?: activity(name) +}