From b3c43e428f98a3b9b32e404ca7ff958d2f05b03b Mon Sep 17 00:00:00 2001 From: "Jason A. Donenfeld" Date: Wed, 23 Sep 2020 12:04:36 +0200 Subject: [PATCH] tv: use our own file picker Signed-off-by: Jason A. Donenfeld --- .../android/activity/TvMainActivity.kt | 187 ++++++++++++++++-- ui/src/main/res/layout/tv_activity.xml | 58 +++++- ui/src/main/res/layout/tv_file_list_item.xml | 46 +++++ ui/src/main/res/values/strings.xml | 2 +- 4 files changed, 266 insertions(+), 27 deletions(-) create mode 100644 ui/src/main/res/layout/tv_file_list_item.xml diff --git a/ui/src/main/java/com/wireguard/android/activity/TvMainActivity.kt b/ui/src/main/java/com/wireguard/android/activity/TvMainActivity.kt index fd9f31b5..004d26e0 100644 --- a/ui/src/main/java/com/wireguard/android/activity/TvMainActivity.kt +++ b/ui/src/main/java/com/wireguard/android/activity/TvMainActivity.kt @@ -5,41 +5,47 @@ package com.wireguard.android.activity -import android.content.ActivityNotFoundException +import android.Manifest +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build import android.os.Bundle +import android.os.Environment +import android.os.storage.StorageManager +import android.os.storage.StorageVolume import android.util.Log import android.view.View import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService import androidx.core.view.forEach import androidx.databinding.DataBindingUtil import androidx.databinding.ObservableBoolean +import androidx.databinding.ObservableField import androidx.lifecycle.lifecycleScope import com.wireguard.android.Application import com.wireguard.android.R import com.wireguard.android.backend.GoBackend import com.wireguard.android.backend.Tunnel +import com.wireguard.android.databinding.Keyed +import com.wireguard.android.databinding.ObservableKeyedArrayList import com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter import com.wireguard.android.databinding.TvActivityBinding +import com.wireguard.android.databinding.TvFileListItemBinding import com.wireguard.android.databinding.TvTunnelListItemBinding import com.wireguard.android.model.ObservableTunnel import com.wireguard.android.util.ErrorMessages import com.wireguard.android.util.QuantityFormatter import com.wireguard.android.util.TunnelImporter +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File class TvMainActivity : AppCompatActivity() { - private val tunnelFileImportResultLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { data -> - if (data == null) return@registerForActivityResult - lifecycleScope.launch { - TunnelImporter.importTunnel(contentResolver, data) { - Toast.makeText(this@TvMainActivity, it, Toast.LENGTH_LONG).show() - } - } - } - private var pendingTunnel: ObservableTunnel? = null private val permissionActivityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { val tunnel = pendingTunnel @@ -64,6 +70,8 @@ class TvMainActivity : AppCompatActivity() { private lateinit var binding: TvActivityBinding private val isDeleting = ObservableBoolean() + private val files = ObservableKeyedArrayList() + private val filesRoot = ObservableField("") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -76,7 +84,9 @@ class TvMainActivity : AppCompatActivity() { binding.tunnelList.requestFocus() } binding.isDeleting = isDeleting - binding.rowConfigurationHandler = object : ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler { + binding.files = files + binding.filesRoot = filesRoot + binding.tunnelRowConfigurationHandler = object : ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler { override fun onConfigureRow(binding: TvTunnelListItemBinding, item: ObservableTunnel, position: Int) { binding.isDeleting = isDeleting binding.isFocused = ObservableBoolean() @@ -111,13 +121,44 @@ class TvMainActivity : AppCompatActivity() { } } } - binding.importButton.setOnClickListener { - try { - tunnelFileImportResultLauncher.launch("*/*") - } catch (e: ActivityNotFoundException) { - Toast.makeText(this@TvMainActivity, getString(R.string.tv_error), Toast.LENGTH_LONG).show() + + binding.filesRowConfigurationHandler = object : ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler { + override fun onConfigureRow(binding: TvFileListItemBinding, item: KeyedFile, position: Int) { + binding.root.setOnClickListener { + if (item.isDirectory) + navigateTo(item) + else { + val uri = Uri.fromFile(item.canonicalFile) + files.clear() + filesRoot.set("") + lifecycleScope.launch { + TunnelImporter.importTunnel(contentResolver, uri) { + Toast.makeText(this@TvMainActivity, it, Toast.LENGTH_LONG).show() + } + } + runOnUiThread { + this@TvMainActivity.binding.tunnelList.requestFocus() + } + } + } } } + + binding.importButton.setOnClickListener { + if (filesRoot.get()?.isEmpty() != false) { + navigateTo(myComputerFile) + runOnUiThread { + binding.filesList.requestFocus() + } + } else { + files.clear() + filesRoot.set("") + runOnUiThread { + binding.tunnelList.requestFocus() + } + } + } + binding.deleteButton.setOnClickListener { isDeleting.set(!isDeleting.get()) runOnUiThread { @@ -135,11 +176,112 @@ class TvMainActivity : AppCompatActivity() { } } + private var pendingNavigation: File? = null + private val permissionRequestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { + val to = pendingNavigation + if (it && to != null) + navigateTo(to) + pendingNavigation = null + } + + private suspend fun makeStorageRoots(): Collection = withContext(Dispatchers.IO) { + val list = HashSet() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + val storageManager: StorageManager = getSystemService() ?: return@withContext list + list.addAll(storageManager.storageVolumes.mapNotNull { volume -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + volume.directory?.let { KeyedFile(it.canonicalPath) } + } else { + KeyedFile((StorageVolume::class.java.getMethod("getPathFile").invoke(volume) as File).canonicalPath) + } + }) + } else { + @Suppress("DEPRECATION") + list.add(KeyedFile(Environment.getExternalStorageDirectory().canonicalPath)) + try { + File("/storage").listFiles()?.forEach { + if (!it.isDirectory) return@forEach + try { + if (Environment.isExternalStorageRemovable(it)) { + list.add(KeyedFile(it.canonicalPath)) + } + } catch (_: Throwable) { + } + } + } catch (_: Throwable) { + } + } + list + } + + private val myComputerFile = File("") + + private fun navigateTo(directory: File) { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + pendingNavigation = directory + permissionRequestPermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) + return + } + + lifecycleScope.launch { + if (directory == myComputerFile) { + val roots = makeStorageRoots() + if (roots.count() == 1) { + navigateTo(roots.first()) + return@launch + } + files.clear() + files.addAll(roots) + filesRoot.set(getString(R.string.tv_select_a_storage_drive)) + return@launch + } + + val newFiles = withContext(Dispatchers.IO) { + val newFiles = ArrayList() + try { + val parent = KeyedFile(directory.canonicalPath + "/..") + if (directory.canonicalPath != "/" && parent.list() != null) + newFiles.add(parent) + val listing = directory.listFiles() ?: return@withContext null + listing.forEach { + if (it.extension == "conf" || it.extension == "zip" || it.isDirectory) + newFiles.add(KeyedFile(it.canonicalPath)) + } + newFiles.sortWith { a, b -> + if (a.isDirectory && !b.isDirectory) -1 + else if (!a.isDirectory && b.isDirectory) 1 + else a.compareTo(b) + } + } catch (e: Throwable) { + Log.e(TAG, Log.getStackTraceString(e)) + } + newFiles + } + if (newFiles?.isEmpty() != false) + return@launch + files.clear() + files.addAll(newFiles) + filesRoot.set(directory.canonicalPath) + } + } + override fun onBackPressed() { - if (isDeleting.get()) - isDeleting.set(false) - else - super.onBackPressed() + when { + isDeleting.get() -> { + isDeleting.set(false) + runOnUiThread { + binding.tunnelList.requestFocus() + } + } + filesRoot.get()?.isNotEmpty() == true -> { + files.clear() + filesRoot.set("") + runOnUiThread { + binding.tunnelList.requestFocus() + } + } + else -> super.onBackPressed() + } } private suspend fun updateStats() { @@ -163,6 +305,11 @@ class TvMainActivity : AppCompatActivity() { } } + class KeyedFile(pathname: String) : File(pathname), Keyed { + override val key: String + get() = if (isDirectory) "$name/" else name + } + companion object { private const val TAG = "WireGuard/TvMainActivity" } diff --git a/ui/src/main/res/layout/tv_activity.xml b/ui/src/main/res/layout/tv_activity.xml index 5e29ed5f..16207b15 100644 --- a/ui/src/main/res/layout/tv_activity.xml +++ b/ui/src/main/res/layout/tv_activity.xml @@ -9,16 +9,30 @@ + + + + + + + + @@ -54,8 +68,8 @@ android:layout_height="0dp" android:layout_marginTop="16dp" android:orientation="horizontal" - android:visibility="@{tunnels.isEmpty() ? View.GONE : View.VISIBLE}" - app:configurationHandler="@{rowConfigurationHandler}" + android:visibility="@{(tunnels.isEmpty || !filesRoot.isEmpty) ? View.GONE : View.VISIBLE}" + app:configurationHandler="@{tunnelRowConfigurationHandler}" app:items="@{tunnels}" app:layout="@{@layout/tv_tunnel_list_item}" app:layoutManager="androidx.recyclerview.widget.GridLayoutManager" @@ -66,13 +80,45 @@ tools:itemCount="10" tools:listitem="@layout/tv_tunnel_list_item" /> + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index f539b0ba..000b993c 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -102,7 +102,7 @@ Use dark theme Delete Select tunnel to delete - Your TV does not have a file picker + Select a storage drive Add a tunnel to get started Disable config exporting Disabling config exporting makes private keys less accessible