summaryrefslogtreecommitdiffhomepage
path: root/ui/src
diff options
context:
space:
mode:
authorHarsh Shandilya <me@msfjarvis.dev>2020-09-22 16:41:08 +0530
committerJason A. Donenfeld <Jason@zx2c4.com>2020-09-22 23:53:27 +0200
commit0ad3781ae5956aa2dbf217edf48c5f8445a92db8 (patch)
tree7bbf4029e44a73b3a6efe209ed9d5f3b4484c159 /ui/src
parentd738161a2ebd6d6494ea5357026fa33884633ae4 (diff)
tv: initial draft of Android TV support
Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
Diffstat (limited to 'ui/src')
-rw-r--r--ui/src/main/AndroidManifest.xml8
-rw-r--r--ui/src/main/java/com/wireguard/android/activity/TvMainActivity.kt162
-rw-r--r--ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.kt13
-rw-r--r--ui/src/main/java/com/wireguard/android/util/Extensions.kt11
-rw-r--r--ui/src/main/res/layout/tv_activity.xml31
-rw-r--r--ui/src/main/res/layout/tv_tunnel_list_item.xml73
6 files changed, 286 insertions, 12 deletions
diff --git a/ui/src/main/AndroidManifest.xml b/ui/src/main/AndroidManifest.xml
index 613562e1..90691841 100644
--- a/ui/src/main/AndroidManifest.xml
+++ b/ui/src/main/AndroidManifest.xml
@@ -48,7 +48,6 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
- <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter>
@@ -56,6 +55,13 @@
</intent-filter>
</activity>
+ <activity android:name=".activity.TvMainActivity">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN" />
+ <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
+ </intent-filter>
+ </activity>
+
<activity
android:name=".activity.SettingsActivity"
android:label="@string/settings"
diff --git a/ui/src/main/java/com/wireguard/android/activity/TvMainActivity.kt b/ui/src/main/java/com/wireguard/android/activity/TvMainActivity.kt
new file mode 100644
index 00000000..0b03d474
--- /dev/null
+++ b/ui/src/main/java/com/wireguard/android/activity/TvMainActivity.kt
@@ -0,0 +1,162 @@
+/*
+ * Copyright © 2020 WireGuard LLC. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.wireguard.android.activity
+
+import android.net.Uri
+import android.os.Bundle
+import android.provider.OpenableColumns
+import android.util.Log
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.lifecycle.lifecycleScope
+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.model.ObservableTunnel
+import com.wireguard.android.util.ErrorMessages
+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.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() {
+ private val tunnelFileImportResultLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { data ->
+ importTunnel(data)
+ }
+
+ override fun onSelectedTunnelChanged(oldTunnel: ObservableTunnel?, newTunnel: ObservableTunnel?) {
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.tv_activity)
+ findViewById<MaterialButton>(R.id.import_button).setOnClickListener {
+ tunnelFileImportResultLauncher.launch("*/*")
+ }
+ }
+
+ 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 {
+ const val TAG = "WireGuard/TvMainActivity"
+ }
+}
diff --git a/ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.kt b/ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.kt
index ce39fd8f..9b643e5f 100644
--- a/ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.kt
+++ b/ui/src/main/java/com/wireguard/android/fragment/TunnelDetailFragment.kt
@@ -17,6 +17,7 @@ import com.wireguard.android.backend.Tunnel
import com.wireguard.android.databinding.TunnelDetailFragmentBinding
import com.wireguard.android.databinding.TunnelDetailPeerBinding
import com.wireguard.android.model.ObservableTunnel
+import com.wireguard.android.util.formatBytes
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -28,16 +29,6 @@ class TunnelDetailFragment : BaseFragment() {
private var lastState = Tunnel.State.TOGGLE
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?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
@@ -117,7 +108,7 @@ class TunnelDetailFragment : BaseFragment() {
peer.transferText.visibility = View.GONE
continue
}
- peer.transferText.text = getString(R.string.transfer_rx_tx, formatBytes(rx), formatBytes(tx))
+ peer.transferText.text = getString(R.string.transfer_rx_tx, context?.formatBytes(rx), context?.formatBytes(tx))
peer.transferLabel.visibility = View.VISIBLE
peer.transferText.visibility = View.VISIBLE
}
diff --git a/ui/src/main/java/com/wireguard/android/util/Extensions.kt b/ui/src/main/java/com/wireguard/android/util/Extensions.kt
index 2d0899c2..b419feef 100644
--- a/ui/src/main/java/com/wireguard/android/util/Extensions.kt
+++ b/ui/src/main/java/com/wireguard/android/util/Extensions.kt
@@ -11,6 +11,7 @@ import androidx.annotation.AttrRes
import androidx.lifecycle.lifecycleScope
import androidx.preference.Preference
import com.wireguard.android.Application
+import com.wireguard.android.R
import com.wireguard.android.activity.SettingsActivity
import kotlinx.coroutines.CoroutineScope
@@ -20,6 +21,16 @@ fun Context.resolveAttribute(@AttrRes attrRes: Int): Int {
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
get() = Application.getCoroutineScope()
diff --git a/ui/src/main/res/layout/tv_activity.xml b/ui/src/main/res/layout/tv_activity.xml
new file mode 100644
index 00000000..03caa311
--- /dev/null
+++ b/ui/src/main/res/layout/tv_activity.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/tunnel_list"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:layout_marginTop="16dp"
+ android:orientation="horizontal"
+ app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
+ app:layout_constraintBottom_toTopOf="@id/import_button"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent"
+ tools:itemCount="10"
+ tools:listitem="@layout/tv_tunnel_list_item" />
+
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/import_button"
+ style="?attr/textAppearanceButton"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_margin="16dp"
+ android:text="@string/create_from_file"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintEnd_toEndOf="parent" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/ui/src/main/res/layout/tv_tunnel_list_item.xml b/ui/src/main/res/layout/tv_tunnel_list_item.xml
new file mode 100644
index 00000000..a48b977b
--- /dev/null
+++ b/ui/src/main/res/layout/tv_tunnel_list_item.xml
@@ -0,0 +1,73 @@
+<?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"
+ xmlns:tools="http://schemas.android.com/tools">
+
+ <data>
+
+ <import type="com.wireguard.android.model.ObservableTunnel" />
+
+ <import type="com.wireguard.android.backend.Tunnel.State" />
+
+ <variable
+ name="collection"
+ type="com.wireguard.android.databinding.ObservableKeyedArrayList&lt;String, ObservableTunnel&gt;" />
+
+ <variable
+ name="key"
+ type="String" />
+
+ <variable
+ name="item"
+ type="com.wireguard.android.model.ObservableTunnel" />
+
+ <!-- Unused on TV but we retain this so the existing Adapter and ViewHolder can be reused -->
+ <variable
+ name="fragment"
+ type="com.wireguard.android.fragment.TunnelListFragment" />
+ </data>
+
+ <com.google.android.material.card.MaterialCardView
+ android:layout_width="300dp"
+ android:layout_height="150dp"
+ android:layout_marginStart="8dp"
+ android:layout_marginEnd="8dp"
+ app:cardCornerRadius="12dp">
+
+ <androidx.constraintlayout.widget.ConstraintLayout
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:padding="16dp">
+
+ <com.google.android.material.textview.MaterialTextView
+ android:id="@+id/tunnel_name"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@{item.name}"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toTopOf="parent"
+ tools:text="@sample/interface_names.json/names/names/name" />
+
+ <com.wireguard.android.widget.ToggleSwitch
+ android:id="@+id/tunnel_toggle"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ app:checked="@{item.state == State.UP}"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintTop_toTopOf="parent"
+ tools:checked="@sample/interface_names.json/names/checked/checked" />
+
+ <!-- TODO: wire in updates here -->
+ <com.google.android.material.textview.MaterialTextView
+ android:id="@+id/tunnel_transfer"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ tools:text="rx: 200 MB, tx: 100 MB" />
+
+ </androidx.constraintlayout.widget.ConstraintLayout>
+
+ </com.google.android.material.card.MaterialCardView>
+
+</layout>