aboutsummaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
-rw-r--r--ui/src/main/java/com/wireguard/android/activity/TvMainActivity.kt185
-rw-r--r--ui/src/main/res/layout/tv_activity.xml58
-rw-r--r--ui/src/main/res/layout/tv_file_list_item.xml46
-rw-r--r--ui/src/main/res/values/strings.xml2
4 files changed, 265 insertions, 26 deletions
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<String, KeyedFile>()
+ 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<TvTunnelListItemBinding, ObservableTunnel> {
+ binding.files = files
+ binding.filesRoot = filesRoot
+ binding.tunnelRowConfigurationHandler = object : ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler<TvTunnelListItemBinding, ObservableTunnel> {
override fun onConfigureRow(binding: TvTunnelListItemBinding, item: ObservableTunnel, position: Int) {
binding.isDeleting = isDeleting
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 {
- try {
- tunnelFileImportResultLauncher.launch("*/*")
- } catch (e: ActivityNotFoundException) {
- Toast.makeText(this@TvMainActivity, getString(R.string.tv_error), Toast.LENGTH_LONG).show()
+ 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<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() {
- 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<String> {
+ 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 @@
<import type="com.wireguard.android.model.ObservableTunnel" />
+ <import type="com.wireguard.android.activity.TvMainActivity.KeyedFile" />
+
<variable
name="isDeleting"
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
name="tunnels"
type="com.wireguard.android.databinding.ObservableKeyedArrayList&lt;String, ObservableTunnel&gt;" />
<variable
- name="rowConfigurationHandler"
+ name="tunnelRowConfigurationHandler"
+ type="com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler" />
+
+ <variable
+ name="filesRowConfigurationHandler"
type="com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler" />
</data>
@@ -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"
@@ -67,12 +81,44 @@
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
style="@style/TextAppearance.MaterialComponents.Headline4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
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_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
@@ -87,7 +133,7 @@
android:layout_margin="16dp"
android:minWidth="0dp"
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:iconTint="?attr/colorOnPrimary"
app:layout_constraintBottom_toBottomOf="parent"
@@ -100,7 +146,7 @@
android:layout_height="wrap_content"
android:layout_margin="16dp"
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:iconPadding="0dp"
app:iconTint="?attr/colorOnPrimary"
diff --git a/ui/src/main/res/layout/tv_file_list_item.xml b/ui/src/main/res/layout/tv_file_list_item.xml
new file mode 100644
index 00000000..270a2531
--- /dev/null
+++ b/ui/src/main/res/layout/tv_file_list_item.xml
@@ -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>
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 @@
<string name="dark_theme_title">Use dark theme</string>
<string name="delete">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="disable_config_export_title">Disable config exporting</string>
<string name="disable_config_export_description">Disabling config exporting makes private keys less accessible</string>