Skip to content

Commit b3c43e4

Browse files
committed
tv: use our own file picker
Signed-off-by: Jason A. Donenfeld <[email protected]>
1 parent 7bec539 commit b3c43e4

File tree

4 files changed

+265
-26
lines changed

4 files changed

+265
-26
lines changed

ui/src/main/java/com/wireguard/android/activity/TvMainActivity.kt

Lines changed: 166 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,41 +5,47 @@
55

66
package com.wireguard.android.activity
77

8-
import android.content.ActivityNotFoundException
8+
import android.Manifest
9+
import android.content.pm.PackageManager
10+
import android.net.Uri
11+
import android.os.Build
912
import android.os.Bundle
13+
import android.os.Environment
14+
import android.os.storage.StorageManager
15+
import android.os.storage.StorageVolume
1016
import android.util.Log
1117
import android.view.View
1218
import android.widget.Toast
1319
import androidx.activity.result.contract.ActivityResultContracts
1420
import androidx.appcompat.app.AppCompatActivity
21+
import androidx.core.content.ContextCompat
22+
import androidx.core.content.getSystemService
1523
import androidx.core.view.forEach
1624
import androidx.databinding.DataBindingUtil
1725
import androidx.databinding.ObservableBoolean
26+
import androidx.databinding.ObservableField
1827
import androidx.lifecycle.lifecycleScope
1928
import com.wireguard.android.Application
2029
import com.wireguard.android.R
2130
import com.wireguard.android.backend.GoBackend
2231
import com.wireguard.android.backend.Tunnel
32+
import com.wireguard.android.databinding.Keyed
33+
import com.wireguard.android.databinding.ObservableKeyedArrayList
2334
import com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter
2435
import com.wireguard.android.databinding.TvActivityBinding
36+
import com.wireguard.android.databinding.TvFileListItemBinding
2537
import com.wireguard.android.databinding.TvTunnelListItemBinding
2638
import com.wireguard.android.model.ObservableTunnel
2739
import com.wireguard.android.util.ErrorMessages
2840
import com.wireguard.android.util.QuantityFormatter
2941
import com.wireguard.android.util.TunnelImporter
42+
import kotlinx.coroutines.Dispatchers
3043
import kotlinx.coroutines.delay
3144
import kotlinx.coroutines.launch
45+
import kotlinx.coroutines.withContext
46+
import java.io.File
3247

