2952/proxy (#2961)
* replace hard-coded strings with existing constants * proxy port * * custom proxy port and hostname inputs * typesafety, refactor, linting, unit tests * relocate ProxyConfiguration in app structure * remove unused editTextPreference fn * allow preference category to have no title * refactor proxy prefs hierarchy/dependency
This commit is contained in:
parent
59c24381a3
commit
25443217c2
6 changed files with 143 additions and 21 deletions
|
@ -19,9 +19,11 @@ import android.os.Bundle
|
||||||
import androidx.preference.PreferenceFragmentCompat
|
import androidx.preference.PreferenceFragmentCompat
|
||||||
import com.keylesspalace.tusky.R
|
import com.keylesspalace.tusky.R
|
||||||
import com.keylesspalace.tusky.settings.PrefKeys
|
import com.keylesspalace.tusky.settings.PrefKeys
|
||||||
import com.keylesspalace.tusky.settings.editTextPreference
|
import com.keylesspalace.tusky.settings.ProxyConfiguration
|
||||||
import com.keylesspalace.tusky.settings.makePreferenceScreen
|
import com.keylesspalace.tusky.settings.makePreferenceScreen
|
||||||
|
import com.keylesspalace.tusky.settings.preferenceCategory
|
||||||
import com.keylesspalace.tusky.settings.switchPreference
|
import com.keylesspalace.tusky.settings.switchPreference
|
||||||
|
import com.keylesspalace.tusky.settings.validatedEditTextPreference
|
||||||
import kotlin.system.exitProcess
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
class ProxyPreferencesFragment : PreferenceFragmentCompat() {
|
class ProxyPreferencesFragment : PreferenceFragmentCompat() {
|
||||||
|
@ -36,14 +38,24 @@ class ProxyPreferencesFragment : PreferenceFragmentCompat() {
|
||||||
setDefaultValue(false)
|
setDefaultValue(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
editTextPreference {
|
preferenceCategory { category ->
|
||||||
|
category.dependency = PrefKeys.HTTP_PROXY_ENABLED
|
||||||
|
category.isIconSpaceReserved = false
|
||||||
|
|
||||||
|
validatedEditTextPreference(null, ProxyConfiguration::isValidHostname) {
|
||||||
setTitle(R.string.pref_title_http_proxy_server)
|
setTitle(R.string.pref_title_http_proxy_server)
|
||||||
key = PrefKeys.HTTP_PROXY_SERVER
|
key = PrefKeys.HTTP_PROXY_SERVER
|
||||||
isIconSpaceReserved = false
|
isIconSpaceReserved = false
|
||||||
setSummaryProvider { text }
|
setSummaryProvider { text }
|
||||||
}
|
}
|
||||||
|
|
||||||
editTextPreference {
|
val portErrorMessage = getString(
|
||||||
|
R.string.pref_title_http_proxy_port_message,
|
||||||
|
ProxyConfiguration.MIN_PROXY_PORT,
|
||||||
|
ProxyConfiguration.MAX_PROXY_PORT
|
||||||
|
)
|
||||||
|
|
||||||
|
validatedEditTextPreference(portErrorMessage, ProxyConfiguration::isValidProxyPort) {
|
||||||
setTitle(R.string.pref_title_http_proxy_port)
|
setTitle(R.string.pref_title_http_proxy_port)
|
||||||
key = PrefKeys.HTTP_PROXY_PORT
|
key = PrefKeys.HTTP_PROXY_PORT
|
||||||
isIconSpaceReserved = false
|
isIconSpaceReserved = false
|
||||||
|
@ -51,6 +63,7 @@ class ProxyPreferencesFragment : PreferenceFragmentCompat() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
super.onPause()
|
super.onPause()
|
||||||
|
|
|
@ -18,6 +18,7 @@ package com.keylesspalace.tusky.di
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
import at.connyduck.calladapter.networkresult.NetworkResultCallAdapterFactory
|
import at.connyduck.calladapter.networkresult.NetworkResultCallAdapterFactory
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.google.gson.GsonBuilder
|
import com.google.gson.GsonBuilder
|
||||||
|
@ -27,6 +28,10 @@ import com.keylesspalace.tusky.json.Rfc3339DateJsonAdapter
|
||||||
import com.keylesspalace.tusky.network.InstanceSwitchAuthInterceptor
|
import com.keylesspalace.tusky.network.InstanceSwitchAuthInterceptor
|
||||||
import com.keylesspalace.tusky.network.MastodonApi
|
import com.keylesspalace.tusky.network.MastodonApi
|
||||||
import com.keylesspalace.tusky.network.MediaUploadApi
|
import com.keylesspalace.tusky.network.MediaUploadApi
|
||||||
|
import com.keylesspalace.tusky.settings.PrefKeys.HTTP_PROXY_ENABLED
|
||||||
|
import com.keylesspalace.tusky.settings.PrefKeys.HTTP_PROXY_PORT
|
||||||
|
import com.keylesspalace.tusky.settings.PrefKeys.HTTP_PROXY_SERVER
|
||||||
|
import com.keylesspalace.tusky.settings.ProxyConfiguration
|
||||||
import com.keylesspalace.tusky.util.getNonNullString
|
import com.keylesspalace.tusky.util.getNonNullString
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
|
@ -38,6 +43,7 @@ import retrofit2.Retrofit
|
||||||
import retrofit2.adapter.rxjava3.RxJava3CallAdapterFactory
|
import retrofit2.adapter.rxjava3.RxJava3CallAdapterFactory
|
||||||
import retrofit2.converter.gson.GsonConverterFactory
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
import retrofit2.create
|
import retrofit2.create
|
||||||
|
import java.net.IDN
|
||||||
import java.net.InetSocketAddress
|
import java.net.InetSocketAddress
|
||||||
import java.net.Proxy
|
import java.net.Proxy
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
@ -64,9 +70,9 @@ class NetworkModule {
|
||||||
context: Context,
|
context: Context,
|
||||||
preferences: SharedPreferences
|
preferences: SharedPreferences
|
||||||
): OkHttpClient {
|
): OkHttpClient {
|
||||||
val httpProxyEnabled = preferences.getBoolean("httpProxyEnabled", false)
|
val httpProxyEnabled = preferences.getBoolean(HTTP_PROXY_ENABLED, false)
|
||||||
val httpServer = preferences.getNonNullString("httpProxyServer", "")
|
val httpServer = preferences.getNonNullString(HTTP_PROXY_SERVER, "")
|
||||||
val httpPort = preferences.getNonNullString("httpProxyPort", "-1").toIntOrNull() ?: -1
|
val httpPort = preferences.getNonNullString(HTTP_PROXY_PORT, "-1").toIntOrNull() ?: -1
|
||||||
val cacheSize = 25 * 1024 * 1024L // 25 MiB
|
val cacheSize = 25 * 1024 * 1024L // 25 MiB
|
||||||
val builder = OkHttpClient.Builder()
|
val builder = OkHttpClient.Builder()
|
||||||
.addInterceptor { chain ->
|
.addInterceptor { chain ->
|
||||||
|
@ -87,10 +93,13 @@ class NetworkModule {
|
||||||
.writeTimeout(30, TimeUnit.SECONDS)
|
.writeTimeout(30, TimeUnit.SECONDS)
|
||||||
.cache(Cache(context.cacheDir, cacheSize))
|
.cache(Cache(context.cacheDir, cacheSize))
|
||||||
|
|
||||||
if (httpProxyEnabled && httpServer.isNotEmpty() && httpPort > 0 && httpPort < 65535) {
|
if (httpProxyEnabled) {
|
||||||
val address = InetSocketAddress.createUnresolved(httpServer, httpPort)
|
ProxyConfiguration.create(httpServer, httpPort)?.also { conf ->
|
||||||
|
val address = InetSocketAddress.createUnresolved(IDN.toASCII(conf.hostname), conf.port)
|
||||||
builder.proxy(Proxy(Proxy.Type.HTTP, address))
|
builder.proxy(Proxy(Proxy.Type.HTTP, address))
|
||||||
|
} ?: Log.w(TAG, "Invalid proxy configuration: ($httpServer, $httpPort)")
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder
|
return builder
|
||||||
.apply {
|
.apply {
|
||||||
addInterceptor(InstanceSwitchAuthInterceptor(accountManager))
|
addInterceptor(InstanceSwitchAuthInterceptor(accountManager))
|
||||||
|
@ -132,4 +141,8 @@ class NetworkModule {
|
||||||
.build()
|
.build()
|
||||||
.create()
|
.create()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "NetworkModule"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
package com.keylesspalace.tusky.settings
|
||||||
|
|
||||||
|
import java.net.IDN
|
||||||
|
|
||||||
|
class ProxyConfiguration private constructor(
|
||||||
|
val hostname: String,
|
||||||
|
val port: Int
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun create(hostname: String, port: Int): ProxyConfiguration? {
|
||||||
|
if (isValidHostname(IDN.toASCII(hostname)) && isValidProxyPort(port)) {
|
||||||
|
return ProxyConfiguration(hostname, port)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
fun isValidProxyPort(value: Any): Boolean = when (value) {
|
||||||
|
is String -> if (value == "") true else value.runCatching(String::toInt).map(
|
||||||
|
PROXY_RANGE::contains
|
||||||
|
).getOrDefault(false)
|
||||||
|
is Int -> PROXY_RANGE.contains(value)
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
fun isValidHostname(hostname: String): Boolean =
|
||||||
|
IP_ADDRESS_REGEX.matches(hostname) || HOSTNAME_REGEX.matches(hostname)
|
||||||
|
const val MIN_PROXY_PORT = 0
|
||||||
|
const val MAX_PROXY_PORT = 65535
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val PROXY_RANGE = IntRange(ProxyConfiguration.MIN_PROXY_PORT, ProxyConfiguration.MAX_PROXY_PORT)
|
||||||
|
private val IP_ADDRESS_REGEX = Regex("^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$")
|
||||||
|
private val HOSTNAME_REGEX = Regex("^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]*[a-zA-Z0-9])\\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]*[A-Za-z0-9])$")
|
|
@ -1,8 +1,10 @@
|
||||||
package com.keylesspalace.tusky.settings
|
package com.keylesspalace.tusky.settings
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.widget.Button
|
||||||
import androidx.activity.result.ActivityResultRegistryOwner
|
import androidx.activity.result.ActivityResultRegistryOwner
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.core.widget.doAfterTextChanged
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import androidx.preference.CheckBoxPreference
|
import androidx.preference.CheckBoxPreference
|
||||||
import androidx.preference.EditTextPreference
|
import androidx.preference.EditTextPreference
|
||||||
|
@ -50,10 +52,25 @@ inline fun PreferenceParent.switchPreference(
|
||||||
return pref
|
return pref
|
||||||
}
|
}
|
||||||
|
|
||||||
inline fun PreferenceParent.editTextPreference(
|
inline fun PreferenceParent.validatedEditTextPreference(
|
||||||
|
errorMessage: String?,
|
||||||
|
crossinline isValid: (a: String) -> Boolean,
|
||||||
builder: EditTextPreference.() -> Unit
|
builder: EditTextPreference.() -> Unit
|
||||||
): EditTextPreference {
|
): EditTextPreference {
|
||||||
val pref = EditTextPreference(context)
|
val pref = EditTextPreference(context)
|
||||||
|
pref.setOnBindEditTextListener { editText ->
|
||||||
|
editText.doAfterTextChanged { editable ->
|
||||||
|
requireNotNull(editable)
|
||||||
|
val btn = editText.rootView.findViewById<Button>(android.R.id.button1)
|
||||||
|
if (isValid(editable.toString())) {
|
||||||
|
editText.error = null
|
||||||
|
btn.isEnabled = true
|
||||||
|
} else {
|
||||||
|
editText.error = errorMessage
|
||||||
|
btn.isEnabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
builder(pref)
|
builder(pref)
|
||||||
addPref(pref)
|
addPref(pref)
|
||||||
return pref
|
return pref
|
||||||
|
@ -69,12 +86,12 @@ inline fun PreferenceParent.checkBoxPreference(
|
||||||
}
|
}
|
||||||
|
|
||||||
inline fun PreferenceParent.preferenceCategory(
|
inline fun PreferenceParent.preferenceCategory(
|
||||||
@StringRes title: Int,
|
@StringRes title: Int? = null,
|
||||||
builder: PreferenceParent.(PreferenceCategory) -> Unit
|
builder: PreferenceParent.(PreferenceCategory) -> Unit
|
||||||
) {
|
) {
|
||||||
val category = PreferenceCategory(context)
|
val category = PreferenceCategory(context)
|
||||||
addPref(category)
|
addPref(category)
|
||||||
category.setTitle(title)
|
title?.run(category::setTitle)
|
||||||
val newParent = PreferenceParent(context) { category.addPreference(it) }
|
val newParent = PreferenceParent(context) { category.addPreference(it) }
|
||||||
builder(newParent, category)
|
builder(newParent, category)
|
||||||
}
|
}
|
||||||
|
|
|
@ -282,6 +282,7 @@
|
||||||
<string name="pref_title_http_proxy_enable">Enable HTTP proxy</string>
|
<string name="pref_title_http_proxy_enable">Enable HTTP proxy</string>
|
||||||
<string name="pref_title_http_proxy_server">HTTP proxy server</string>
|
<string name="pref_title_http_proxy_server">HTTP proxy server</string>
|
||||||
<string name="pref_title_http_proxy_port">HTTP proxy port</string>
|
<string name="pref_title_http_proxy_port">HTTP proxy port</string>
|
||||||
|
<string name="pref_title_http_proxy_port_message">Port should be between %d and %d</string>
|
||||||
|
|
||||||
<string name="pref_default_post_privacy">Default post privacy</string>
|
<string name="pref_default_post_privacy">Default post privacy</string>
|
||||||
<string name="pref_default_post_language">Default posting language</string>
|
<string name="pref_default_post_language">Default posting language</string>
|
||||||
|
|
|
@ -0,0 +1,46 @@
|
||||||
|
package com.keylesspalace.tusky.entity
|
||||||
|
|
||||||
|
import com.keylesspalace.tusky.settings.ProxyConfiguration
|
||||||
|
import org.junit.Assert
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ProxyConfigurationTest {
|
||||||
|
@Test
|
||||||
|
fun `serialized non-int is not valid proxy port`() {
|
||||||
|
Assert.assertFalse(ProxyConfiguration.isValidProxyPort("should fail"))
|
||||||
|
Assert.assertFalse(ProxyConfiguration.isValidProxyPort("1.5"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `number outside port range is not valid`() {
|
||||||
|
Assert.assertFalse(ProxyConfiguration.isValidProxyPort("${ProxyConfiguration.MIN_PROXY_PORT - 1}"))
|
||||||
|
Assert.assertFalse(ProxyConfiguration.isValidProxyPort("${ProxyConfiguration.MAX_PROXY_PORT + 1}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `number in port range, inclusive of min and max, is valid`() {
|
||||||
|
Assert.assertTrue(ProxyConfiguration.isValidProxyPort(ProxyConfiguration.MIN_PROXY_PORT))
|
||||||
|
Assert.assertTrue(ProxyConfiguration.isValidProxyPort(ProxyConfiguration.MAX_PROXY_PORT))
|
||||||
|
Assert.assertTrue(ProxyConfiguration.isValidProxyPort((ProxyConfiguration.MIN_PROXY_PORT + ProxyConfiguration.MAX_PROXY_PORT) / 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `create with invalid port yields null`() {
|
||||||
|
Assert.assertNull(ProxyConfiguration.create("hostname", ProxyConfiguration.MIN_PROXY_PORT - 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `create with invalid hostname yields null`() {
|
||||||
|
Assert.assertNull(ProxyConfiguration.create(".", ProxyConfiguration.MIN_PROXY_PORT))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `create with valid hostname and port yields the config object`() {
|
||||||
|
Assert.assertTrue(ProxyConfiguration.create("hostname", ProxyConfiguration.MIN_PROXY_PORT) is ProxyConfiguration)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unicode hostname allowed`() {
|
||||||
|
Assert.assertTrue(ProxyConfiguration.create("federação.social", ProxyConfiguration.MIN_PROXY_PORT) is ProxyConfiguration)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue