From 8ce5237a5f950c25114f99282bc04f8c9d4d5a62 Mon Sep 17 00:00:00 2001 From: NickVs2015 Date: Tue, 16 Jun 2026 17:37:08 +0300 Subject: [PATCH] fix: support filepicker android 9 --- .../src/org/amnezia/vpn/AmneziaActivity.kt | 34 +++-- .../src/org/amnezia/vpn/TvFilePicker.kt | 140 ++++++++++++++++-- 2 files changed, 144 insertions(+), 30 deletions(-) diff --git a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt index 5b0a5c3c4..6f29233ba 100644 --- a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt +++ b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt @@ -42,6 +42,7 @@ import androidx.core.view.OnApplyWindowInsetsListener import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat +import java.io.File import java.io.IOException import kotlin.LazyThreadSafetyMode.NONE import kotlin.coroutines.CoroutineContext @@ -766,7 +767,13 @@ class AmneziaActivity : QtActivity() { fun openFile(filter: String?) { Log.v(TAG, "Open file with filter: $filter") 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 extensionRegex = "\\*\\.([a-z0-9]+)".toRegex(IGNORE_CASE) val mime = MimeTypeMap.getSingleton() @@ -792,16 +799,7 @@ class AmneziaActivity : QtActivity() { else -> type = "*/*" } } - // Force system document picker to avoid third-party file managers - // 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 - } + `package` = systemPickerPackage } } else { Intent(this@AmneziaActivity, TvFilePicker::class.java) @@ -813,8 +811,11 @@ class AmneziaActivity : QtActivity() { if (isOnTv() && it?.hasExtra("activityNotFound") == true) { showNoFileBrowserAlertDialog() } - val uri = it?.data?.apply { - grantUriPermission(packageName, this, Intent.FLAG_GRANT_READ_URI_PERMISSION) + val uri = it?.data?.let { u -> + if (u.scheme == "content") { + try { grantUriPermission(packageName, u, Intent.FLAG_GRANT_READ_URI_PERMISSION) } catch (_: Exception) {} + } + u }?.toString() ?: "" Log.v(TAG, "Open file: $uri") if (uri.isNotEmpty()) { @@ -854,7 +855,12 @@ class AmneziaActivity : QtActivity() { Log.v(TAG, "Get fd for $fileName") return blockingCall(Dispatchers.IO) { 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 } catch (e: Exception) { Log.e(TAG, "Failed to get fd: $e") diff --git a/client/android/src/org/amnezia/vpn/TvFilePicker.kt b/client/android/src/org/amnezia/vpn/TvFilePicker.kt index a3c7ec8af..80b73ef97 100644 --- a/client/android/src/org/amnezia/vpn/TvFilePicker.kt +++ b/client/android/src/org/amnezia/vpn/TvFilePicker.kt @@ -1,30 +1,36 @@ package org.amnezia.vpn +import android.Manifest +import android.app.AlertDialog import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.net.Uri import android.os.Build import android.os.Bundle +import android.os.Environment import androidx.activity.ComponentActivity import androidx.activity.result.contract.ActivityResultContracts import org.amnezia.vpn.util.Log +import java.io.File private const val TAG = "TvFilePicker" +private const val READ_STORAGE_REQUEST_CODE = 1001 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): Intent { val intent = super.createIntent(context, input) - - val activitiesToResolveIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val activities = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { context.packageManager.queryIntentActivities(intent, PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong())) } else { @Suppress("DEPRECATION") context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) } - if (activitiesToResolveIntent.all { + if (activities.all { val name = it.activityInfo.packageName name.startsWith("com.google.android.tv.frameworkpackagestubs") || name.startsWith("com.android.tv.frameworkpackagestubs") }) { @@ -32,38 +38,140 @@ class TvFilePicker : ComponentActivity() { } return intent } - }) { + }) { uri -> setResult(RESULT_OK, Intent().apply { - data = it + data = uri addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) }) finish() } + private val directoryStack = ArrayDeque() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) Log.v(TAG, "onCreate") - getFile() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + launchSaf() + } else { + checkPermissionAndBrowse() + } } - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - Log.v(TAG, "onNewIntent") - getFile() + @Deprecated("Deprecated in Java") + override fun onBackPressed() { + navigateBack() } - private fun getFile() { + private fun launchSaf() { try { - Log.v(TAG, "getFile") - fileChooseResultLauncher.launch(arrayOf("*/*")) + safLauncher.launch(arrayOf("*/*")) } catch (_: ActivityNotFoundException) { - Log.w(TAG, "Activity not found") + Log.w(TAG, "No SAF activity found") setResult(RESULT_CANCELED, Intent().apply { putExtra("activityNotFound", true) }) finish() } catch (e: Exception) { - Log.e(TAG, "Failed to get file: $e") + Log.e(TAG, "SAF launch failed: $e") setResult(RESULT_CANCELED) 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, 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() + } }