diff --git a/app/src/androidTest/java/org/kiwix/kiwixmobile/utils/UnsupportedMimeTypeHandlerTest.kt b/app/src/androidTest/java/org/kiwix/kiwixmobile/utils/UnsupportedMimeTypeHandlerTest.kt new file mode 100644 index 000000000..939481c38 --- /dev/null +++ b/app/src/androidTest/java/org/kiwix/kiwixmobile/utils/UnsupportedMimeTypeHandlerTest.kt @@ -0,0 +1,201 @@ +/* + * Kiwix Android + * Copyright (c) 2024 Kiwix + * 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 . + * + */ + +package org.kiwix.kiwixmobile.utils + +import android.app.Activity +import android.webkit.WebResourceResponse +import android.widget.Toast +import io.mockk.every +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.verify +import org.junit.Before +import org.junit.jupiter.api.Test +import org.kiwix.kiwixmobile.core.R +import org.kiwix.kiwixmobile.core.extensions.toast +import org.kiwix.kiwixmobile.core.reader.ZimReaderContainer +import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil +import org.kiwix.kiwixmobile.core.utils.dialog.AlertDialogShower +import org.kiwix.kiwixmobile.core.utils.dialog.UnsupportedMimeTypeHandler +import org.kiwix.kiwixmobile.core.utils.dialog.KiwixDialog +import java.io.File +import java.io.InputStream + +class UnsupportedMimeTypeHandlerTest { + private val demoUrl = "content://demoPdf.pdf" + private val demoFileName = "demoPdf.pdf" + private val sharedPreferenceUtil: SharedPreferenceUtil = mockk() + private val zimReaderContainer: ZimReaderContainer = mockk() + private val alertDialogShower: AlertDialogShower = mockk(relaxed = true) + private val savedFile: File = mockk(relaxed = true) + private val activity: Activity = mockk() + private val webResourceResponse: WebResourceResponse = mockk() + private val inputStream: InputStream = mockk() + private val unsupportedMimeTypeHandler = UnsupportedMimeTypeHandler( + activity, + sharedPreferenceUtil, + alertDialogShower, + zimReaderContainer + ) + + @Before + fun before() { + every { savedFile.name } returns demoFileName + every { sharedPreferenceUtil.isPlayStoreBuildWithAndroid11OrAbove() } returns true + every { inputStream.read(array()) } returns 1024 + every { webResourceResponse.data } returns inputStream + every { + zimReaderContainer.load( + demoUrl, + emptyMap() + ) + } returns webResourceResponse + every { activity.packageManager } returns mockk() + every { activity.packageName } returns "org.kiwix.kiwixmobile" + every { + activity.getString( + R.string.save_media_saved, + demoFileName + ) + } returns "Saved media as $demoFileName to Downloads/org.kiwix…/" + every { savedFile.path } returns "Emulated/0/Downloads/$demoFileName" + every { savedFile.exists() } returns true + unsupportedMimeTypeHandler.intent = mockk() + every { unsupportedMimeTypeHandler.intent.setDataAndType(any(), any()) } returns mockk() + every { unsupportedMimeTypeHandler.intent.setFlags(any()) } returns mockk() + every { unsupportedMimeTypeHandler.intent.addFlags(any()) } returns mockk() + } + + @Test + fun testOpeningFileInExternalReaderApplication() { + every { + unsupportedMimeTypeHandler.intent.resolveActivity(activity.packageManager) + } returns mockk() + every { activity.startActivity(unsupportedMimeTypeHandler.intent) } returns mockk() + val lambdaSlot = slot<() -> Unit>() + unsupportedMimeTypeHandler.showSaveOrOpenUnsupportedFilesDialog(demoUrl, "application/pdf") + verify { + alertDialogShower.show( + KiwixDialog.SaveOrOpenUnsupportedFiles, + capture(lambdaSlot), + any(), + any() + ) + } + lambdaSlot.captured.invoke() + verify { + activity.startActivity(unsupportedMimeTypeHandler.intent) + } + } + + @Test + fun testOpeningFileWhenNoReaderApplicationInstalled() { + every { + unsupportedMimeTypeHandler.intent.resolveActivity(activity.packageManager) + } returns null + mockkStatic(Toast::class) + justRun { + Toast.makeText(activity, R.string.no_reader_application_installed, Toast.LENGTH_LONG).show() + } + val lambdaSlot = slot<() -> Unit>() + unsupportedMimeTypeHandler.showSaveOrOpenUnsupportedFilesDialog(demoUrl, "application/pdf") + verify { + alertDialogShower.show( + KiwixDialog.SaveOrOpenUnsupportedFiles, + capture(lambdaSlot), + any(), + any() + ) + } + lambdaSlot.captured.invoke() + verify { activity.toast(R.string.no_reader_application_installed) } + } + + @Test + fun testFileSavedSuccessfully() { + val toastMessage = activity.getString(R.string.save_media_saved, savedFile.name) + mockkStatic(Toast::class) + justRun { + Toast.makeText( + activity, + toastMessage, + Toast.LENGTH_LONG + ).show() + } + val lambdaSlot = slot<() -> Unit>() + unsupportedMimeTypeHandler.showSaveOrOpenUnsupportedFilesDialog( + demoUrl, + "application/pdf" + ) + verify { + alertDialogShower.show( + KiwixDialog.SaveOrOpenUnsupportedFiles, + any(), + capture(lambdaSlot), + any() + ) + } + lambdaSlot.captured.invoke() + verify { activity.toast(toastMessage) } + } + + @Test + fun testUserClicksOnNoThanksButton() { + val lambdaSlot = slot<() -> Unit>() + unsupportedMimeTypeHandler.showSaveOrOpenUnsupportedFilesDialog(demoUrl, "application/pdf") + verify { + alertDialogShower.show( + KiwixDialog.SaveOrOpenUnsupportedFiles, + any(), + any(), + capture(lambdaSlot) + ) + } + lambdaSlot.captured.invoke() + verify(exactly = 0) { activity.startActivity(any()) } + } + + @Test + fun testIfSavingFailed() { + val downloadOrOpenEpubAndPdfHandler = UnsupportedMimeTypeHandler( + activity, + sharedPreferenceUtil, + alertDialogShower, + zimReaderContainer + ) + mockkStatic(Toast::class) + justRun { + Toast.makeText(activity, R.string.save_media_error, Toast.LENGTH_LONG).show() + } + val lambdaSlot = slot<() -> Unit>() + downloadOrOpenEpubAndPdfHandler.showSaveOrOpenUnsupportedFilesDialog(null, "application/pdf") + verify { + alertDialogShower.show( + KiwixDialog.SaveOrOpenUnsupportedFiles, + any(), + capture(lambdaSlot), + any() + ) + } + lambdaSlot.captured.invoke() + verify { activity.toast(R.string.save_media_error) } + } +} diff --git a/app/src/androidTest/java/org/kiwix/kiwixmobile/utils/files/FileUtilsInstrumentationTest.kt b/app/src/androidTest/java/org/kiwix/kiwixmobile/utils/files/FileUtilsInstrumentationTest.kt index 2079286f5..b2a5fc3c1 100644 --- a/app/src/androidTest/java/org/kiwix/kiwixmobile/utils/files/FileUtilsInstrumentationTest.kt +++ b/app/src/androidTest/java/org/kiwix/kiwixmobile/utils/files/FileUtilsInstrumentationTest.kt @@ -260,6 +260,14 @@ class FileUtilsInstrumentationTest { DummyUrlData( "https://kiwix.org/contributors/images/wikipedia", null + ), + DummyUrlData( + "https://kiwix.org/contributors/images/wikipedia:hello.epub", + "wikipediahello.epub" + ), + DummyUrlData( + "https://kiwix.org/contributors/Y Gododin: A Poem of the Battle: of Cattraeth.9842.epub", + "Y Gododin A Poem of the Battle of Cattraeth.9842.epub" ) ) dummyUrlArray.forEach { diff --git a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/KiwixReaderFragment.kt b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/KiwixReaderFragment.kt index 8beda678a..59d8190af 100644 --- a/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/KiwixReaderFragment.kt +++ b/app/src/main/java/org/kiwix/kiwixmobile/nav/destination/reader/KiwixReaderFragment.kt @@ -243,7 +243,7 @@ class KiwixReaderFragment : CoreReaderFragment() { override fun createWebView(attrs: AttributeSet?): ToolbarScrollingKiwixWebView { return ToolbarScrollingKiwixWebView( requireContext(), this, attrs!!, activityMainRoot as ViewGroup, videoView!!, - CoreWebViewClient(this, zimReaderContainer!!, sharedPreferenceUtil!!), + CoreWebViewClient(this, zimReaderContainer!!), toolbarContainer!!, bottomToolbar!!, sharedPreferenceUtil = sharedPreferenceUtil!!, parentNavigationBar = requireActivity().findViewById(R.id.bottom_nav_view) ) diff --git a/core/src/main/AndroidManifest.xml b/core/src/main/AndroidManifest.xml index 1f8379679..0c0ce3e85 100644 --- a/core/src/main/AndroidManifest.xml +++ b/core/src/main/AndroidManifest.xml @@ -39,6 +39,12 @@ + + + + + + diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/main/CoreReaderFragment.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/main/CoreReaderFragment.kt index 8d43a36da..11249f14a 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/main/CoreReaderFragment.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/main/CoreReaderFragment.kt @@ -156,6 +156,7 @@ import org.kiwix.kiwixmobile.core.utils.TAG_FILE_SEARCHED_NEW_TAB import org.kiwix.kiwixmobile.core.utils.TAG_KIWIX import org.kiwix.kiwixmobile.core.utils.UpdateUtils.reformatProviderUrl import org.kiwix.kiwixmobile.core.utils.dialog.DialogShower +import org.kiwix.kiwixmobile.core.utils.dialog.UnsupportedMimeTypeHandler import org.kiwix.kiwixmobile.core.utils.dialog.KiwixDialog import org.kiwix.kiwixmobile.core.utils.files.FileUtils.deleteCachedFiles import org.kiwix.kiwixmobile.core.utils.files.FileUtils.readFile @@ -321,6 +322,10 @@ abstract class CoreReaderFragment : @JvmField @Inject var externalLinkOpener: ExternalLinkOpener? = null + + @JvmField + @Inject + var unsupportedMimeTypeHandler: UnsupportedMimeTypeHandler? = null private var hideBackToTopTimer: CountDownTimer? = null private var documentSections: MutableList? = null private var isBackToTopEnabled = false @@ -1214,7 +1219,7 @@ abstract class CoreReaderFragment : return if (activityMainRoot != null) { ToolbarScrollingKiwixWebView( requireActivity(), this, attrs!!, (activityMainRoot as ViewGroup?)!!, videoView!!, - CoreWebViewClient(this, zimReaderContainer!!, sharedPreferenceUtil!!), + CoreWebViewClient(this, zimReaderContainer!!), toolbarContainer!!, bottomToolbar!!, sharedPreferenceUtil!! ) @@ -1542,6 +1547,10 @@ abstract class CoreReaderFragment : externalLinkOpener?.openExternalUrl(intent) } + override fun showSaveOrOpenUnsupportedFilesDialog(url: String, documentType: String?) { + unsupportedMimeTypeHandler?.showSaveOrOpenUnsupportedFilesDialog(url, documentType) + } + protected fun openZimFile( file: File?, isCustomApp: Boolean = false, diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/main/CoreWebViewClient.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/main/CoreWebViewClient.kt index cb4ceaf95..25b8ed045 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/main/CoreWebViewClient.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/main/CoreWebViewClient.kt @@ -17,7 +17,6 @@ */ package org.kiwix.kiwixmobile.core.main -import android.content.Context import android.content.Intent import android.net.Uri import org.kiwix.kiwixmobile.core.utils.files.Log @@ -27,18 +26,14 @@ import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebView import android.webkit.WebViewClient -import androidx.core.content.FileProvider import org.kiwix.kiwixmobile.core.CoreApp.Companion.instance import org.kiwix.kiwixmobile.core.reader.ZimFileReader import org.kiwix.kiwixmobile.core.reader.ZimReaderContainer -import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil import org.kiwix.kiwixmobile.core.utils.TAG_KIWIX -import org.kiwix.kiwixmobile.core.utils.files.FileUtils.downloadFileFromUrl open class CoreWebViewClient( protected val callback: WebViewCallback, - protected val zimReaderContainer: ZimReaderContainer, - private val sharedPreferenceUtil: SharedPreferenceUtil + protected val zimReaderContainer: ZimReaderContainer ) : WebViewClient() { private var urlWithAnchor: String? = null @@ -49,14 +44,14 @@ open class CoreWebViewClient( url = convertLegacyUrl(url) urlWithAnchor = if (url.contains("#")) url else null if (zimReaderContainer.isRedirect(url)) { - if (handleEpubAndPdf(url)) { + if (handleUnsupportedFiles(url)) { return true } view.loadUrl(zimReaderContainer.getRedirect(url)) return true } if (url.startsWith(ZimFileReader.CONTENT_PREFIX)) { - return handleEpubAndPdf(url) + return handleUnsupportedFiles(url) } if (url.startsWith("javascript:")) { // Allow javascript for HTML functions and code execution (EX: night mode) @@ -82,29 +77,10 @@ open class CoreWebViewClient( } @Suppress("NestedBlockDepth") - private fun handleEpubAndPdf(url: String): Boolean { + private fun handleUnsupportedFiles(url: String): Boolean { val extension = MimeTypeMap.getFileExtensionFromUrl(url) if (DOCUMENT_TYPES.containsKey(extension)) { - downloadFileFromUrl( - url, - null, - zimReaderContainer, - sharedPreferenceUtil - )?.let { - if (it.exists()) { - val context: Context = instance - val uri = FileProvider.getUriForFile( - context, - context.packageName + ".fileprovider", it - ) - val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(uri, DOCUMENT_TYPES[extension]) - flags = Intent.FLAG_ACTIVITY_NO_HISTORY - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - callback.openExternalUrl(intent) - } - } + callback.showSaveOrOpenUnsupportedFilesDialog(url, DOCUMENT_TYPES[extension]) return true } return false diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/main/WebViewCallback.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/main/WebViewCallback.kt index d15eca03a..25c890250 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/main/WebViewCallback.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/main/WebViewCallback.kt @@ -25,6 +25,7 @@ interface WebViewCallback { fun webViewUrlFinishedLoading() fun webViewFailedLoading(failingUrl: String) fun openExternalUrl(intent: Intent) + fun showSaveOrOpenUnsupportedFilesDialog(url: String, documentType: String?) fun webViewProgressChanged(progress: Int, webView: WebView) fun webViewTitleUpdated(title: String) fun webViewPageChanged(page: Int, maxPages: Int) diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/utils/dialog/KiwixDialog.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/utils/dialog/KiwixDialog.kt index e0e1bbc7d..3264eec6d 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/utils/dialog/KiwixDialog.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/utils/dialog/KiwixDialog.kt @@ -108,6 +108,14 @@ sealed class KiwixDialog( cancelable = false ) + object SaveOrOpenUnsupportedFiles : KiwixDialog( + R.string.save_or_open_unsupported_files_dialog_title, + R.string.save_or_open_unsupported_files_dialog_message, + R.string.open, + R.string.save, + neutralMessage = R.string.no_thanks + ) + data class ShowHotspotDetails(override val args: List) : KiwixDialog( R.string.hotspot_turned_on, diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/utils/dialog/UnsupportedMimeTypeHandler.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/utils/dialog/UnsupportedMimeTypeHandler.kt new file mode 100644 index 000000000..830a94f58 --- /dev/null +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/utils/dialog/UnsupportedMimeTypeHandler.kt @@ -0,0 +1,88 @@ +/* + * Kiwix Android + * Copyright (c) 2024 Kiwix + * 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 . + * + */ + +package org.kiwix.kiwixmobile.core.utils.dialog + +import android.app.Activity +import android.content.Intent +import android.util.Log +import androidx.core.content.FileProvider +import org.kiwix.kiwixmobile.core.R +import org.kiwix.kiwixmobile.core.extensions.toast +import org.kiwix.kiwixmobile.core.reader.ZimReaderContainer +import org.kiwix.kiwixmobile.core.utils.SharedPreferenceUtil +import org.kiwix.kiwixmobile.core.utils.files.FileUtils.downloadFileFromUrl +import java.io.File +import javax.inject.Inject + +class UnsupportedMimeTypeHandler @Inject constructor( + private val activity: Activity, + private val sharedPreferenceUtil: SharedPreferenceUtil, + private val alertDialogShower: AlertDialogShower, + private val zimReaderContainer: ZimReaderContainer +) { + var intent: Intent = Intent(Intent.ACTION_VIEW) + + fun showSaveOrOpenUnsupportedFilesDialog(url: String?, documentType: String?) { + alertDialogShower.show( + KiwixDialog.SaveOrOpenUnsupportedFiles, + { openOrSaveFile(url, documentType, true) }, + { openOrSaveFile(url, documentType, false) }, + { } + ) + } + + private fun openOrSaveFile(url: String?, documentType: String?, openFile: Boolean) { + downloadFileFromUrl( + url, + null, + zimReaderContainer, + sharedPreferenceUtil + )?.let { savedFile -> + if (openFile) { + openFile(savedFile, documentType) + } else { + activity.toast(activity.getString(R.string.save_media_saved, savedFile.name)).also { + Log.e("DownloadOrOpenEpubAndPdf", "File downloaded at = ${savedFile.path}") + } + } + } ?: run { + activity.toast(R.string.save_media_error) + } + } + + private fun openFile(savedFile: File, documentType: String?) { + if (savedFile.exists()) { + val uri = FileProvider.getUriForFile( + activity, + "${activity.packageName}.fileprovider", + savedFile + ) + intent.apply { + setDataAndType(uri, documentType) + flags = Intent.FLAG_ACTIVITY_NO_HISTORY + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + if (intent.resolveActivity(activity.packageManager) != null) { + activity.startActivity(intent) + } else { + activity.toast(R.string.no_reader_application_installed) + } + } + } +} diff --git a/core/src/main/java/org/kiwix/kiwixmobile/core/utils/files/FileUtils.kt b/core/src/main/java/org/kiwix/kiwixmobile/core/utils/files/FileUtils.kt index 3acc0833b..c3e1d8ef8 100644 --- a/core/src/main/java/org/kiwix/kiwixmobile/core/utils/files/FileUtils.kt +++ b/core/src/main/java/org/kiwix/kiwixmobile/core/utils/files/FileUtils.kt @@ -410,12 +410,15 @@ object FileUtils { * We are placing a condition here for if the file name does not have a .bin extension, then it returns the original file name. + * Remove colon if any contains in the fileName since most of the fileSystem + will not allow to create file which contains colon in it. + see https://github.com/kiwix/kiwix-android/issues/3737 */ fun getDecodedFileName(url: String?): String? { var fileName: String? = null val decodedFileName = URLUtil.guessFileName(url, null, null) if (!decodedFileName.endsWith(".bin")) { - fileName = decodedFileName + fileName = decodedFileName.replace(":", "") } return fileName } @@ -444,17 +447,8 @@ object FileUtils { ) if (!root.isFileExist()) root.mkdir() } - if (File(root, fileName).isFileExist()) return File(root, fileName) - val fileToSave = sequence { - yield(File(root, fileName)) - yieldAll( - generateSequence(1) { it + 1 }.map { - File( - root, fileName.replace(".", "_$it.") - ) - } - ) - }.first { !it.isFileExist() } + val fileToSave = File(root, fileName) + if (fileToSave.isFileExist()) return fileToSave val source = if (url == null) Uri.parse(src) else Uri.parse(url) return try { zimReaderContainer.load("$source", emptyMap()).data.use { inputStream -> diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 84b9750bf..94034e08d 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -373,4 +373,6 @@ ZIM file which has the reading content Selecting this language will prioritize displaying downloadable books in that language at the top. Go to previous screen + Save or Open this file? + Choosing Open will open this file in external reader application.