tv: initial draft of Android TV support
Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
This commit is contained in:
parent
d738161a2e
commit
0ad3781ae5
@ -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"
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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()
|
||||
|
||||
|
31
ui/src/main/res/layout/tv_activity.xml
Normal file
31
ui/src/main/res/layout/tv_activity.xml
Normal file
@ -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>
|
73
ui/src/main/res/layout/tv_tunnel_list_item.xml
Normal file
73
ui/src/main/res/layout/tv_tunnel_list_item.xml
Normal file
@ -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<String, ObservableTunnel>" />
|
||||
|
||||
<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>
|
Loading…
Reference in New Issue
Block a user