fix: support filepicker android 9

This commit is contained in:
NickVs2015
2026-06-16 17:37:08 +03:00
parent 129ae44edc
commit 8ce5237a5f
2 changed files with 144 additions and 30 deletions
@@ -42,6 +42,7 @@ import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import java.io.File
import java.io.IOException import java.io.IOException
import kotlin.LazyThreadSafetyMode.NONE import kotlin.LazyThreadSafetyMode.NONE
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
@@ -766,7 +767,13 @@ class AmneziaActivity : QtActivity() {
fun openFile(filter: String?) { fun openFile(filter: String?) {
Log.v(TAG, "Open file with filter: $filter") Log.v(TAG, "Open file with filter: $filter")
mainScope.launch { mainScope.launch {
val intent = if (!isOnTv()) { val systemPickerPackage = listOf("com.google.android.documentsui", "com.android.documentsui")
.firstOrNull { pkg ->
try { packageManager.getPackageInfo(pkg, 0); true }
catch (_: PackageManager.NameNotFoundException) { false }
}
val intent = if (!isOnTv() && systemPickerPackage != null) {
val mimeTypes = if (!filter.isNullOrEmpty()) { val mimeTypes = if (!filter.isNullOrEmpty()) {
val extensionRegex = "\\*\\.([a-z0-9]+)".toRegex(IGNORE_CASE) val extensionRegex = "\\*\\.([a-z0-9]+)".toRegex(IGNORE_CASE)
val mime = MimeTypeMap.getSingleton() val mime = MimeTypeMap.getSingleton()
@@ -792,16 +799,7 @@ class AmneziaActivity : QtActivity() {
else -> type = "*/*" else -> type = "*/*"
} }
} }
// Force system document picker to avoid third-party file managers `package` = systemPickerPackage
// that may lack storage permissions (common on Android TV devices)
val systemPickerPackage = listOf("com.google.android.documentsui", "com.android.documentsui")
.firstOrNull { pkg ->
try { packageManager.getPackageInfo(pkg, 0); true }
catch (_: PackageManager.NameNotFoundException) { false }
}
if (systemPickerPackage != null) {
`package` = systemPickerPackage
}
} }
} else { } else {
Intent(this@AmneziaActivity, TvFilePicker::class.java) Intent(this@AmneziaActivity, TvFilePicker::class.java)
@@ -813,8 +811,11 @@ class AmneziaActivity : QtActivity() {
if (isOnTv() && it?.hasExtra("activityNotFound") == true) { if (isOnTv() && it?.hasExtra("activityNotFound") == true) {
showNoFileBrowserAlertDialog() showNoFileBrowserAlertDialog()
} }
val uri = it?.data?.apply { val uri = it?.data?.let { u ->
grantUriPermission(packageName, this, Intent.FLAG_GRANT_READ_URI_PERMISSION) if (u.scheme == "content") {
try { grantUriPermission(packageName, u, Intent.FLAG_GRANT_READ_URI_PERMISSION) } catch (_: Exception) {}
}
u
}?.toString() ?: "" }?.toString() ?: ""
Log.v(TAG, "Open file: $uri") Log.v(TAG, "Open file: $uri")
if (uri.isNotEmpty()) { if (uri.isNotEmpty()) {
@@ -854,7 +855,12 @@ class AmneziaActivity : QtActivity() {
Log.v(TAG, "Get fd for $fileName") Log.v(TAG, "Get fd for $fileName")
return blockingCall(Dispatchers.IO) { return blockingCall(Dispatchers.IO) {
try { try {
pfd = contentResolver.openFileDescriptor(Uri.parse(fileName), "r") val uri = Uri.parse(fileName)
pfd = if (uri.scheme == "file") {
ParcelFileDescriptor.open(File(uri.path!!), ParcelFileDescriptor.MODE_READ_ONLY)
} else {
contentResolver.openFileDescriptor(uri, "r")
}
pfd?.fd ?: -1 pfd?.fd ?: -1
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to get fd: $e") Log.e(TAG, "Failed to get fd: $e")
@@ -1,30 +1,36 @@
package org.amnezia.vpn package org.amnezia.vpn
import android.Manifest
import android.app.AlertDialog
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Environment
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import org.amnezia.vpn.util.Log import org.amnezia.vpn.util.Log
import java.io.File
private const val TAG = "TvFilePicker" private const val TAG = "TvFilePicker"
private const val READ_STORAGE_REQUEST_CODE = 1001
class TvFilePicker : ComponentActivity() { class TvFilePicker : ComponentActivity() {
private val fileChooseResultLauncher = registerForActivityResult(object : ActivityResultContracts.OpenDocument() { // SAF launcher for Android 10+ where File API is blocked by scoped storage
private val safLauncher = registerForActivityResult(object : ActivityResultContracts.OpenDocument() {
override fun createIntent(context: Context, input: Array<String>): Intent { override fun createIntent(context: Context, input: Array<String>): Intent {
val intent = super.createIntent(context, input) val intent = super.createIntent(context, input)
val activities = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val activitiesToResolveIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.packageManager.queryIntentActivities(intent, PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong())) context.packageManager.queryIntentActivities(intent, PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong()))
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY)
} }
if (activitiesToResolveIntent.all { if (activities.all {
val name = it.activityInfo.packageName val name = it.activityInfo.packageName
name.startsWith("com.google.android.tv.frameworkpackagestubs") || name.startsWith("com.android.tv.frameworkpackagestubs") name.startsWith("com.google.android.tv.frameworkpackagestubs") || name.startsWith("com.android.tv.frameworkpackagestubs")
}) { }) {
@@ -32,38 +38,140 @@ class TvFilePicker : ComponentActivity() {
} }
return intent return intent
} }
}) { }) { uri ->
setResult(RESULT_OK, Intent().apply { setResult(RESULT_OK, Intent().apply {
data = it data = uri
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}) })
finish() finish()
} }
private val directoryStack = ArrayDeque<File>()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Log.v(TAG, "onCreate") Log.v(TAG, "onCreate")
getFile() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
launchSaf()
} else {
checkPermissionAndBrowse()
}
} }
override fun onNewIntent(intent: Intent) { @Deprecated("Deprecated in Java")
super.onNewIntent(intent) override fun onBackPressed() {
Log.v(TAG, "onNewIntent") navigateBack()
getFile()
} }
private fun getFile() { private fun launchSaf() {
try { try {
Log.v(TAG, "getFile") safLauncher.launch(arrayOf("*/*"))
fileChooseResultLauncher.launch(arrayOf("*/*"))
} catch (_: ActivityNotFoundException) { } catch (_: ActivityNotFoundException) {
Log.w(TAG, "Activity not found") Log.w(TAG, "No SAF activity found")
setResult(RESULT_CANCELED, Intent().apply { putExtra("activityNotFound", true) }) setResult(RESULT_CANCELED, Intent().apply { putExtra("activityNotFound", true) })
finish() finish()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to get file: $e") Log.e(TAG, "SAF launch failed: $e")
setResult(RESULT_CANCELED) setResult(RESULT_CANCELED)
finish() finish()
} }
} }
private fun checkPermissionAndBrowse() {
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE), READ_STORAGE_REQUEST_CODE)
} else {
showRootDirectory()
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == READ_STORAGE_REQUEST_CODE &&
grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
showRootDirectory()
} else {
setResult(RESULT_CANCELED)
finish()
}
}
private fun showRootDirectory() {
@Suppress("DEPRECATION")
val primaryExternal = Environment.getExternalStorageDirectory()
val storageDir = File("/storage")
// Pre-seed stack with /storage so Back from primary storage goes there (USB drives etc.)
if (storageDir.exists() && storageDir.canonicalPath != primaryExternal.canonicalPath) {
directoryStack.addLast(storageDir)
}
showDirectory(primaryExternal)
}
private fun navigateBack() {
if (directoryStack.size > 1) {
directoryStack.removeLast()
val parent = directoryStack.removeLast()
showDirectory(parent)
} else {
setResult(RESULT_CANCELED)
finish()
}
}
private fun showDirectory(dir: File) {
directoryStack.addLast(dir)
Log.v(TAG, "Showing directory: ${dir.absolutePath}")
val entries = try {
dir.listFiles()
?.sortedWith(compareBy({ !it.isDirectory }, { it.name.lowercase() }))
?: emptyList()
} catch (e: Exception) {
Log.e(TAG, "Failed to list directory: $e")
emptyList()
}
val names = entries.map { if (it.isDirectory) "[${it.name}]" else it.name }.toTypedArray()
val builder = AlertDialog.Builder(this)
.setTitle(dir.absolutePath)
if (entries.isEmpty()) {
builder.setMessage("No files available")
} else {
builder.setItems(names) { dialog, which ->
dialog.dismiss()
val selected = entries[which]
if (selected.isDirectory) {
showDirectory(selected)
} else {
Log.v(TAG, "Selected file: ${selected.absolutePath}")
setResult(RESULT_OK, Intent().apply {
data = Uri.fromFile(selected)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
})
finish()
}
}
}
if (directoryStack.size > 1) {
builder.setNegativeButton("↑ Back") { dialog, _ ->
dialog.dismiss()
navigateBack()
}
} else {
builder.setNegativeButton(android.R.string.cancel) { _, _ ->
setResult(RESULT_CANCELED)
finish()
}
}
builder.setOnCancelListener {
setResult(RESULT_CANCELED)
finish()
}
builder.show()
}
} }