Added zim scheme deep link support.

* Refactored code to retrieve the ZIM file from `LibkiwixBookOnDisk` instead of `NewBookDao`, since `NewBookDao` no longer exists and books are now stored in `Libkiwix`.
* Added a UI test case for this deep link to verify correct behavior.
* Added `testZimHostDeepLink` to specifically test the new compose deep link and help prevent future regressions.
This commit is contained in:
MohitMaliFtechiz 2025-08-21 17:25:54 +05:30 committed by Kelson
parent 7b4afafa2d
commit 268b411f6e
6 changed files with 177 additions and 12 deletions

View File

@ -0,0 +1,71 @@
/*
* Kiwix Android
* Copyright (c) 2025 Kiwix <android.kiwix.org>
* 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/>.
*
*/
package org.kiwix.kiwixmobile.deeplinks
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.test.espresso.web.sugar.Web.onWebView
import androidx.test.espresso.web.webdriver.DriverAtoms.findElement
import androidx.test.espresso.web.webdriver.Locator
import applyWithViewHierarchyPrinting
import org.kiwix.kiwixmobile.BaseRobot
import org.kiwix.kiwixmobile.core.R
import org.kiwix.kiwixmobile.core.main.reader.READER_SCREEN_TESTING_TAG
import org.kiwix.kiwixmobile.core.ui.components.TOOLBAR_TITLE_TESTING_TAG
import org.kiwix.kiwixmobile.testutils.TestUtils.testFlakyView
fun deepLink(func: DeepLinkRobot.() -> Unit) =
DeepLinkRobot().applyWithViewHierarchyPrinting(func)
class DeepLinkRobot : BaseRobot() {
fun checkZimFileLoadedSuccessful(composeTestRule: ComposeContentTestRule) {
testFlakyView({
composeTestRule.apply {
waitForIdle()
onNodeWithTag(READER_SCREEN_TESTING_TAG).assertExists()
}
})
}
fun assertZimFilePageLoaded(composeTestRule: ComposeContentTestRule) {
testFlakyView({
composeTestRule.apply {
waitForIdle()
onWebView()
.withElement(
findElement(
Locator.XPATH,
"//*[contains(text(), 'History')]"
)
)
}
})
}
fun checkZimHostScreenVisible(composeTestRule: ComposeContentTestRule) {
testFlakyView({
composeTestRule.apply {
waitForIdle()
onNodeWithTag(TOOLBAR_TITLE_TESTING_TAG)
.assertTextEquals(context.getString(R.string.menu_wifi_hotspot))
}
})
}
}

View File