3348
class TvMainActivity : AppCompatActivity() {
34-
private val tunnelFileImportResultLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { data ->
35-
if (data == null) return@registerForActivityResult
36-
lifecycleScope.launch {
37-
TunnelImporter.importTunnel(contentResolver, data) {
38-
Toast.makeText(this@TvMainActivity, it, Toast.LENGTH_LONG).show()
39-
}
40-
}
41-
}
42-
4349
private var pendingTunnel: ObservableTunnel? = null
4450
private val permissionActivityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
4551
val tunnel = pendingTunnel
@@ -64,6 +70,8 @@ class TvMainActivity : AppCompatActivity() {
6470

6571
private lateinit var binding: TvActivityBinding
6672
private val isDeleting = ObservableBoolean()
73+
private val files = ObservableKeyedArrayList<String, KeyedFile>()
74+
private val filesRoot = ObservableField("")
6775

6876
override fun onCreate(savedInstanceState: Bundle?) {
6977
super.onCreate(savedInstanceState)
@@ -76,7 +84,9 @@ class TvMainActivity : AppCompatActivity() {
7684
binding.tunnelList.requestFocus()
7785
}
7886
binding.isDeleting = isDeleting
79-
binding.rowConfigurationHandler = object : ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler<TvTunnelListItemBinding, ObservableTunnel> {
87+
binding.files = files
88+
binding.filesRoot = filesRoot
89+
binding.tunnelRowConfigurationHandler = object : ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler<TvTunnelListItemBinding, ObservableTunnel> {
8090
override fun onConfigureRow(binding: TvTunnelListItemBinding, item: ObservableTunnel, position: Int) {
8191
binding.isDeleting = isDeleting
8292
binding.isFocused = ObservableBoolean()
@@ -111,13 +121,44 @@ class TvMainActivity : AppCompatActivity() {
111121
}
112122
}
113123
}
124+
125+
binding.filesRowConfigurationHandler = object : ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler<TvFileListItemBinding, KeyedFile> {
126+
override fun onConfigureRow(binding: TvFileListItemBinding, item: KeyedFile, position: Int) {
127+
binding.root.setOnClickListener {
128+
if (item.isDirectory)
129+
navigateTo(item)
130+
else {
131+
val uri = Uri.fromFile(item.canonicalFile)
132+
files.clear()
133+
filesRoot.set("")
134+
lifecycleScope.launch {
135+
TunnelImporter.importTunnel(contentResolver, uri) {
136+
Toast.makeText(this@TvMainActivity, it, Toast.LENGTH_LONG).show()
137+
}
138+
}
139+
runOnUiThread {
140+
this@TvMainActivity.binding.tunnelList.requestFocus()
141+
}
142+
}
143+
}
144+
}
145+
}
146+
114147
binding.importButton.setOnClickListener {
115-
try {
116-
tunnelFileImportResultLauncher.launch("*/*")
117-
} catch (e: ActivityNotFoundException) {
118-
Toast.makeText(this@TvMainActivity, getString(R.string.tv_error), Toast.LENGTH_LONG).show()
148+
if (filesRoot.get()?.isEmpty() != false) {
149+
navigateTo(myComputerFile)
150+
runOnUiThread {
151+
binding.filesList.requestFocus()
152+
}
153+
} else {
154+
files.clear()
155+
filesRoot.set("")
156+
runOnUiThread {
157+
binding.tunnelList.requestFocus()
158+
}
119159
}
120160
}
161+
121162
binding.deleteButton.setOnClickListener {
122163
isDeleting.set(!isDeleting.get())
123164
runOnUiThread {
@@ -135,11 +176,112 @@ class TvMainActivity : AppCompatActivity() {
135176
}
136177
}
137178

179+
private var pendingNavigation: File? = null
180+
private val permissionRequestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {
181+
val to = pendingNavigation
182+
if (it && to != null)
183+
navigateTo(to)
184+
pendingNavigation = null
185+
}
186+
187+
private suspend fun makeStorageRoots(): Collection<KeyedFile> = withContext(Dispatchers.IO) {
188+
val list = HashSet<KeyedFile>()
189+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
190+
val storageManager: StorageManager = getSystemService() ?: return@withContext list
191+
list.addAll(storageManager.storageVolumes.mapNotNull { volume ->
192+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
193+
volume.directory?.let { KeyedFile(it.canonicalPath) }
194+
} else {
195+
KeyedFile((StorageVolume::class.java.getMethod("getPathFile").invoke(volume) as File).canonicalPath)
196+
}
197+
})
198+
} else {
199+
@Suppress("DEPRECATION")
200+
list.add(KeyedFile(Environment.getExternalStorageDirectory().canonicalPath))
201+
try {
202+
File("/storage").listFiles()?.forEach {
203+
if (!it.isDirectory) return@forEach
204+
try {
205+
if (Environment.isExternalStorageRemovable(it)) {
206+
list.add(KeyedFile(it.canonicalPath))
207+
}
208+
} catch (_: Throwable) {
209+
}
210+
}
211+
} catch (_: Throwable) {
212+
}
213+
}
214+
list
215+
}
216+
217+
private val myComputerFile = File("")
218+
219+
private fun navigateTo(directory: File) {
220+
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
221+
pendingNavigation = directory
222+
permissionRequestPermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
223+
return
224+
}
225+
226+
lifecycleScope.launch {
227+
if (directory == myComputerFile) {
228+
val roots = makeStorageRoots()
229+
if (roots.count() == 1) {
230+
navigateTo(roots.first())
231+
return@launch
232+
}
233+
files.clear()
234+
files.addAll(roots)
235+
filesRoot.set(getString(R.string.tv_select_a_storage_drive))
236+
return@launch
237+
}
238+
239+
val newFiles = withContext(Dispatchers.IO) {
240+
val newFiles = ArrayList<KeyedFile>()
241+
try {
242+
val parent = KeyedFile(directory.canonicalPath + "/..")
243+
if (directory.canonicalPath != "/" && parent.list() != null)
244+
newFiles.add(parent)
245+
val listing = directory.listFiles() ?: return@withContext null
246+
listing.forEach {
247+
if (it.extension == "conf" || it.extension == "zip" || it.isDirectory)
248+
newFiles.add(KeyedFile(it.canonicalPath))
249+
}
250+
newFiles.sortWith { a, b ->
251+
if (a.isDirectory && !b.isDirectory) -1
252+
else if (!a.isDirectory && b.isDirectory) 1
253+
else a.compareTo(b)
254+
}
255+
} catch (e: Throwable) {
256+
Log.e(TAG, Log.getStackTraceString(e))
257+
}
258+
newFiles
259+
}
260+
if (newFiles?.isEmpty() != false)
261+
return@launch
262+
files.clear()
263+
files.addAll(newFiles)
264+
filesRoot.set(directory.canonicalPath)
265+
}
266+
}
267+
138268
override fun onBackPressed() {
139-
if (isDeleting.get())
140-
isDeleting.set(false)
141-
else
142-
super.onBackPressed()
269+
when {
270+
isDeleting.get() -> {
271+
isDeleting.set(false)
272+
runOnUiThread {
273+
binding.tunnelList.requestFocus()
274+
}
275+
}
276+
filesRoot.get()?.isNotEmpty() == true -> {
277+
files.clear()
278+
filesRoot.set("")
279+
runOnUiThread {
280+
binding.tunnelList.requestFocus()
281+
}
282+
}
283+
else -> super.onBackPressed()
284+
}
143285
}
144286

145287
private suspend fun updateStats() {
@@ -163,6 +305,11 @@ class TvMainActivity : AppCompatActivity() {
163305
}
164306
}
165307

308+
class KeyedFile(pathname: String) : File(pathname), Keyed<String> {
309+
override val key: String
310+
get() = if (isDirectory) "$name/" else name
311+
}
312+
166313
companion object {
167314
private const val TAG = "WireGuard/TvMainActivity"
168315
}

ui/src/main/res/layout/tv_activity.xml

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,30 @@
99

1010
<import type="com.wireguard.android.model.ObservableTunnel" />
1111

12+
<import type="com.wireguard.android.activity.TvMainActivity.KeyedFile" />
13+
1214
<variable
1315
name="isDeleting"
1416
type="androidx.databinding.ObservableBoolean" />
1517

18+
<variable
19+
name="files"
20+
type="com.wireguard.android.databinding.ObservableKeyedArrayList&lt;String, KeyedFile&gt;" />
21+
22+
<variable
23+
name="filesRoot"
24+
type="androidx.databinding.ObservableField&lt;String&gt;" />
25+
1626
<variable
1727
name="tunnels"
1828
type="com.wireguard.android.databinding.ObservableKeyedArrayList&lt;String, ObservableTunnel&gt;" />
1929

2030
<variable
21-
name="rowConfigurationHandler"
31+
name="tunnelRowConfigurationHandler"
32+
type="com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler" />
33+
34+
<variable
35+
name="filesRowConfigurationHandler"
2236
type="com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter.RowConfigurationHandler" />
2337
</data>
2438

@@ -54,8 +68,8 @@
5468
android:layout_height="0dp"
5569
android:layout_marginTop="16dp"
5670
android:orientation="horizontal"
57-
android:visibility="@{tunnels.isEmpty() ? View.GONE : View.VISIBLE}"
58-
app:configurationHandler="@{rowConfigurationHandler}"
71+
android:visibility="@{(tunnels.isEmpty || !filesRoot.isEmpty) ? View.GONE : View.VISIBLE}"
72+
app:configurationHandler="@{tunnelRowConfigurationHandler}"
5973
app:items="@{tunnels}"
6074
app:layout="@{@layout/tv_tunnel_list_item}"
6175
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
@@ -66,13 +80,45 @@
6680
tools:itemCount="10"
6781
tools:listitem="@layout/tv_tunnel_list_item" />
6882

83+
<TextView
84+
android:id="@+id/files_root_label"
85+
style="@style/TextAppearance.MaterialComponents.Headline5"
86+
android:layout_width="wrap_content"
87+
android:layout_height="wrap_content"
88+
android:layout_gravity="center_horizontal"
89+
android:layout_marginStart="8dp"
90+
android:text="@{filesRoot}"
91+
android:visibility="@{filesRoot.isEmpty ? View.GONE : View.VISIBLE}"
92+
app:layout_constraintStart_toStartOf="parent"
93+
app:layout_constraintTop_toBottomOf="@id/banner_logo"
94+
tools:visibility="gone" />
95+
96+
<androidx.recyclerview.widget.RecyclerView
97+
android:id="@+id/files_list"
98+
android:layout_width="match_parent"
99+
android:layout_height="0dp"
100+
android:layout_marginTop="16dp"
101+
android:orientation="horizontal"
102+
android:visibility="@{filesRoot.isEmpty ? View.GONE : View.VISIBLE}"
103+
app:configurationHandler="@{filesRowConfigurationHandler}"
104+
app:items="@{files}"
105+
app:layout="@{@layout/tv_file_list_item}"
106+
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
107+
app:layout_constraintBottom_toTopOf="@id/import_button"
108+
app:layout_constraintStart_toStartOf="parent"
109+
app:layout_constraintTop_toBottomOf="@id/files_root_label"
110+
app:spanCount="5"
111+
tools:itemCount="10"
112+
tools:listitem="@layout/tv_file_list_item"
113+
tools:visibility="gone" />
114+
69115
<TextView
70116
style="@style/TextAppearance.MaterialComponents.Headline4"
71117
android:layout_width="wrap_content"
72118
android:layout_height="wrap_content"
73119
android:layout_gravity="center_horizontal"
74120
android:text="@string/tv_add_tunnel_get_started"
75-
android:visibility="@{tunnels.isEmpty() ? View.VISIBLE : View.GONE}"
121+
android:visibility="@{(filesRoot.isEmpty &amp;&amp; tunnels.isEmpty) ? View.VISIBLE : View.GONE}"
76122
app:layout_constraintBottom_toTopOf="@id/delete_button"
77123
app:layout_constraintEnd_toEndOf="parent"
78124
app:layout_constraintStart_toStartOf="parent"
@@ -87,7 +133,7 @@
87133
android:layout_margin="16dp"
88134
android:minWidth="0dp"
89135
android:visibility="@{isDeleting ? View.GONE : View.VISIBLE}"
90-
app:icon="@drawable/ic_action_add_white"
136+
app:icon="@{filesRoot.isEmpty ? @drawable/ic_action_add_white : @drawable/ic_arrow_back}"
91137
app:iconPadding="0dp"
92138
app:iconTint="?attr/colorOnPrimary"
93139
app:layout_constraintBottom_toBottomOf="parent"
@@ -100,7 +146,7 @@
100146
android:layout_height="wrap_content"
101147
android:layout_margin="16dp"
102148
android:minWidth="0dp"
103-
android:visibility="@{tunnels.isEmpty &amp;&amp; !isDeleting ? View.GONE : View.VISIBLE}"
149+
android:visibility="@{((tunnels.isEmpty &amp;&amp; !isDeleting) || !filesRoot.isEmpty) ? View.GONE : View.VISIBLE}"
104150
app:icon="@{isDeleting ? @drawable/ic_arrow_back : @drawable/ic_action_delete}"
105151
app:iconPadding="0dp"
106152
app:iconTint="?attr/colorOnPrimary"

0 commit comments

Comments
 (0)