5
5
6
6
package com.wireguard.android.activity
7
7
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
9
12
import android.os.Bundle
13
+ import android.os.Environment
14
+ import android.os.storage.StorageManager
15
+ import android.os.storage.StorageVolume
10
16
import android.util.Log
11
17
import android.view.View
12
18
import android.widget.Toast
13
19
import androidx.activity.result.contract.ActivityResultContracts
14
20
import androidx.appcompat.app.AppCompatActivity
21
+ import androidx.core.content.ContextCompat
22
+ import androidx.core.content.getSystemService
15
23
import androidx.core.view.forEach
16
24
import androidx.databinding.DataBindingUtil
17
25
import androidx.databinding.ObservableBoolean
26
+ import androidx.databinding.ObservableField
18
27
import androidx.lifecycle.lifecycleScope
19
28
import com.wireguard.android.Application
20
29
import com.wireguard.android.R
21
30
import com.wireguard.android.backend.GoBackend
22
31
import com.wireguard.android.backend.Tunnel
32
+ import com.wireguard.android.databinding.Keyed
33
+ import com.wireguard.android.databinding.ObservableKeyedArrayList
23
34
import com.wireguard.android.databinding.ObservableKeyedRecyclerViewAdapter
24
35
import com.wireguard.android.databinding.TvActivityBinding
36
+ import com.wireguard.android.databinding.TvFileListItemBinding
25
37
import com.wireguard.android.databinding.TvTunnelListItemBinding
26
38
import com.wireguard.android.model.ObservableTunnel
27
39
import com.wireguard.android.util.ErrorMessages
28
40
import com.wireguard.android.util.QuantityFormatter
29
41
import com.wireguard.android.util.TunnelImporter
42
+ import kotlinx.coroutines.Dispatchers
30
43
import kotlinx.coroutines.delay
31
44
import kotlinx.coroutines.launch
45
+ import kotlinx.coroutines.withContext
46
+ import java.io.File
32
47
33
48
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
-
43
49
private var pendingTunnel: ObservableTunnel ? = null
44
50
private val permissionActivityResultLauncher = registerForActivityResult(ActivityResultContracts .StartActivityForResult ()) {
45
51
val tunnel = pendingTunnel
@@ -64,6 +70,8 @@ class TvMainActivity : AppCompatActivity() {
64
70
65
71
private lateinit var binding: TvActivityBinding
66
72
private val isDeleting = ObservableBoolean ()
73
+ private val files = ObservableKeyedArrayList <String , KeyedFile >()
74
+ private val filesRoot = ObservableField (" " )
67
75
68
76
override fun onCreate (savedInstanceState : Bundle ? ) {
69
77
super .onCreate(savedInstanceState)
@@ -76,7 +84,9 @@ class TvMainActivity : AppCompatActivity() {
76
84
binding.tunnelList.requestFocus()
77
85
}
78
86
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 > {
80
90
override fun onConfigureRow (binding : TvTunnelListItemBinding , item : ObservableTunnel , position : Int ) {
81
91
binding.isDeleting = isDeleting
82
92
binding.isFocused = ObservableBoolean ()
@@ -111,13 +121,44 @@ class TvMainActivity : AppCompatActivity() {
111
121
}
112
122
}
113
123
}
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
+
114
147
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
+ }
119
159
}
120
160
}
161
+
121
162
binding.deleteButton.setOnClickListener {
122
163
isDeleting.set(! isDeleting.get())
123
164
runOnUiThread {
@@ -135,11 +176,112 @@ class TvMainActivity : AppCompatActivity() {
135
176
}
136
177
}
137
178
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
+
138
268
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
+ }
143
285
}
144
286
145
287
private suspend fun updateStats () {
@@ -163,6 +305,11 @@ class TvMainActivity : AppCompatActivity() {
163
305
}
164
306
}
165
307
308
+ class KeyedFile (pathname : String ) : File(pathname), Keyed<String> {
309
+ override val key: String
310
+ get() = if (isDirectory) " $name /" else name
311
+ }
312
+
166
313
companion object {
167
314
private const val TAG = " WireGuard/TvMainActivity"
168
315
}
0 commit comments