@ -27,6 +27,8 @@ import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.accessibility.AccessibilityChecks
import androidx.test.espresso.matcher.ViewMatchers.withContentDescription
@ -44,16 +46,21 @@ import org.junit.Rule
import org.junit.Test
import org.junit.jupiter.api.fail
import org.kiwix.kiwixmobile.BaseActivityTest
import org.kiwix.kiwixmobile.core.main.ZIM_HOST_NAV_DEEP_LINK
import org.kiwix.kiwixmobile.core.utils.LanguageUtils.Companion.handleLocaleChange
import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil
import org.kiwix.kiwixmobile.core.utils.TestingUtils.COMPOSE_TEST_RULE_ORDER
import org.kiwix.kiwixmobile.core.utils.TestingUtils.RETRY_RULE_ORDER
import org.kiwix.kiwixmobile.core.utils.dialog.ALERT_DIALOG_CONFIRM_BUTTON_TESTING_TAG
import org.kiwix.kiwixmobile.main.KiwixMainActivity
import org.kiwix.kiwixmobile.main.OPENING_ZIM_FILE_DELAY
import org.kiwix.kiwixmobile.nav.destination.library.library
import org.kiwix.kiwixmobile.page.history.navigationHistory
import org.kiwix.kiwixmobile.testutils.RetryRule
import org.kiwix.kiwixmobile.testutils.TestUtils
import org.kiwix.kiwixmobile.testutils.TestUtils.TEST_PAUSE_MS_FOR_DOWNLOAD_TEST
import org.kiwix.kiwixmobile.testutils.TestUtils.testFlakyView
import org.kiwix.kiwixmobile.ui.KiwixDestination
import java.io.File
import java.io.FileOutputStream
import java.io.OutputStream
@ -108,7 +115,9 @@ class DeepLinksTest : BaseActivityTest() {
fun fileTypeDeepLinkTest() {
loadZimFileInApplicationAndReturnSchemeTypeUri("file")?.let {
// Launch the activity to test the deep link
ActivityScenario.launch<KiwixMainActivity>(createDeepLinkIntent(it)).onActivity {}
ActivityScenario.launch<KiwixMainActivity>(
createDeepLinkIntent(it, "application/octet-stream")
).onActivity {}
clickOnCopy(composeTestRule)
navigationHistory {
checkZimFileLoadedSuccessful(composeTestRule)
@ -138,7 +147,9 @@ class DeepLinksTest : BaseActivityTest() {
fun contentTypeDeepLinkTest() {
loadZimFileInApplicationAndReturnSchemeTypeUri("content")?.let {
// Launch the activity to test the deep link
ActivityScenario.launch<KiwixMainActivity>(createDeepLinkIntent(it)).onActivity {}
ActivityScenario.launch<KiwixMainActivity>(
createDeepLinkIntent(it, "application/octet-stream")
).onActivity {}
clickOnCopy(composeTestRule)
navigationHistory {
checkZimFileLoadedSuccessful(composeTestRule)
@ -151,6 +162,60 @@ class DeepLinksTest : BaseActivityTest() {
}
}
@Test
fun zimUrlTypeDeepLinkTest() {
activityScenario =
ActivityScenario.launch(KiwixMainActivity::class.java).apply {
moveToState(Lifecycle.State.RESUMED)
onActivity {
handleLocaleChange(
it,
"en",
SharedPreferenceUtil(context)
)
}
}
activityScenario.onActivity {
it.navigate(KiwixDestination.Library.route)
}
library {
refreshList(composeTestRule)
waitUntilZimFilesRefreshing(composeTestRule)
deleteZimIfExists(composeTestRule)
}
loadZimFileInApplicationAndReturnSchemeTypeUri("file")
library {
refreshList(composeTestRule)
waitUntilZimFilesRefreshing(composeTestRule)
}
// it tests the zim deep link e.g. (zim://60094d1e-1c9a-a60b-2011-4fb02f8db6c3/A/Android_(operating_system).html)
ActivityScenario.launch<KiwixMainActivity>(
createDeepLinkIntent("zim://60094d1e-1c9a-a60b-2011-4fb02f8db6c3/A/Android_(operating_system).html".toUri())
).onActivity {}
// for a bit to properly handle the deep link.
composeTestRule.mainClock.advanceTimeBy(OPENING_ZIM_FILE_DELAY + 500)
composeTestRule.waitForIdle()
deepLink {
checkZimFileLoadedSuccessful(composeTestRule)
assertZimFilePageLoaded(composeTestRule)
}
}
@Test
fun testZimHostDeepLink() {
// For testing the deep link triggers when user click on notification of the hotspot.
// it should open the WIFI-Hotspot screen.
ActivityScenario.launch<KiwixMainActivity>(
createDeepLinkIntent(ZIM_HOST_NAV_DEEP_LINK.toUri())
).onActivity {}
// for a bit to properly handle the deep link.
composeTestRule.mainClock.advanceTimeBy(OPENING_ZIM_FILE_DELAY + 500)
composeTestRule.waitForIdle()
deepLink {
checkZimHostScreenVisible(composeTestRule)
}
}
private fun loadZimFileInApplicationAndReturnSchemeTypeUri(schemeType: String): Uri? {
val loadFileStream =
DeepLinksTest::class.java.classLoader.getResourceAsStream("testzim.zim")
@ -180,14 +245,16 @@ class DeepLinksTest : BaseActivityTest() {
}
}
private fun createDeepLinkIntent(uri: Uri): Intent {
val intent =
Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/octet-stream")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
setPackage(context.packageName)
}
return intent
private fun createDeepLinkIntent(
uri: Uri,
mimeType: String? = null
): Intent {
return Intent(Intent.ACTION_VIEW).apply {
data = uri
mimeType?.let { setDataAndType(uri, it) }
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
setPackage(context.packageName)
}
}
@After

View File

@ -141,6 +141,14 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="kiwix" android:host="zimhost" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="zim" />
</intent-filter>
</activity>
<receiver

View File

@ -83,6 +83,7 @@ import org.kiwix.kiwixmobile.core.main.PAGE_URL_KEY
import org.kiwix.kiwixmobile.core.main.SHOULD_OPEN_IN_NEW_TAB
import org.kiwix.kiwixmobile.core.main.ZIM_FILE_URI_KEY
import org.kiwix.kiwixmobile.core.main.ZIM_HOST_DEEP_LINK_SCHEME
import org.kiwix.kiwixmobile.core.reader.ZimFileReader.Companion.CONTENT_PREFIX
import org.kiwix.kiwixmobile.core.reader.ZimReaderSource
import org.kiwix.kiwixmobile.core.utils.LanguageUtils.Companion.handleLocaleChange
import org.kiwix.kiwixmobile.core.utils.dialog.DialogHost
@ -309,6 +310,21 @@ class KiwixMainActivity : CoreMainActivity() {
}, OPENING_ZIM_FILE_DELAY)
}
"zim" -> {
val zimId = it.host
val page = it.encodedPath?.removePrefix("/")
if (zimId.isNullOrEmpty() || page.isNullOrEmpty()) {
return toast(R.string.cannot_open_file)
}
lifecycleScope.launch {
delay(OPENING_ZIM_FILE_DELAY)
val book = libkiwixBookOnDisk.bookById(zimId)
?: return@launch toast(R.string.cannot_open_file)
openPage("$CONTENT_PREFIX$page", book.zimReaderSource)
clearIntentDataAndAction()
}
}
else -> {
if (it.scheme != ZIM_HOST_DEEP_LINK_SCHEME) {
toast(R.string.cannot_open_file)

View File

@ -228,6 +228,9 @@ class LibkiwixBookOnDisk @Inject constructor(
it.zimReaderSource.toDatabase().endsWith(downloadTitle, true)
}
suspend fun bookById(bookId: String) =
getBooks().firstOrNull { it.book.id == bookId }
/**
* Asynchronously writes the library data to their respective file in a background thread
* to prevent potential data loss and ensures that the library holds the updated ZIM file data.

View File

@ -1869,7 +1869,7 @@ abstract class CoreReaderFragment :
}
private fun startIntentBasedOnAction(intent: Intent?) {
Log.d(TAG_KIWIX, "action" + requireActivity().intent?.action)
Log.d(TAG_KIWIX, "action: ${requireActivity().intent?.action}")
when (intent?.action) {
Intent.ACTION_PROCESS_TEXT -> {
goToSearchWithText(intent)
@ -1903,7 +1903,7 @@ abstract class CoreReaderFragment :
// Added condition to handle ZIM files. When opening from storage, the intent may
// return null for the type, triggering the search unintentionally. This condition
// prevents such occurrences.
intent.scheme !in listOf("file", "content", ZIM_HOST_DEEP_LINK_SCHEME)
intent.scheme !in listOf("file", "content", "zim", ZIM_HOST_DEEP_LINK_SCHEME)
) {
val searchString = if (intent.data == null) "" else intent.data?.lastPathSegment
openSearch(