Skip to content

Commit

Permalink
Merge pull request #4015 from kiwix/Fixes#4008
Browse files Browse the repository at this point in the history
Fixed: In Samsung tablet of Android 14, downloaded files can not be opened via file picker or deep linking in non-PS variant.
  • Loading branch information
kelson42 authored Oct 30, 2024
2 parents eaa28d6 + 946b5cc commit 7400c26
Show file tree
Hide file tree
Showing 2 changed files with 159 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,15 @@ class FileUtilsInstrumentationTest {
Uri.parse(
"${downloadUriPrefix}0"
)
),
DummyUrlData(
null,
null,
null,
null,
Uri.parse(
"${downloadDocumentUriPrefix}msf%3A1000000057"
)
)
)
context?.let { context ->
Expand Down
152 changes: 150 additions & 2 deletions core/src/main/java/org/kiwix/kiwixmobile/core/utils/files/FileUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,14 @@ import android.annotation.SuppressLint
import android.content.ContentUris
import android.content.Context
import android.content.res.AssetFileDescriptor
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.Environment.DIRECTORY_DOWNLOADS
import android.os.storage.StorageManager
import android.provider.DocumentsContract
import android.provider.MediaStore
import android.webkit.URLUtil
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
Expand Down Expand Up @@ -143,7 +147,7 @@ object FileUtils {
}
} else if (uri.scheme != null) {
if ("content".equals(uri.scheme, ignoreCase = true)) {
return contentQuery(context, uri)
return getFilePathOfContentUri(context, uri)
} else if ("file".equals(uri.scheme, ignoreCase = true)) {
return uri.path
}
Expand All @@ -154,6 +158,143 @@ object FileUtils {
return null
}

/**
* Retrieves the file path from a given content URI. This method first attempts to get the path
* using the content resolver (via `contentQuery`). If that returns null or empty, it falls back
* to a secondary method to resolve the actual path.
*
* This fallback is especially necessary when:
* 1. The user clicks directly on a downloaded file from browsers, where different browsers
* return URIs using their own file providers.
* 2. On devices below Android 11, when files are clicked directly in the file manager, the content
* resolver may not be able to retrieve the path for certain URIs.
*/
private fun getFilePathOfContentUri(context: Context, uri: Uri): String? {
val filePath = contentQuery(context, uri)
return if (!filePath.isNullOrEmpty()) {
filePath
} else {
// Fallback method to get the actual path of the URI
getActualFilePathOfContentUri(context, uri)
}
}

private fun getFullFilePathFromFilePath(
context: Context,
filePath: String?
): String? {
var actualFilePath: String? = null
if (filePath?.isNotEmpty() == true) {
getStorageVolumesList(context).forEach { volume ->
// Check if the volume is part of the file path and remove it
val trimmedFilePath = filePath.removePrefix(volume)
val file = File("$volume/$trimmedFilePath")
if (file.isFileExist()) {
actualFilePath = file.path
}
}
}
return actualFilePath
}

private fun getStorageVolumesList(context: Context): HashSet<String> {
val storageVolumes = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
val storageVolumesList = HashSet<String>()
storageVolumes.storageVolumes.filterNotNull().forEach {
if (it.isPrimary) {
storageVolumesList.add("${Environment.getExternalStorageDirectory()}/")
} else {
val externalStorageName = it.uuid?.let { uuid ->
"/$uuid/"
} ?: kotlin.run {
"/${it.getDescription(context)}/"
}
storageVolumesList.add("/storage$externalStorageName")
}
}
return storageVolumesList
}

private fun getFileNameFromUri(context: Context, uri: Uri): String? {
var cursor: Cursor? = null
val projection = arrayOf(
MediaStore.MediaColumns.DISPLAY_NAME
)
return try {
cursor = context.contentResolver.query(
uri, projection, null, null,
null
)
if (cursor != null && cursor.moveToFirst()) {
val index = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)
cursor.getString(index)
} else {
null
}
} catch (ignore: Exception) {
null
} finally {
cursor?.close()
}
}

/**
* Retrieves the actual file path from a given content URI. This method handles various cases based on
* the type of URI and the source (file managers, browser downloads, etc.).
*
* 1. For file managers that include the full file path in the URI (common in devices below Android 11),
* it triggers when the user clicks directly on the ZIM file in the file manager. The file manager may
* return the path with their own file provider, and we extract the path.
*
* 2. For URIs from the download provider (e.g., when opening files directly from browsers), this method
* constructs the full path using the `DIRECTORY_DOWNLOADS` directory and the file name.
*
* 3. For other URIs, it attempts to resolve the full file path from the provided URI using a custom
* method to retrieve the folder and file path.
*/
private fun getActualFilePathOfContentUri(context: Context, uri: Uri): String? {
return when {
// For file managers that provide the full path in the URI (common on devices below Android 11).
// This triggers when the user clicks directly on a ZIM file in the file manager, and the file
// manager returns the path via its own file provider.
"$uri".contains("root") && "$uri".endsWith("zim") -> {
"$uri".substringAfter("/root")
}

// Handles URIs from the download provider, commonly used when files are opened from browsers.
// Some browsers return URIs with their DownloadProvider.
isDownloadProviderUri(uri) -> {
getFullFilePathFromFilePath(
context,
"$DIRECTORY_DOWNLOADS/${getFileNameFromUri(context, uri)}"
)
}

else -> {
// Attempts to retrieve the full path from the URI using a custom method.
getFullFilePathFromFilePath(
context,
getFilePathWithFolderFromUri(uri)
)
}
}
}

private fun getFilePathWithFolderFromUri(uri: Uri): String? {
val pathSegments = uri.pathSegments
if (pathSegments.isNotEmpty()) {
// Returns the path of the folder containing the file with the specified fileName,
// from which the user selects the file.
return pathSegments.drop(1)
.filterNot { it.startsWith("0") } // remove the prefix of primary storage device
.joinToString(separator = "/")
}
return null
}

private fun isDownloadProviderUri(uri: Uri): Boolean =
"$uri".contains("DownloadProvider") || "$uri".contains("/downloads")

fun documentProviderContentQuery(
context: Context,
uri: Uri,
Expand Down Expand Up @@ -183,7 +324,14 @@ object FileUtils {
actualDocumentId,
contentUriPrefixes,
documentsContractWrapper
)
) ?: kotlin.run {
// Fallback method to get the actual path of the URI. This will be called
// when queryForActualPath returns null, especially in cases where the user directly opens
// the file from the file manager in the downloads folder, and the URI contains a different
// document ID (particularly on tablets). See https://github.com/kiwix/kiwix-android/issues/4008
val fileName = getFileNameFromUri(context, uri)
getFullFilePathFromFilePath(context, "$DIRECTORY_DOWNLOADS/$fileName")
}
}

private fun queryForActualPath(
Expand Down

0 comments on commit 7400c26

Please sign in to comment.