diff options
-rw-r--r-- | ui/src/main/java/com/wireguard/android/activity/TvMainActivity.kt | 185 | ||||
-rw-r--r-- | ui/src/main/res/layout/tv_activity.xml | 58 | ||||
-rw-r--r-- | ui/src/main/res/layout/tv_file_list_item.xml | 46 | ||||
-rw-r--r-- | ui/src/main/res/values/strings.xml | 2 |
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<String, KeyedFile>" /> + + <variable + name="filesRoot" + type="androidx.databinding.ObservableField<String>" /> + + <variable name="tunnels" type="com.wireguard.android.databinding.ObservableKeyedArrayList<String, ObservableTunnel>" /> <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 && 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 && !isDeleting ? View.GONE : View.VISIBLE}" + android:visibility="@{((tunnels.isEmpty && !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> |