Commit c7e62979 authored by Daniel Wolf's avatar Daniel Wolf
Browse files

Merge branch '88-add-server-speed-testing' into 'master'

Resolve "Add server speed testing"

Closes #88

See merge request !22
parents 3bd1a465 7fe742ad
Pipeline #4765 passed with stage
in 5 minutes and 38 seconds
......@@ -170,6 +170,11 @@
<action android:name="com.twofortyfouram.locale.intent.action.EDIT_SETTING"/>
</intent-filter>
</activity>
<activity
android:name=".activity.SpeedTestActivity"
android:theme="@style/AppTheme_Mono"
android:label="@string/activity_label_speed_test"
/>
<service
android:name=".service.DnsVpnService"
......
package com.frostnerd.smokescreen.activity
import android.graphics.Color
import android.graphics.Rect
import android.os.Bundle
import android.view.View
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.frostnerd.cacheadapter.AdapterBuilder
import com.frostnerd.design.DesignUtil
import com.frostnerd.dnstunnelproxy.DnsServerInformation
import com.frostnerd.encrypteddnstunnelproxy.AbstractHttpsDNSHandle
import com.frostnerd.encrypteddnstunnelproxy.HttpsDnsServerInformation
import com.frostnerd.encrypteddnstunnelproxy.tls.AbstractTLSDnsHandle
import com.frostnerd.lifecyclemanagement.BaseActivity
import com.frostnerd.lifecyclemanagement.BaseViewHolder
import com.frostnerd.lifecyclemanagement.launchWithLifecylce
import com.frostnerd.smokescreen.R
import com.frostnerd.smokescreen.getPreferences
import com.frostnerd.smokescreen.showInfoTextDialog
import com.frostnerd.smokescreen.util.speedtest.DnsSpeedTest
import kotlinx.android.synthetic.main.activity_speedtest.*
import kotlinx.android.synthetic.main.item_dns_speed.view.*
import kotlinx.coroutines.Job
/*
* Copyright (C) 2019 Daniel Wolf (Ch4t4r)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* You can contact the developer at daniel.wolf@frostnerd.com.
*/
class SpeedTestActivity : BaseActivity() {
private var testRunning = false
private var wasStartedBefore = false
private var testJob: Job? = null
private var testResults:MutableList<SpeedTest>? = null
private var listAdapter:RecyclerView.Adapter<*>? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_speedtest)
setSupportActionBar(toolBar)
startTest.setOnClickListener {
startTest()
startTest.isEnabled = false
abort.visibility = View.VISIBLE
info.visibility = View.GONE
}
abort.setOnClickListener {
abort.visibility = View.GONE
testJob?.cancel()
testJob = null
startTest.isEnabled = true
testRunning = false
info.visibility = View.VISIBLE
startTest.text = getString(R.string.window_speedtest_runtest)
}
info.setOnClickListener {
if(testResults != null) {
val dotCount = testResults!!.count { it.server !is HttpsDnsServerInformation }
val dotReachable = testResults!!.count { it.server !is HttpsDnsServerInformation && it.latency != null}
val dotNotReachable = dotCount - dotReachable
val dohCount = testResults!!.size - dotCount
val dohReachable = testResults!!.count { it.server is HttpsDnsServerInformation && it.latency != null}
val dohNotReachable = dohCount - dohReachable
val avgLatency = testResults!!.sumBy { it.latency ?: 0 }/testResults!!.size
val fastestServer = testResults!!.minBy { it.latency ?: Integer.MAX_VALUE}
val slowestServer = testResults!!.minBy { it.latency ?: 0}
showInfoTextDialog(this,
getString(R.string.dialog_speedresult_title),
getString(R.string.dialog_speedresult_message,
testResults!!.size,
dotReachable,
dotNotReachable,
dohReachable,
dohNotReachable,
avgLatency,
fastestServer?.server?.name ?: "-",
slowestServer?.server?.name ?: "-"
))
}
}
serverList.layoutManager = LinearLayoutManager(this)
serverList.addItemDecoration(SpaceItemDecorator())
prepareList()
}
private fun prepareList(joinWithJob:Boolean = true) {
launchWithLifecylce(true) {
if(joinWithJob) testJob?.join()
val dnsServers = AbstractTLSDnsHandle.KNOWN_DNS_SERVERS.values +
AbstractHttpsDNSHandle.KNOWN_DNS_SERVERS.values +
getPreferences().userServers.map {
it.serverInformation
}
val testResults = dnsServers.map {
SpeedTest(it, null)
}.toMutableList()
this@SpeedTestActivity.testResults = testResults
val showUseServerDialog = { test:SpeedTest ->
showInfoTextDialog(this@SpeedTestActivity,
getString(R.string.dialog_speedtest_useserver_title),
getString(R.string.dialog_speedtest_useserver_message,
test.server.name,
testResults.indexOf(test) + 1,
testResults.size,
test.latency!!
),
getString(R.string.all_yes) to { dialog, _ ->
getPreferences().dnsServerConfig = test.server
dialog.dismiss()
}, getString(R.string.all_no) to { dialog, _ ->
dialog.dismiss()
}, null)
}
listAdapter = AdapterBuilder.withViewHolder({ SpeedViewHolder(it, showUseServerDialog) }) {
viewBuilder = { parent, _ ->
layoutInflater.inflate(R.layout.item_dns_speed, parent, false)
}
getItemCount = {
testResults.size
}
bindView = { viewHolder, position ->
viewHolder.display(testResults[position])
}
}.build()
runOnUiThread {
serverList.adapter = listAdapter
}
}
}
private fun startTest() {
if(wasStartedBefore) prepareList(true)
testJob = launchWithLifecylce(false) {
testRunning = true
wasStartedBefore = true
val testsLeft = testResults!!.shuffled()
var cnt = 0
startTest.text = "0/${testsLeft.size}"
testsLeft.forEach {
if(testJob?.isCancelled == false) {
it.started = true
val res = DnsSpeedTest(it.server, 500, 750).runTest(3)
if (res != null) it.latency = res
else it.error = true
testResults!!.sortBy {
it.latency ?: Integer.MAX_VALUE
}
runOnUiThread {
cnt++
listAdapter!!.notifyDataSetChanged()
startTest.text = "$cnt/${testResults!!.size}"
}
}
}
if(testJob?.isCancelled == false)runOnUiThread {
startTest.isEnabled = true
abort.visibility = View.GONE
startTest.text = getString(R.string.window_speedtest_runtest)
testRunning = false
testJob = null
info.visibility = View.VISIBLE
}
}
}
override fun getConfiguration(): Configuration {
return Configuration.withDefaults()
}
private inner class SpeedViewHolder(view: View, private val showUseServerDialog:(SpeedTest) -> Any) : BaseViewHolder(view) {
val name = view.name
val servers = view.servers
val progress = view.progress
val latency = view.latency
val serverType = view.serverType
val nameWrap = view.nameWrap
private var defaultTextColor = latency.currentTextColor
fun display(speedTest: SpeedTest) {
if(speedTest.latency != null) {
val listener:(View) ->Unit = { showUseServerDialog(speedTest) }
itemView.setOnClickListener(listener)
nameWrap.setOnClickListener(listener)
} else {
itemView.setOnClickListener(null)
nameWrap.setOnClickListener(null)
}
name.text = speedTest.server.name
servers.text = buildString {
speedTest.server.servers.forEach {
append(it.address.formatToString())
append("\n")
}
}
serverType.text = if(speedTest.server is HttpsDnsServerInformation) getString(R.string.tasker_mode_doh)
else getString(R.string.tasker_mode_dot)
if (speedTest.latency == null) {
when {
speedTest.error -> {
latency.text = "- ms"
latency.setTextColor(Color.RED)
progress.visibility = View.INVISIBLE
latency.visibility = View.VISIBLE
}
speedTest.started -> {
progress.visibility = View.VISIBLE
latency.visibility = View.INVISIBLE
}
else -> {
latency.text = "? ms"
latency.visibility = View.VISIBLE
progress.visibility = View.INVISIBLE
latency.setTextColor(defaultTextColor)
}
}
} else {
latency.text = "${speedTest.latency} ms"
latency.setTextColor(defaultTextColor)
progress.visibility = View.INVISIBLE
latency.visibility = View.VISIBLE
}
}
override fun destroy() {}
}
private class SpeedTest(val server: DnsServerInformation<*>, var latency: Int?) {
var error: Boolean = false
var started:Boolean = false
}
private inner class SpaceItemDecorator() : RecyclerView.ItemDecoration() {
private val decorationHeight: Int = DesignUtil.dpToPixels(12f, this@SpeedTestActivity).toInt()
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
super.getItemOffsets(outRect, view, parent, state)
val itemPosition = parent.getChildAdapterPosition(view)
val totalCount = parent.adapter!!.itemCount
if (itemPosition >= 0 && itemPosition < totalCount - 1) {
outRect.bottom = decorationHeight
}
}
}
}
\ No newline at end of file
......@@ -18,6 +18,7 @@ import androidx.fragment.app.Fragment
import com.frostnerd.dnstunnelproxy.DnsServerInformation
import com.frostnerd.general.service.isServiceRunning
import com.frostnerd.smokescreen.R
import com.frostnerd.smokescreen.activity.SpeedTestActivity
import com.frostnerd.smokescreen.dialog.ServerChoosalDialog
import com.frostnerd.smokescreen.getPreferences
import com.frostnerd.smokescreen.registerLocalReceiver
......@@ -77,6 +78,9 @@ class MainFragment : Fragment() {
}
updateVpnIndicators()
}
speedTest.setOnClickListener {
startActivity(Intent(context!!, SpeedTestActivity::class.java))
}
vpnStateReceiver = requireContext().registerLocalReceiver(
listOf(
DnsVpnService.BROADCAST_VPN_ACTIVE,
......
package com.frostnerd.smokescreen.util.speedtest
import androidx.annotation.IntRange
import com.frostnerd.dnstunnelproxy.DnsServerInformation
import com.frostnerd.dnstunnelproxy.UpstreamAddress
import com.frostnerd.encrypteddnstunnelproxy.*
import com.frostnerd.encrypteddnstunnelproxy.tls.TLSUpstreamAddress
import okhttp3.*
import org.minidns.dnsmessage.DnsMessage
import org.minidns.dnsmessage.Question
import org.minidns.record.Record
import java.io.DataInputStream
import java.io.DataOutputStream
import java.lang.Exception
import java.net.*
import java.time.Duration
import java.time.temporal.TemporalUnit
import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLSocketFactory
import kotlin.random.Random
/*
* Copyright (C) 2019 Daniel Wolf (Ch4t4r)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* You can contact the developer at daniel.wolf@frostnerd.com.
*/
class DnsSpeedTest(val server: DnsServerInformation<*>, val connectTimeout: Int = 2500, val readTimeout:Int = 1500) {
private val httpClient by lazy {
OkHttpClient.Builder()
.dns(httpsDnsClient)
.connectTimeout(3, TimeUnit.SECONDS)
.readTimeout(readTimeout.toLong(), TimeUnit.MILLISECONDS)
.build()
}
private val httpsDnsClient by lazy {
PinnedDns((server as HttpsDnsServerInformation).serverConfigurations.values.map {
it.urlCreator.address
})
}
companion object {
val testDomains = listOf("google.com", "frostnerd.com", "amazon.com", "youtube.com", "github.com",
"stackoverflow.com", "stackexchange.com", "spotify.com", "material.io", "reddit.com", "android.com")
}
/**
* @param passes The amount of requests to make
* @return The average response time (in ms)
*/
fun runTest(@IntRange(from = 1) passes: Int): Int? {
println("Running test for ${server.name}")
var ttl = 0
for (i in 0 until passes) {
if (server is HttpsDnsServerInformation) {
server.serverConfigurations.values.forEach {
ttl += testHttps(it) ?: 0
}
} else {
(server as DnsServerInformation<TLSUpstreamAddress>).servers.forEach {
ttl += testTls(it.address) ?: 0
}
}
}
return (ttl / passes).let {
if (it <= 0) null else it
}
}
private fun testHttps(config: ServerConfiguration): Int? {
val msg = createTestDnsPacket()
val url: URL = config.urlCreator.createUrl(msg, config.urlCreator.address)
val requestBuilder = Request.Builder().url(url)
if (config.requestHasBody) {
val body = config.bodyCreator!!.createBody(msg, config.urlCreator.address)
if (body != null) {
requestBuilder.header("Content-Type", config.contentType)
requestBuilder.post(RequestBody.create(body.mediaType, body.rawBody))
} else {
return null
}
}
var response:Response? = null
try {
val start = System.currentTimeMillis()
response = httpClient.newCall(requestBuilder.build()).execute()
if(!response.isSuccessful) return null
val body = response.body() ?: return null
val bytes = body.bytes()
val time = (System.currentTimeMillis() - start).toInt()
if (bytes.size < 17) {
return null
} else if(!testResponse(DnsMessage(bytes))) {
return null
}
return time
} catch (ex: Exception) {
return null
} finally {
if(response?.body() != null) response.close()
}
}
private fun testTls(address: TLSUpstreamAddress): Int? {
val addr =
address.addressCreator.resolveOrGetResultOrNull(retryIfError = true, runResolveNow = true) ?: return null
var socket: Socket? = null
try {
socket = SSLSocketFactory.getDefault().createSocket()
val msg = createTestDnsPacket()
val start = System.currentTimeMillis()
socket.connect(InetSocketAddress(addr[0], address.port), connectTimeout)
socket.soTimeout = readTimeout
val data: ByteArray = msg.toArray()
val outputStream = DataOutputStream(socket.getOutputStream())
val size = data.size
val arr: ByteArray = byteArrayOf(((size shr 8) and 0xFF).toByte(), (size and 0xFF).toByte())
outputStream.write(arr)
outputStream.write(data)
outputStream.flush()
val inStream = DataInputStream(socket.getInputStream())
val readData = ByteArray(inStream.readUnsignedShort())
inStream.read(readData)
val time = (System.currentTimeMillis() - start).toInt()
socket.close()
socket = null
if(!testResponse(DnsMessage(readData))) return null
return time
} catch (ex: Exception) {
return null
} finally {
socket?.close()
}
}
private fun createTestDnsPacket(): DnsMessage {
val msg = DnsMessage.builder().setQrFlag(false)
.addQuestion(Question(testDomains.random(), Record.TYPE.A, Record.CLASS.IN))
.setId(Random.nextInt(1, 999999))
.setRecursionDesired(true)
.setAuthenticData(true)
.setRecursionAvailable(true)
return msg.build()
}
private fun testResponse(message:DnsMessage):Boolean {
return message.answerSection.size > 0
}
private inner class PinnedDns(private val upstreamServers: List<UpstreamAddress>) : Dns {
override fun lookup(hostname: String): MutableList<InetAddress> {
val res = mutableListOf<InetAddress>()
for (server in upstreamServers) {
if (server.host.equals(hostname, true)) {
res.addAll(server.addressCreator.resolveOrGetResultOrNull(true) ?: emptyArray())
break
}
}
if (res.isEmpty()) {
res.addAll(Dns.SYSTEM.lookup(hostname))
}
if (res.isEmpty()) {
throw UnknownHostException("Could not resolve $hostname")
}
return res
}
}
}
\ No newline at end of file
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:pathData="M1,9l2,2c4.97,-4.97 13.03,-4.97 18,0l2,-2C16.93,2.93 7.08,2.93 1,9zM9,17l3,3 3,-3c-1.65,-1.66 -4.34,-1.66 -6,0zM5,13l2,2c2.76,-2.76 7.24,-2.76 10,0l2,-2C15.14,9.14 8.87,9.14 5,13z"/>
</vector>
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_width="match_parent"
android:animateLayoutChanges="true"
android:layout_height="match_parent">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolBar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimary"
android:elevation="4dp"
app:titleTextColor="?android:attr/textColor"
android:theme="@style/AppTheme_Mono"
app:popupTheme="@style/ThemeOverlay.AppCompat.Light"/>
<androidx.recyclerview.widget.RecyclerView
android:layout_width="match_parent"
android:layout_above="@id/startTest"
android:layout_below="@id/toolBar"
android:layout_marginTop="12dp"
android:layout_marginLeft="4dp"
android:layout_marginRight="4dp"
android:id="@+id/serverList"
android:layout_marginBottom="12dp"
android:layout_height="wrap_content"/>
<Button
android:layout_width="match_parent"
android:id="@+id/startTest"
android:layout_centerHorizontal="true"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:background="@drawable/main_roundbuttons"
android:layout_alignParentBottom="true"
android:layout_toStartOf="@id/abort"
android:layout_marginBottom="16dp"
android:text="@string/window_speedtest_runtest"
android:layout_height="wrap_content"/>
<ImageButton
android:id="@+id/abort"
android:src="@drawable/ic_times"
android:background="@drawable/main_roundbuttons"
android:tint="?android:attr/textColor"
android:layout_width="48dp"
android:layout_alignParentBottom="true"
android:visibility="gone"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:layout_toStartOf="@id/info"
android:contentDescription="@string/contentdescription_main_servericon"
android:layout_alignParentEnd="true"
android:layout_height="48dp"/>
<ImageButton
android:id="@+id/info"
android:src="@drawable/ic_info"
android:background="@drawable/main_roundbuttons"
android:tint="?android:attr/textColor"
android:layout_width="48dp"
android:layout_alignParentBottom="true"
android:visibility="gone"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:contentDescription="@string/contentdescription_main_servericon"
android:layout_alignParentEnd="true"
android:layout_height="48dp"/>
</RelativeLayout>
\ No newline at end of file
......@@ -54,6 +54,17 @@
android:orientation="horizontal"
android:layout_height="wrap_content">
<ImageButton
android:id="@+id/speedTest"