tv: abstract out tunnel importing

Signed-off-by: Jason A. Donenfeld <Jason@zx2c4.com>
This commit is contained in:
Jason A. Donenfeld 2020-09-22 13:51:57 +02:00
parent 0ad3781ae5
commit aaa55c0dcc
5 changed files with 175 additions and 267 deletions

View File

@ -5,36 +5,23 @@
package com.wireguard.android.activity package com.wireguard.android.activity
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.provider.OpenableColumns import android.widget.Toast
import android.util.Log
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
import com.google.android.material.snackbar.Snackbar
import com.wireguard.android.Application
import com.wireguard.android.R import com.wireguard.android.R
import com.wireguard.android.model.ObservableTunnel import com.wireguard.android.model.ObservableTunnel
import com.wireguard.android.util.ErrorMessages import com.wireguard.android.util.TunnelImporter
import com.wireguard.config.Config
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.InputStreamReader
import java.nio.charset.StandardCharsets
import java.util.ArrayList
import java.util.Locale
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
class TvMainActivity : BaseActivity() { class TvMainActivity : BaseActivity() {
private val tunnelFileImportResultLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { data -> private val tunnelFileImportResultLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { data ->
importTunnel(data) lifecycleScope.launch {
TunnelImporter.importTunnel(contentResolver, data) {
Toast.makeText(this@TvMainActivity, it, Toast.LENGTH_LONG).show()
}
}
} }
override fun onSelectedTunnelChanged(oldTunnel: ObservableTunnel?, newTunnel: ObservableTunnel?) { override fun onSelectedTunnelChanged(oldTunnel: ObservableTunnel?, newTunnel: ObservableTunnel?) {
@ -48,114 +35,6 @@ class TvMainActivity : BaseActivity() {
} }
} }
private fun onTunnelImportFinished(tunnels: List<ObservableTunnel>, throwables: Collection<Throwable>) {
var message = ""
for (throwable in throwables) {
val error = ErrorMessages[throwable]
message = getString(R.string.import_error, error)
Log.e(TAG, message, throwable)
}
if (tunnels.size == 1 && throwables.isEmpty())
message = getString(R.string.import_success, tunnels[0].name)
else if (tunnels.isEmpty() && throwables.size == 1)
else if (throwables.isEmpty())
message = resources.getQuantityString(R.plurals.import_total_success,
tunnels.size, tunnels.size)
else if (!throwables.isEmpty())
message = resources.getQuantityString(R.plurals.import_partial_success,
tunnels.size + throwables.size,
tunnels.size, tunnels.size + throwables.size)
Snackbar.make(findViewById(android.R.id.content), message, Snackbar.LENGTH_LONG).show()
}
private fun importTunnel(uri: Uri?) {
lifecycleScope.launch {
withContext(Dispatchers.IO) {
if (uri == null) {
return@withContext
}
val futureTunnels = ArrayList<Deferred<ObservableTunnel>>()
val throwables = ArrayList<Throwable>()
try {
val columns = arrayOf(OpenableColumns.DISPLAY_NAME)
var name = ""
contentResolver.query(uri, columns, null, null, null)?.use { cursor ->
if (cursor.moveToFirst() && !cursor.isNull(0)) {
name = cursor.getString(0)
}
}
if (name.isEmpty()) {
name = Uri.decode(uri.lastPathSegment)
}
var idx = name.lastIndexOf('/')
if (idx >= 0) {
require(idx < name.length - 1) { resources.getString(R.string.illegal_filename_error, name) }
name = name.substring(idx + 1)
}
val isZip = name.toLowerCase(Locale.ROOT).endsWith(".zip")
if (name.toLowerCase(Locale.ROOT).endsWith(".conf")) {
name = name.substring(0, name.length - ".conf".length)
} else {
require(isZip) { resources.getString(R.string.bad_extension_error) }
}
if (isZip) {
ZipInputStream(contentResolver.openInputStream(uri)).use { zip ->
val reader = BufferedReader(InputStreamReader(zip, StandardCharsets.UTF_8))
var entry: ZipEntry?
while (true) {
entry = zip.nextEntry ?: break
name = entry.name
idx = name.lastIndexOf('/')
if (idx >= 0) {
if (idx >= name.length - 1) {
continue
}
name = name.substring(name.lastIndexOf('/') + 1)
}
if (name.toLowerCase(Locale.ROOT).endsWith(".conf")) {
name = name.substring(0, name.length - ".conf".length)
} else {
continue
}
try {
Config.parse(reader)
} catch (e: Throwable) {
throwables.add(e)
null
}?.let {
val nameCopy = name
futureTunnels.add(async(SupervisorJob()) { Application.getTunnelManager().create(nameCopy, it) })
}
}
}
} else {
futureTunnels.add(async(SupervisorJob()) { Application.getTunnelManager().create(name, Config.parse(contentResolver.openInputStream(uri)!!)) })
}
if (futureTunnels.isEmpty()) {
if (throwables.size == 1) {
throw throwables[0]
} else {
require(throwables.isNotEmpty()) { resources.getString(R.string.no_configs_error) }
}
}
val tunnels = futureTunnels.mapNotNull {
try {
it.await()
} catch (e: Throwable) {
throwables.add(e)
null
}
}
withContext(Dispatchers.Main.immediate) { onTunnelImportFinished(tunnels, throwables) }
} catch (e: Throwable) {
withContext(Dispatchers.Main.immediate) { onTunnelImportFinished(emptyList(), listOf(e)) }
}
}
}
}
companion object { companion object {
const val TAG = "WireGuard/TvMainActivity" const val TAG = "WireGuard/TvMainActivity"
} }

View File

@ -17,7 +17,6 @@ import com.wireguard.android.backend.Tunnel
import com.wireguard.android.databinding.TunnelDetailFragmentBinding import com.wireguard.android.databinding.TunnelDetailFragmentBinding
import com.wireguard.android.databinding.TunnelDetailPeerBinding import com.wireguard.android.databinding.TunnelDetailPeerBinding
import com.wireguard.android.model.ObservableTunnel import com.wireguard.android.model.ObservableTunnel
import com.wireguard.android.util.formatBytes
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -29,6 +28,16 @@ class TunnelDetailFragment : BaseFragment() {
private var lastState = Tunnel.State.TOGGLE private var lastState = Tunnel.State.TOGGLE
private var timerActive = true private var timerActive = true
private fun formatBytes(bytes: Long): String {
return when {
bytes < 1024 -> getString(R.string.transfer_bytes, bytes)
bytes < 1024 * 1024 -> getString(R.string.transfer_kibibytes, bytes / 1024.0)
bytes < 1024 * 1024 * 1024 -> getString(R.string.transfer_mibibytes, bytes / (1024.0 * 1024.0))
bytes < 1024 * 1024 * 1024 * 1024L -> getString(R.string.transfer_gibibytes, bytes / (1024.0 * 1024.0 * 1024.0))
else -> getString(R.string.transfer_tibibytes, bytes / (1024.0 * 1024.0 * 1024.0) / 1024.0)
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setHasOptionsMenu(true) setHasOptionsMenu(true)
@ -108,7 +117,7 @@ class TunnelDetailFragment : BaseFragment() {
peer.transferText.visibility = View.GONE peer.transferText.visibility = View.GONE
continue continue
} }
peer.transferText.text = getString(R.string.transfer_rx_tx, context?.formatBytes(rx), context?.formatBytes(tx)) peer.transferText.text = getString(R.string.transfer_rx_tx, formatBytes(rx), formatBytes(tx))
peer.transferLabel.visibility = View.VISIBLE peer.transferLabel.visibility = View.VISIBLE
peer.transferText.visibility = View.VISIBLE peer.transferText.visibility = View.VISIBLE
} }

View File

@ -32,6 +32,7 @@ import com.wireguard.android.databinding.TunnelListItemBinding
import com.wireguard.android.fragment.ConfigNamingDialogFragment.Companion.newInstance import com.wireguard.android.fragment.ConfigNamingDialogFragment.Companion.newInstance
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.TunnelImporter
import com.wireguard.android.widget.MultiselectableRelativeLayout import com.wireguard.android.widget.MultiselectableRelativeLayout
import com.wireguard.config.Config import com.wireguard.config.Config
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
@ -59,114 +60,15 @@ class TunnelListFragment : BaseFragment() {
private var actionMode: ActionMode? = null private var actionMode: ActionMode? = null
private var binding: TunnelListFragmentBinding? = null private var binding: TunnelListFragmentBinding? = null
private val tunnelFileImportResultLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { data -> private val tunnelFileImportResultLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { data ->
importTunnel(data) lifecycleScope.launch {
val contentResolver = activity?.contentResolver ?: return@launch
TunnelImporter.importTunnel(contentResolver, data) { showSnackbar(it) }
}
} }
private val qrImportResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> private val qrImportResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val qrCode = IntentIntegrator.parseActivityResult(result.resultCode, result.data) val qrCode = IntentIntegrator.parseActivityResult(result.resultCode, result.data)?.contents ?: return@registerForActivityResult
qrCode?.contents?.let { importTunnel(it) } lifecycleScope.launch { TunnelImporter.importTunnel(parentFragmentManager, qrCode) { showSnackbar(it) } }
}
private fun importTunnel(configText: String) {
try {
// Ensure the config text is parseable before proceeding…
Config.parse(ByteArrayInputStream(configText.toByteArray(StandardCharsets.UTF_8)))
// Config text is valid, now create the tunnel…
newInstance(configText).show(parentFragmentManager, null)
} catch (e: Throwable) {
onTunnelImportFinished(emptyList(), listOf<Throwable>(e))
}
}
private fun importTunnel(uri: Uri?) {
lifecycleScope.launch {
withContext(Dispatchers.IO) {
val activity = activity
if (activity == null || uri == null) {
return@withContext
}
val contentResolver = activity.contentResolver
val futureTunnels = ArrayList<Deferred<ObservableTunnel>>()
val throwables = ArrayList<Throwable>()
try {
val columns = arrayOf(OpenableColumns.DISPLAY_NAME)
var name = ""
contentResolver.query(uri, columns, null, null, null)?.use { cursor ->
if (cursor.moveToFirst() && !cursor.isNull(0)) {
name = cursor.getString(0)
}
}
if (name.isEmpty()) {
name = Uri.decode(uri.lastPathSegment)
}
var idx = name.lastIndexOf('/')
if (idx >= 0) {
require(idx < name.length - 1) { resources.getString(R.string.illegal_filename_error, name) }
name = name.substring(idx + 1)
}
val isZip = name.toLowerCase(Locale.ROOT).endsWith(".zip")
if (name.toLowerCase(Locale.ROOT).endsWith(".conf")) {
name = name.substring(0, name.length - ".conf".length)
} else {
require(isZip) { resources.getString(R.string.bad_extension_error) }
}
if (isZip) {
ZipInputStream(contentResolver.openInputStream(uri)).use { zip ->
val reader = BufferedReader(InputStreamReader(zip, StandardCharsets.UTF_8))
var entry: ZipEntry?
while (true) {
entry = zip.nextEntry ?: break
name = entry.name
idx = name.lastIndexOf('/')
if (idx >= 0) {
if (idx >= name.length - 1) {
continue
}
name = name.substring(name.lastIndexOf('/') + 1)
}
if (name.toLowerCase(Locale.ROOT).endsWith(".conf")) {
name = name.substring(0, name.length - ".conf".length)
} else {
continue
}
try {
Config.parse(reader)
} catch (e: Throwable) {
throwables.add(e)
null
}?.let {
val nameCopy = name
futureTunnels.add(async(SupervisorJob()) { Application.getTunnelManager().create(nameCopy, it) })
}
}
}
} else {
futureTunnels.add(async(SupervisorJob()) { Application.getTunnelManager().create(name, Config.parse(contentResolver.openInputStream(uri)!!)) })
}
if (futureTunnels.isEmpty()) {
if (throwables.size == 1) {
throw throwables[0]
} else {
require(throwables.isNotEmpty()) { resources.getString(R.string.no_configs_error) }
}
}
val tunnels = futureTunnels.mapNotNull {
try {
it.await()
} catch (e: Throwable) {
throwables.add(e)
null
}
}
withContext(Dispatchers.Main.immediate) { onTunnelImportFinished(tunnels, throwables) }
} catch (e: Throwable) {
withContext(Dispatchers.Main.immediate) { onTunnelImportFinished(emptyList(), listOf(e)) }
}
}
}
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@ -241,26 +143,6 @@ class TunnelListFragment : BaseFragment() {
showSnackbar(message) showSnackbar(message)
} }
private fun onTunnelImportFinished(tunnels: List<ObservableTunnel>, throwables: Collection<Throwable>) {
var message = ""
for (throwable in throwables) {
val error = ErrorMessages[throwable]
message = getString(R.string.import_error, error)
Log.e(TAG, message, throwable)
}
if (tunnels.size == 1 && throwables.isEmpty())
message = getString(R.string.import_success, tunnels[0].name)
else if (tunnels.isEmpty() && throwables.size == 1)
else if (throwables.isEmpty())
message = resources.getQuantityString(R.plurals.import_total_success,
tunnels.size, tunnels.size)
else if (!throwables.isEmpty())
message = resources.getQuantityString(R.plurals.import_partial_success,
tunnels.size + throwables.size,
tunnels.size, tunnels.size + throwables.size)
showSnackbar(message)
}
override fun onViewStateRestored(savedInstanceState: Bundle?) { override fun onViewStateRestored(savedInstanceState: Bundle?) {
super.onViewStateRestored(savedInstanceState) super.onViewStateRestored(savedInstanceState)
binding ?: return binding ?: return
@ -423,8 +305,6 @@ class TunnelListFragment : BaseFragment() {
} }
companion object { companion object {
const val REQUEST_IMPORT = 1
private const val REQUEST_TARGET_FRAGMENT = 2
private const val CHECKED_ITEMS = "CHECKED_ITEMS" private const val CHECKED_ITEMS = "CHECKED_ITEMS"
private const val TAG = "WireGuard/TunnelListFragment" private const val TAG = "WireGuard/TunnelListFragment"
} }

View File

@ -21,16 +21,6 @@ fun Context.resolveAttribute(@AttrRes attrRes: Int): Int {
return typedValue.data return typedValue.data
} }
fun Context.formatBytes(bytes: Long): String {
return when {
bytes < 1024 -> getString(R.string.transfer_bytes, bytes)
bytes < 1024 * 1024 -> getString(R.string.transfer_kibibytes, bytes / 1024.0)
bytes < 1024 * 1024 * 1024 -> getString(R.string.transfer_mibibytes, bytes / (1024.0 * 1024.0))
bytes < 1024 * 1024 * 1024 * 1024L -> getString(R.string.transfer_gibibytes, bytes / (1024.0 * 1024.0 * 1024.0))
else -> getString(R.string.transfer_tibibytes, bytes / (1024.0 * 1024.0 * 1024.0) / 1024.0)
}
}
val Any.applicationScope: CoroutineScope val Any.applicationScope: CoroutineScope
get() = Application.getCoroutineScope() get() = Application.getCoroutineScope()

View File

@ -0,0 +1,150 @@
/*
* Copyright © 2020 WireGuard LLC. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
package com.wireguard.android.util
import android.content.ContentResolver
import android.net.Uri
import android.provider.OpenableColumns
import android.util.Log
import androidx.fragment.app.FragmentManager
import com.wireguard.android.Application
import com.wireguard.android.R
import com.wireguard.android.fragment.ConfigNamingDialogFragment
import com.wireguard.android.model.ObservableTunnel
import com.wireguard.config.Config
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.ByteArrayInputStream
import java.io.InputStreamReader
import java.nio.charset.StandardCharsets
import java.util.ArrayList
import java.util.Locale
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
object TunnelImporter {
suspend fun importTunnel(contentResolver: ContentResolver, uri: Uri, messageCallback: (CharSequence) -> Unit) = withContext(Dispatchers.IO) {
val context = Application.get().applicationContext
val futureTunnels = ArrayList<Deferred<ObservableTunnel>>()
val throwables = ArrayList<Throwable>()
try {
val columns = arrayOf(OpenableColumns.DISPLAY_NAME)
var name = ""
contentResolver.query(uri, columns, null, null, null)?.use { cursor ->
if (cursor.moveToFirst() && !cursor.isNull(0)) {
name = cursor.getString(0)
}
}
if (name.isEmpty()) {
name = Uri.decode(uri.lastPathSegment)
}
var idx = name.lastIndexOf('/')
if (idx >= 0) {
require(idx < name.length - 1) { context.getString(R.string.illegal_filename_error, name) }
name = name.substring(idx + 1)
}
val isZip = name.toLowerCase(Locale.ROOT).endsWith(".zip")
if (name.toLowerCase(Locale.ROOT).endsWith(".conf")) {
name = name.substring(0, name.length - ".conf".length)
} else {
require(isZip) { context.getString(R.string.bad_extension_error) }
}
if (isZip) {
ZipInputStream(contentResolver.openInputStream(uri)).use { zip ->
val reader = BufferedReader(InputStreamReader(zip, StandardCharsets.UTF_8))
var entry: ZipEntry?
while (true) {
entry = zip.nextEntry ?: break
name = entry.name
idx = name.lastIndexOf('/')
if (idx >= 0) {
if (idx >= name.length - 1) {
continue
}
name = name.substring(name.lastIndexOf('/') + 1)
}
if (name.toLowerCase(Locale.ROOT).endsWith(".conf")) {
name = name.substring(0, name.length - ".conf".length)
} else {
continue
}
try {
Config.parse(reader)
} catch (e: Throwable) {
throwables.add(e)
null
}?.let {
val nameCopy = name
futureTunnels.add(async(SupervisorJob()) { Application.getTunnelManager().create(nameCopy, it) })
}
}
}
} else {
futureTunnels.add(async(SupervisorJob()) { Application.getTunnelManager().create(name, Config.parse(contentResolver.openInputStream(uri)!!)) })
}
if (futureTunnels.isEmpty()) {
if (throwables.size == 1) {
throw throwables[0]
} else {
require(throwables.isNotEmpty()) { context.getString(R.string.no_configs_error) }
}
}
val tunnels = futureTunnels.mapNotNull {
try {
it.await()
} catch (e: Throwable) {
throwables.add(e)
null
}
}
withContext(Dispatchers.Main.immediate) { onTunnelImportFinished(tunnels, throwables, messageCallback) }
} catch (e: Throwable) {
withContext(Dispatchers.Main.immediate) { onTunnelImportFinished(emptyList(), listOf(e), messageCallback) }
}
}
fun importTunnel(parentFragmentManager: FragmentManager, configText: String, messageCallback: (CharSequence) -> Unit) {
try {
// Ensure the config text is parseable before proceeding…
Config.parse(ByteArrayInputStream(configText.toByteArray(StandardCharsets.UTF_8)))
// Config text is valid, now create the tunnel…
ConfigNamingDialogFragment.newInstance(configText).show(parentFragmentManager, null)
} catch (e: Throwable) {
onTunnelImportFinished(emptyList(), listOf<Throwable>(e), messageCallback)
}
}
private fun onTunnelImportFinished(tunnels: List<ObservableTunnel>, throwables: Collection<Throwable>, messageCallback: (CharSequence) -> Unit) {
val context = Application.get().applicationContext
var message = ""
for (throwable in throwables) {
val error = ErrorMessages[throwable]
message = context.getString(R.string.import_error, error)
Log.e(TAG, message, throwable)
}
if (tunnels.size == 1 && throwables.isEmpty())
message = context.getString(R.string.import_success, tunnels[0].name)
else if (tunnels.isEmpty() && throwables.size == 1)
else if (throwables.isEmpty())
message = context.resources.getQuantityString(R.plurals.import_total_success,
tunnels.size, tunnels.size)
else if (!throwables.isEmpty())
message = context.resources.getQuantityString(R.plurals.import_partial_success,
tunnels.size + throwables.size,
tunnels.size, tunnels.size + throwables.size)
messageCallback(message)
}
private const val TAG = "WireGuard/TunnelImporter"
}