tv: use our own file picker

Signed-off-by: Jason A. Donenfeld <Jason@zx2c4.com>
This commit is contained in:
Jason A. Donenfeld 2020-09-23 12:04:36 +02:00
parent 7bec539722
commit b3c43e428f
4 changed files with 266 additions and 27 deletions

View File

@ -5,41 +5,47 @@
package com.wireguard.android.activity 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.Bundle
import android.os.Environment
import android.os.storage.StorageManager
import android.os.storage.StorageVolume
import android.util.Log import android.util.Log
import android.view.View import android.view.View
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.core.view.forEach import androidx.core.view.forEach
import androidx.databinding.DataBindingUtil import androidx.databinding.DataBindingUtil
import androidx.databinding.ObservableBoolean import androidx.databinding.ObservableBoolean
import androidx.databinding.ObservableField
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.wireguard.android.Application import com.wireguard.android.Application
import com.wireguard.android.R import com.wireguard.android.R
import com.wireguard.android.backend.GoBackend import com.wireguard.android.backend.GoBackend
import com.wireguard.android.backend.Tunnel 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.ObservableKeyedRecyclerViewAdapter
import com.wireguard.android.databinding.TvActivityBinding import com.wireguard.android.databinding.TvActivityBinding
import com.wireguard.android.databinding.TvFileListItemBinding
import com.wireguard.android.databinding.TvTunnelListItemBinding import com.wireguard.android.databinding.TvTunnelListItemBinding
import com.wireguard.android.model.ObservableTunnel import com.wireguard.android.model.ObservableTunnel
import com.wireguard.android.util.ErrorMessages import com.wireguard.android.util.ErrorMessages
import com.wireguard.android.util.QuantityFormatter import com.wireguard.android.util.QuantityFormatter
import com.wireguard.android.util.TunnelImporter import com.wireguard.android.util.TunnelImporter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
class TvMainActivity : AppCompatActivity() { 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 var pendingTunnel: ObservableTunnel? = null
private val permissionActivityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { private val permissionActivityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
val tunnel = pendingTunnel val tunnel = pendingTunnel
@ -64,6 +70,8 @@ class TvMainActivity : AppCompatActivity() {
private lateinit var binding: TvActivityBinding private lateinit var binding: TvActivityBinding
private val isDeleting = ObservableBoolean() private val isDeleting = ObservableBoolean()
private val files = ObservableKeyedArrayList<String, KeyedFile>()
private val filesRoot = ObservableField("")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -76,7 +84,9 @@ class TvMainActivity : AppCompatActivity() {
binding.tunnelList.requestFocus() binding.tunnelList.requestFocus()
} }
binding.isDeleting = isDeleting binding.isDeleting = isDeleting
binding.rowConfigurationHandler = object : ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler<TvTunnelListItemBinding, ObservableTunnel> { binding.files = files
binding.filesRoot = filesRoot
binding.tunnelRowConfigurationHandler = object : ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler<TvTunnelListItemBinding, ObservableTunnel> {
override fun onConfigureRow(binding: TvTunnelListItemBinding, item: ObservableTunnel, position: Int) { override fun onConfigureRow(binding: TvTunnelListItemBinding, item: ObservableTunnel, position: Int) {
binding.isDeleting = isDeleting binding.isDeleting = isDeleting
binding.isFocused = ObservableBoolean() binding.isFocused = ObservableBoolean()
@ -111,13 +121,44 @@ class TvMainActivity : AppCompatActivity() {
} }
} }
} }
binding.filesRowConfigurationHandler = object : ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler<TvFileListItemBinding, KeyedFile> {
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 { binding.importButton.setOnClickListener {
try { if (filesRoot.get()?.isEmpty() != false) {
tunnelFileImportResultLauncher.launch("*/*") navigateTo(myComputerFile)
} catch (e: ActivityNotFoundException) { runOnUiThread {
Toast.makeText(this@TvMainActivity, getString(R.string.tv_error), Toast.LENGTH_LONG).show() binding.filesList.requestFocus()
}
} else {
files.clear()
filesRoot.set("")
runOnUiThread {
binding.tunnelList.requestFocus()
} }
} }
}
binding.deleteButton.setOnClickListener { binding.deleteButton.setOnClickListener {
isDeleting.set(!isDeleting.get()) isDeleting.set(!isDeleting.get())
runOnUiThread { 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<KeyedFile> = withContext(Dispatchers.IO) {
val list = HashSet<KeyedFile>()
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<KeyedFile>()
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() { override fun onBackPressed() {
if (isDeleting.get()) when {
isDeleting.get() -> {
isDeleting.set(false) isDeleting.set(false)
else runOnUiThread {
super.onBackPressed() binding.tunnelList.requestFocus()
}
}
filesRoot.get()?.isNotEmpty() == true -> {
files.clear()
filesRoot.set("")
runOnUiThread {
binding.tunnelList.requestFocus()
}
}
else -> super.onBackPressed()
}
} }
private suspend fun updateStats() { private suspend fun updateStats() {
@ -163,6 +305,11 @@ class TvMainActivity : AppCompatActivity() {
} }
} }
class KeyedFile(pathname: String) : File(pathname), Keyed<String> {
override val key: String
get() = if (isDirectory) "$name/" else name
}
companion object { companion object {
private const val TAG = "WireGuard/TvMainActivity" private const val TAG = "WireGuard/TvMainActivity"
} }

View File

@ -9,16 +9,30 @@
<import type="com.wireguard.android.model.ObservableTunnel" /> <import type="com.wireguard.android.model.ObservableTunnel" />
<import type="com.wireguard.android.activity.TvMainActivity.KeyedFile" />
<variable <variable
name="isDeleting" name="isDeleting"
type="androidx.databinding.ObservableBoolean" /> type="androidx.databinding.ObservableBoolean" />
<variable
name="files"
type="com.wireguard.android.databinding.ObservableKeyedArrayList&lt;String, KeyedFile&gt;" />
<variable
name="filesRoot"
type="androidx.databinding.ObservableField&lt;String&gt;" />
<variable <variable
name="tunnels" name="tunnels"
type="com.wireguard.android.databinding.ObservableKeyedArrayList&lt;String, ObservableTunnel&gt;" /> type="com.wireguard.android.databinding.ObservableKeyedArrayList&lt;String, ObservableTunnel&gt;" />
<variable <variable
name="rowConfigurationHandler" name="tunnelRowConfigurationHandler"
type="com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler" />
<variable
name="filesRowConfigurationHandler"
type="com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler" /> type="com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler" />
</data> </data>
@ -54,8 +68,8 @@
android:layout_height="0dp" android:layout_height="0dp"
android:layout_marginTop="16dp" android:layout_marginTop="16dp"
android:orientation="horizontal" android:orientation="horizontal"
android:visibility="@{tunnels.isEmpty() ? View.GONE : View.VISIBLE}" android:visibility="@{(tunnels.isEmpty || !filesRoot.isEmpty) ? View.GONE : View.VISIBLE}"
app:configurationHandler="@{rowConfigurationHandler}" app:configurationHandler="@{tunnelRowConfigurationHandler}"
app:items="@{tunnels}" app:items="@{tunnels}"
app:layout="@{@layout/tv_tunnel_list_item}" app:layout="@{@layout/tv_tunnel_list_item}"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager" app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
@ -66,13 +80,45 @@
tools:itemCount="10" tools:itemCount="10"
tools:listitem="@layout/tv_tunnel_list_item" /> tools:listitem="@layout/tv_tunnel_list_item" />
<TextView
android:id="@+id/files_root_label"
style="@style/TextAppearance.MaterialComponents.Headline5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginStart="8dp"
android:text="@{filesRoot}"
android:visibility="@{filesRoot.isEmpty ? View.GONE : View.VISIBLE}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/banner_logo"
tools:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/files_list"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="16dp"
android:orientation="horizontal"
android:visibility="@{filesRoot.isEmpty ? View.GONE : View.VISIBLE}"
app:configurationHandler="@{filesRowConfigurationHandler}"
app:items="@{files}"
app:layout="@{@layout/tv_file_list_item}"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:layout_constraintBottom_toTopOf="@id/import_button"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/files_root_label"
app:spanCount="5"
tools:itemCount="10"
tools:listitem="@layout/tv_file_list_item"
tools:visibility="gone" />
<TextView <TextView
style="@style/TextAppearance.MaterialComponents.Headline4" style="@style/TextAppearance.MaterialComponents.Headline4"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center_horizontal" android:layout_gravity="center_horizontal"
android:text="@string/tv_add_tunnel_get_started" android:text="@string/tv_add_tunnel_get_started"
android:visibility="@{tunnels.isEmpty() ? View.VISIBLE : View.GONE}" android:visibility="@{(filesRoot.isEmpty &amp;&amp; tunnels.isEmpty) ? View.VISIBLE : View.GONE}"
app:layout_constraintBottom_toTopOf="@id/delete_button" app:layout_constraintBottom_toTopOf="@id/delete_button"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
@ -87,7 +133,7 @@
android:layout_margin="16dp" android:layout_margin="16dp"
android:minWidth="0dp" android:minWidth="0dp"
android:visibility="@{isDeleting ? View.GONE : View.VISIBLE}" android:visibility="@{isDeleting ? View.GONE : View.VISIBLE}"
app:icon="@drawable/ic_action_add_white" app:icon="@{filesRoot.isEmpty ? @drawable/ic_action_add_white : @drawable/ic_arrow_back}"
app:iconPadding="0dp" app:iconPadding="0dp"
app:iconTint="?attr/colorOnPrimary" app:iconTint="?attr/colorOnPrimary"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
@ -100,7 +146,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_margin="16dp" android:layout_margin="16dp"
android:minWidth="0dp" android:minWidth="0dp"
android:visibility="@{tunnels.isEmpty &amp;&amp; !isDeleting ? View.GONE : View.VISIBLE}" android:visibility="@{((tunnels.isEmpty &amp;&amp; !isDeleting) || !filesRoot.isEmpty) ? View.GONE : View.VISIBLE}"
app:icon="@{isDeleting ? @drawable/ic_arrow_back : @drawable/ic_action_delete}" app:icon="@{isDeleting ? @drawable/ic_arrow_back : @drawable/ic_action_delete}"
app:iconPadding="0dp" app:iconPadding="0dp"
app:iconTint="?attr/colorOnPrimary" app:iconTint="?attr/colorOnPrimary"

View File

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<import type="com.wireguard.android.activity.TvMainActivity.KeyedFile" />
<variable
name="key"
type="String" />
<variable
name="item"
type="KeyedFile" />
</data>
<com.google.android.material.card.MaterialCardView
android:layout_width="320dp"
android:layout_height="50dp"
android:layout_margin="8dp"
android:layout_marginTop="4dp"
android:layout_marginBottom="0dp"
android:backgroundTint="@color/tv_card_background"
android:checkable="true"
android:focusable="true"
app:contentPadding="8dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.textview.MaterialTextView
style="@style/TextAppearance.MaterialComponents.Headline5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{key}"
android:textColor="?attr/colorOnPrimary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
</layout>

View File

@ -102,7 +102,7 @@
<string name="dark_theme_title">Use dark theme</string> <string name="dark_theme_title">Use dark theme</string>
<string name="delete">Delete</string> <string name="delete">Delete</string>
<string name="tv_delete">Select tunnel to delete</string> <string name="tv_delete">Select tunnel to delete</string>
<string name="tv_error">Your TV does not have a file picker</string> <string name="tv_select_a_storage_drive">Select a storage drive</string>
<string name="tv_add_tunnel_get_started">Add a tunnel to get started</string> <string name="tv_add_tunnel_get_started">Add a tunnel to get started</string>
<string name="disable_config_export_title">Disable config exporting</string> <string name="disable_config_export_title">Disable config exporting</string>
<string name="disable_config_export_description">Disabling config exporting makes private keys less accessible</string> <string name="disable_config_export_description">Disabling config exporting makes private keys less accessible</string>