diff --git a/.idea/render.experimental.xml b/.idea/render.experimental.xml new file mode 100644 index 0000000..8ec256a --- /dev/null +++ b/.idea/render.experimental.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index db05b74..aba15c1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,19 +11,28 @@ android { defaultConfig { applicationId = "io.github.dovecoteescapee.byedpi" - minSdk = 24 + minSdk = 21 targetSdk = 34 - versionCode = 2 - versionName = "0.1.1-alpha" + versionCode = 3 + versionName = "1.0.0-rc1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } + buildFeatures { + buildConfig = true + } + buildTypes { release { + buildConfigField("String", "VERSION_NAME", "\"${defaultConfig.versionName}\"") + isMinifyEnabled = false proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } + debug { + buildConfigField("String", "VERSION_NAME", "\"${defaultConfig.versionName}-debug\"") + } } compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 @@ -72,7 +81,7 @@ abstract class BuildTun2Socks : DefaultTask() { } project.exec { workingDir = tun2socksDir - commandLine("gomobile", "bind", "-o", tun2socksOutput, "./engine") + commandLine("gomobile", "bind", "-o", tun2socksOutput, "-trimpath", "./engine") } } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fc8effe..95bf82d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,9 +3,9 @@ xmlns:tools="http://schemas.android.com/tools"> + - + tools:targetApi="34"> + android:name=".activities.MainActivity" + android:exported="true" + android:launchMode="singleInstance"> @@ -28,16 +29,31 @@ + android:exported="true"/> - + android:foregroundServiceType="specialUse" + android:exported="false"> + + + + + + diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index 351b18e..d034952 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -30,6 +30,7 @@ add_library(${CMAKE_PROJECT_NAME} SHARED byedpi/desync.c byedpi/packets.c byedpi/proxy.c + byedpi/main.c native-lib.c ) @@ -37,6 +38,8 @@ include_directories("byedpi") set(CMAKE_C_FLAGS "-std=c99 -O2 -D_XOPEN_SOURCE=500") +add_compile_definitions(ANDROID_APP) + # Specifies libraries CMake should link to your target library. You # can link libraries from various origins, such as libraries defined in this # build script, prebuilt third-party libraries, or Android system libraries. diff --git a/app/src/main/cpp/byedpi b/app/src/main/cpp/byedpi index d880b04..12adfe2 160000 --- a/app/src/main/cpp/byedpi +++ b/app/src/main/cpp/byedpi @@ -1 +1 @@ -Subproject commit d880b0441f8c31e0609fb98be90d722190cd737b +Subproject commit 12adfe285f603bfa38c19e9057d897b2b4e40941 diff --git a/app/src/main/cpp/native-lib.c b/app/src/main/cpp/native-lib.c index 1d538e4..9371211 100644 --- a/app/src/main/cpp/native-lib.c +++ b/app/src/main/cpp/native-lib.c @@ -2,74 +2,40 @@ #include #include #include +#include +#include +#include +#include + #include #include -#include -#include -#include -extern int NOT_EXIT; - -struct packet fake_tls = { - sizeof(tls_data), tls_data -}, - fake_http = { - sizeof(http_data), http_data +const enum demode DESYNC_METHODS[] = { + DESYNC_NONE, + DESYNC_SPLIT, + DESYNC_DISORDER, + DESYNC_FAKE }; -struct params params = { - .ttl = 8, - .split = 3, - .sfdelay = 3000, - .attack = DESYNC_NONE, - .split_host = 0, - .def_ttl = 0, - .custom_ttl = 0, - .mod_http = 0, - .tlsrec = 0, - .tlsrec_pos = 0, - .tlsrec_sni = 0, - .de_known = 0, +extern struct packet fake_tls, fake_http; +extern int get_default_ttl(); +extern int get_addr(const char *str, struct sockaddr_ina *addr); - .ipv6 = 1, - .resolve = 1, - .max_open = 512, - .bfsize = 16384, - .baddr = { - .sin6_family = AF_INET - }, - .debug = 2 -}; - -int get_default_ttl() -{ - int orig_ttl = -1, fd; - socklen_t tsize = sizeof(orig_ttl); - - if ((fd = socket(AF_INET, SOCK_STREAM, 0)) < 0) { - uniperror("socket"); +JNIEXPORT jint JNICALL +Java_io_github_dovecoteescapee_byedpi_core_ByeDpiProxy_jniEventFd(JNIEnv *env, jobject thiz) { + int fd = eventfd(0, EFD_NONBLOCK); + if (fd < 0) { return -1; } - if (getsockopt(fd, IPPROTO_IP, IP_TTL, - (char *)&orig_ttl, &tsize) < 0) { - uniperror("getsockopt IP_TTL"); - } - close(fd); - return orig_ttl; + return fd; } -void *run(void *srv) { - LOG(LOG_S, "Start proxy thread"); - listener(*((struct sockaddr_ina *) srv)); - free(srv); - LOG(LOG_S, "Stop proxy thread"); - return NULL; -} - -JNIEXPORT jlong JNICALL -Java_io_github_dovecoteescapee_byedpi_ByeDpiVpnService_jniStartProxy( +JNIEXPORT jint JNICALL +Java_io_github_dovecoteescapee_byedpi_core_ByeDpiProxy_jniStartProxy( JNIEnv *env, jobject thiz, + jint event_fd, + jstring ip, jint port, jint max_connections, jint buffer_size, @@ -82,24 +48,35 @@ Java_io_github_dovecoteescapee_byedpi_ByeDpiVpnService_jniStartProxy( jint fake_ttl, jboolean host_mixed_case, jboolean domain_mixed_case, - jboolean host_remove_space, + jboolean host_remove_spaces, jboolean tls_record_split, jint tls_record_split_position, jboolean tls_record_split_at_sni) { - enum demode desync_methods[] = {DESYNC_NONE, DESYNC_SPLIT, DESYNC_DISORDER, DESYNC_FAKE}; + + struct sockaddr_ina s = { + .in.sin_family = AF_INET, + .in.sin_addr.s_addr = inet_addr("0.0.0.0"), + }; + + const char *address = (*env)->GetStringUTFChars(env, ip, 0); + if (get_addr(address, &s) < 0) { + return -1; + } + s.in.sin_port = htons(port); params.max_open = max_connections; params.bfsize = buffer_size; params.def_ttl = default_ttl; params.resolve = !no_domain; params.de_known = desync_known; - params.attack = desync_methods[desync_method]; + params.attack = DESYNC_METHODS[desync_method]; params.split = split_position; params.split_host = split_at_host; params.ttl = fake_ttl; - params.mod_http |= host_mixed_case ? MH_HMIX : 0; - params.mod_http |= domain_mixed_case ? MH_DMIX : 0; - params.mod_http |= host_remove_space ? MH_SPACE : 0; + params.mod_http = + MH_HMIX * host_mixed_case | + MH_DMIX * domain_mixed_case | + MH_SPACE * host_remove_spaces; params.tlsrec = tls_record_split; params.tlsrec_pos = tls_record_split_position; params.tlsrec_sni = tls_record_split_at_sni; @@ -110,23 +87,22 @@ Java_io_github_dovecoteescapee_byedpi_ByeDpiVpnService_jniStartProxy( } } - struct sockaddr_ina *srv = malloc(sizeof(struct sockaddr_ina)); - srv->in.sin_family = AF_INET; - srv->in.sin_addr.s_addr = inet_addr("0.0.0.0"); - srv->in.sin_port = htons(port); + int res = listener(event_fd, s); - NOT_EXIT = 1; - - pthread_t proxy_thread; - if (pthread_create(&proxy_thread, NULL, run, srv) != 0) { - LOG(LOG_S, "Failed to start proxy thread"); - return -1; + if (close(event_fd) < 0) { + uniperror("close"); } - return proxy_thread; + return res < 0 ? get_e() : 0; } -JNIEXPORT void JNICALL -Java_io_github_dovecoteescapee_byedpi_ByeDpiVpnService_jniStopProxy(JNIEnv *env, jobject thiz, jlong proxy_thread) { - NOT_EXIT = 0; -} +JNIEXPORT jint JNICALL +Java_io_github_dovecoteescapee_byedpi_core_ByeDpiProxy_jniStopProxy(JNIEnv *env, jobject thiz, + jint event_fd) { + if (eventfd_write(event_fd, 1) < 0) { + uniperror("eventfd_write"); + LOG(LOG_S, "event_fd: %d", event_fd); + } + + return 0; +} \ No newline at end of file diff --git a/app/src/main/java/io/github/dovecoteescapee/byedpi/ByeDpiVpnService.kt b/app/src/main/java/io/github/dovecoteescapee/byedpi/ByeDpiVpnService.kt deleted file mode 100644 index 56b56b9..0000000 --- a/app/src/main/java/io/github/dovecoteescapee/byedpi/ByeDpiVpnService.kt +++ /dev/null @@ -1,255 +0,0 @@ -package io.github.dovecoteescapee.byedpi - -import android.app.PendingIntent -import android.content.Intent -import android.net.VpnService -import android.os.Build -import android.os.IBinder -import android.os.ParcelFileDescriptor -import android.util.Log -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.ServiceLifecycleDispatcher -import androidx.preference.PreferenceManager -import engine.Engine -import engine.Key - -class ByeDpiVpnService : VpnService(), LifecycleOwner { - private var proxyThread: Long = -1 - private var vpn: ParcelFileDescriptor? = null - - private val dispatcher = ServiceLifecycleDispatcher(this) - override val lifecycle: Lifecycle - get() = dispatcher.lifecycle - - companion object { - private val TAG: String = ByeDpiVpnService::class.java.simpleName - - var status: Status = Status.STOPPED - private set - } - - override fun onCreate() { - dispatcher.onServicePreSuperOnCreate() - super.onCreate() - } - - override fun onBind(intent: Intent?): IBinder? { - dispatcher.onServicePreSuperOnBind() - return super.onBind(intent) - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - if (intent != null) { - return when (intent.action) { - "start" -> { - run() - START_STICKY - } - - "stop" -> { - stop() - START_NOT_STICKY - } - - else -> { - throw IllegalArgumentException("Unknown action ${intent.action}") - } - } - } - return super.onStartCommand(intent, flags, startId) - } - - override fun onRevoke() { - super.onRevoke() - Log.i(TAG, "VPN revoked") - stop() - } - - override fun onDestroy() { - dispatcher.onServicePreSuperOnDestroy() - super.onDestroy() - Log.i(TAG, "Service destroyed") - stop() - } - - private fun run() { - val preferences = getPreferences() - status = Status.RUNNING - - if (proxyThread >= 0) { - Log.w(TAG, "Proxy already running") - return - } - - proxyThread = startProxy(preferences) - if (proxyThread < 0) { - status = Status.STOPPED - Log.e(TAG, "Proxy failed to start") - return - } - - val vpn = getBuilder().establish() - this.vpn = vpn - if (vpn == null) { - Log.e(TAG, "VPN connection failed") - return - } - - Log.d(TAG, "fd: ${vpn.fd}") - startTun2Socks(vpn.fd, preferences.port) - } - - private fun getPreferences(): ByeDpiProxyPreferences { - val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this) - - return ByeDpiProxyPreferences( - port = - sharedPreferences.getString("byedpi_proxy_port", null)?.toInt(), - maxConnections = - sharedPreferences.getString("byedpi_max_connections", null)?.toInt(), - bufferSize = - sharedPreferences.getString("byedpi_buffer_size", null)?.toInt(), - defaultTtl = - sharedPreferences.getString("byedpi_default_ttl", null)?.toInt(), - noDomain = - sharedPreferences.getBoolean("byedpi_no_domain", false), - desyncKnown = - sharedPreferences.getBoolean("byedpi_desync_known", false), - desyncMethod = - sharedPreferences.getString("byedpi_desync_method", null) - ?.let { ByeDpiProxyPreferences.DesyncMethod.fromName(it) }, - splitPosition = - sharedPreferences.getString("byedpi_split_position", null)?.toInt(), - splitAtHost = - sharedPreferences.getBoolean("byedpi_split_at_host", false), - fakeTtl = - sharedPreferences.getString("byedpi_fake_ttl", null)?.toInt(), - hostMixedCase = - sharedPreferences.getBoolean("byedpi_host_mixed_case", false), - domainMixedCase = - sharedPreferences.getBoolean("byedpi_domain_mixed_case", false), - hostRemoveSpaces = - sharedPreferences.getBoolean("byedpi_host_remove_spaces", false), - tlsRecordSplit = - sharedPreferences.getBoolean("byedpi_tlsrec", false), - tlsRecordSplitPosition = - sharedPreferences.getString("byedpi_tlsrec_position", null)?.toInt(), - tlsRecordSplitAtSni = - sharedPreferences.getBoolean("byedpi_tlsrec_at_sni", false), - ) - } - - private fun stop() { - status = Status.STOPPED - stopTun2Socks() - stopProxy() - } - - private fun startProxy(preferences: ByeDpiProxyPreferences): Long { - Log.i(TAG, "Proxy started") - return jniStartProxy( - port = preferences.port, - maxConnections = preferences.maxConnections, - bufferSize = preferences.bufferSize, - defaultTtl = preferences.defaultTtl, - noDomain = preferences.noDomain, - desyncKnown = preferences.desyncKnown, - desyncMethod = preferences.desyncMethod.ordinal, - splitPosition = preferences.splitPosition, - splitAtHost = preferences.splitAtHost, - fakeTtl = preferences.fakeTtl, - hostMixedCase = preferences.hostMixedCase, - domainMixedCase = preferences.domainMixedCase, - hostRemoveSpace = preferences.hostRemoveSpaces, - tlsRecordSplit = preferences.tlsRecordSplit, - tlsRecordSplitPosition = preferences.tlsRecordSplitPosition, - tlsRecordSplitAtSni = preferences.tlsRecordSplitAtSni, - ) - } - - private fun stopProxy() { - if (proxyThread < 0) { - Log.w(TAG, "Proxy not running") - } - jniStopProxy(proxyThread) - proxyThread = -1 - } - - private fun startTun2Socks(fd: Int, port: Int) { - val key = Key().apply { - mark = 0 - mtu = 0 - device = "fd://$fd" - - setInterface("") - logLevel = "debug" - udpProxy = "direct://" - tcpProxy = "socks5://127.0.0.1:$port" - - restAPI = "" - tcpSendBufferSize = "" - tcpReceiveBufferSize = "" - tcpModerateReceiveBuffer = false - } - - Engine.insert(key) - - Log.i(TAG, "Tun2Socks started") - Engine.start() - } - - private fun stopTun2Socks() { - Log.i(TAG, "Tun2socks stopped") - vpn?.close() ?: Log.w(TAG, "VPN not running") - } - - private external fun jniStartProxy( - port: Int, - maxConnections: Int, - bufferSize: Int, - defaultTtl: Int, - noDomain: Boolean, - desyncKnown: Boolean, - desyncMethod: Int, - splitPosition: Int, - splitAtHost: Boolean, - fakeTtl: Int, - hostMixedCase: Boolean, - domainMixedCase: Boolean, - hostRemoveSpace: Boolean, - tlsRecordSplit: Boolean, - tlsRecordSplitPosition: Int, - tlsRecordSplitAtSni: Boolean, - ): Long - - private external fun jniStopProxy(proxyThread: Long) - - private fun getBuilder(): Builder { - val builder = Builder() - builder.setSession("ByeDPI") - builder.setConfigureIntent( - PendingIntent.getActivity( - this, - 0, - Intent(this, MainActivity::class.java), - PendingIntent.FLAG_IMMUTABLE, - ) - ) - - val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this) - val dns = sharedPreferences.getString("dns_ip", "9.9.9.9")!! - - builder.addAddress("10.10.10.10", 32) - builder.addRoute("0.0.0.0", 0) - builder.addRoute("0:0:0:0:0:0:0:0", 0) - builder.addDnsServer(dns) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - builder.setMetered(false) - } - - builder.addDisallowedApplication("io.github.dovecoteescapee.byedpi") - - return builder - } -} diff --git a/app/src/main/java/io/github/dovecoteescapee/byedpi/MainActivity.kt b/app/src/main/java/io/github/dovecoteescapee/byedpi/MainActivity.kt deleted file mode 100644 index 29e96cf..0000000 --- a/app/src/main/java/io/github/dovecoteescapee/byedpi/MainActivity.kt +++ /dev/null @@ -1,104 +0,0 @@ -package io.github.dovecoteescapee.byedpi - -import android.content.Intent -import android.net.VpnService -import androidx.appcompat.app.AppCompatActivity -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import android.widget.Toast -import androidx.activity.result.contract.ActivityResultContracts -import io.github.dovecoteescapee.byedpi.databinding.ActivityMainBinding - -class MainActivity : AppCompatActivity() { - private lateinit var binding: ActivityMainBinding - - private val register = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == RESULT_OK) { - startVpnService() - } else { - Toast.makeText(this, R.string.vpn_permission_denied, Toast.LENGTH_SHORT).show() - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - binding = ActivityMainBinding.inflate(layoutInflater) - setContentView(binding.root) - - binding.statusButton.setOnClickListener { - when (ByeDpiVpnService.status) { - Status.STOPPED -> { - val intentPrepare = VpnService.prepare(this) - if (intentPrepare != null) { - register.launch(intentPrepare) - } else { - startVpnService() - } - updateStatus(Status.RUNNING) - } - Status.RUNNING -> { - stopVpnService() - updateStatus(Status.STOPPED) - } - } - } - - updateStatus(ByeDpiVpnService.status) - } - - override fun onCreateOptionsMenu(menu: Menu?): Boolean { - menuInflater.inflate(R.menu.menu_main, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean = - when (item.itemId) { - R.id.action_settings -> { - val intent = Intent(this, SettingsActivity::class.java) - if (ByeDpiVpnService.status == Status.RUNNING) { - Toast.makeText(this, R.string.settings_unavailable, Toast.LENGTH_SHORT) - .show() - } else { - startActivity(intent) - } - - true - } - - else -> super.onOptionsItemSelected(item) - } - - private fun startVpnService() { - val intent = Intent(this, ByeDpiVpnService::class.java) - intent.action = "start" - startService(intent) - } - - private fun stopVpnService() { - val intent = Intent(this, ByeDpiVpnService::class.java) - intent.action = "stop" - startService(intent) - stopService(intent) - } - - private fun updateStatus(status : Status) { - when (status) { - Status.STOPPED -> { - binding.statusButton.setText(R.string.start) - } - Status.RUNNING -> { - binding.statusButton.setText(R.string.stop) - } - } - } - - companion object { - // Used to load the 'byedpi' library on application startup. - init { - System.loadLibrary("byedpi") - } - } -} \ No newline at end of file diff --git a/app/src/main/java/io/github/dovecoteescapee/byedpi/SettingsActivity.kt b/app/src/main/java/io/github/dovecoteescapee/byedpi/SettingsActivity.kt deleted file mode 100644 index 9bdf42e..0000000 --- a/app/src/main/java/io/github/dovecoteescapee/byedpi/SettingsActivity.kt +++ /dev/null @@ -1,115 +0,0 @@ -package io.github.dovecoteescapee.byedpi - -import android.content.Intent -import android.os.Bundle -import android.util.Log -import android.view.Menu -import android.view.MenuItem -import android.widget.Toast -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.lifecycle.lifecycleScope -import androidx.preference.PreferenceManager -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import java.io.IOException - -class SettingsActivity : AppCompatActivity() { - private val register = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - lifecycleScope.launch(Dispatchers.IO) { - val logs = collectLogs() - - if (logs == null) { - Toast.makeText( - this@SettingsActivity, - R.string.logs_failed, - Toast.LENGTH_SHORT - ).show() - } else { - val uri = it.data?.data ?: run { - Log.e(TAG, "No data in result") - return@launch - } - contentResolver.openOutputStream(uri)?.use { - try { - it.write(logs.toByteArray()) - } catch (e: IOException) { - Log.e(TAG, "Failed to save logs", e) - } - } ?: run { - Log.e(TAG, "Failed to open output stream") - } - } - } - } - - - companion object { - private val TAG: String = SettingsActivity::class.java.simpleName - - private fun collectLogs(): String? = - try { - Runtime.getRuntime() - .exec("logcat *:I -d") - .inputStream.bufferedReader() - .use { it.readText() } - } catch (e: Exception) { - Log.e(TAG, "Failed to collect logs", e) - null - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.activity_settings) - - supportFragmentManager - .beginTransaction() - .replace(R.id.settings, SettingsFragment()) - .commit() - - supportActionBar?.setDisplayHomeAsUpEnabled(true) - } - - override fun onCreateOptionsMenu(menu: Menu?): Boolean { - menuInflater.inflate(R.menu.menu_settings, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { - android.R.id.home -> { - onBackPressedDispatcher.onBackPressed() - true - } - - R.id.action_save_logs -> { - collectLogs() - val intent = - Intent(Intent.ACTION_CREATE_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - setType("text/plain") - putExtra(Intent.EXTRA_TITLE, "byedpi.log") - } - - register.launch(intent) - true - } - - R.id.action_reset_settings -> { - PreferenceManager - .getDefaultSharedPreferences(this) - .edit() - .clear() - .apply() - - supportFragmentManager - .beginTransaction() - .replace(R.id.settings, SettingsFragment()) - .commit() - true - } - - else -> super.onOptionsItemSelected(item) - } -} \ No newline at end of file diff --git a/app/src/main/java/io/github/dovecoteescapee/byedpi/SettingsFragment.kt b/app/src/main/java/io/github/dovecoteescapee/byedpi/SettingsFragment.kt deleted file mode 100644 index 02f9d72..0000000 --- a/app/src/main/java/io/github/dovecoteescapee/byedpi/SettingsFragment.kt +++ /dev/null @@ -1,61 +0,0 @@ -package io.github.dovecoteescapee.byedpi - -import android.net.InetAddresses -import android.os.Build -import android.os.Bundle -import android.util.Log -import android.util.Patterns -import androidx.preference.EditTextPreference -import androidx.preference.PreferenceFragmentCompat - -class SettingsFragment : PreferenceFragmentCompat() { - companion object { - private val TAG: String = SettingsFragment::class.java.simpleName - - private fun checkIp(ip: String): Boolean = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - InetAddresses.isNumericAddress(ip) - } else { - Patterns.IP_ADDRESS.matcher(ip).matches() - } - - private fun checkPort(port: String): Boolean = - port.toIntOrNull()?.let { it in 1..65535 } ?: false - } - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - setPreferencesFromResource(R.xml.settings, rootKey) - - setEditTestPreferenceListener("dns_ip") { checkIp(it) } - setEditTestPreferenceListener("byedpi_port") { checkPort(it) } - setEditTestPreferenceListener("byedpi_max_connections") { value -> - value.toIntOrNull()?.let { it > 0 } ?: false - } - setEditTestPreferenceListener("byedpi_buffer_size") { value -> - value.toIntOrNull()?.let { it > 0 } ?: false - } - setEditTestPreferenceListener("byedpi_default_ttl") { value -> - value.toIntOrNull()?.let { it >= 0 } ?: false - } - setEditTestPreferenceListener("byedpi_split_position") { value -> - value.toIntOrNull() != null - } - setEditTestPreferenceListener("byedpi_fake_ttl") { value -> - value.toIntOrNull()?.let { it >= 0 } ?: false - } - setEditTestPreferenceListener("byedpi_tlsrec_position") { - it.toIntOrNull()?.let { it >= 0 } ?: false - } - } - - private fun setEditTestPreferenceListener(key: String, check: (String) -> Boolean) { - findPreference(key) - ?.setOnPreferenceChangeListener { preference, newValue -> - newValue as String - val valid = check(newValue) - if (!valid) { - Log.e(TAG, "Invalid ${preference.title}: $newValue") - } - valid - } - } -} \ No newline at end of file diff --git a/app/src/main/java/io/github/dovecoteescapee/byedpi/Status.kt b/app/src/main/java/io/github/dovecoteescapee/byedpi/Status.kt deleted file mode 100644 index 9c9cbf4..0000000 --- a/app/src/main/java/io/github/dovecoteescapee/byedpi/Status.kt +++ /dev/null @@ -1,6 +0,0 @@ -package io.github.dovecoteescapee.byedpi - -enum class Status { - RUNNING, - STOPPED -} \ No newline at end of file diff --git a/app/src/main/java/io/github/dovecoteescapee/byedpi/activities/MainActivity.kt b/app/src/main/java/io/github/dovecoteescapee/byedpi/activities/MainActivity.kt new file mode 100644 index 0000000..56aa3c8 --- /dev/null +++ b/app/src/main/java/io/github/dovecoteescapee/byedpi/activities/MainActivity.kt @@ -0,0 +1,359 @@ +package io.github.dovecoteescapee.byedpi.activities + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.VpnService +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.view.Menu +import android.view.MenuItem +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import io.github.dovecoteescapee.byedpi.data.START_ACTION +import io.github.dovecoteescapee.byedpi.data.STOP_ACTION +import io.github.dovecoteescapee.byedpi.R +import io.github.dovecoteescapee.byedpi.data.AppStatus +import io.github.dovecoteescapee.byedpi.data.FAILED_BROADCAST +import io.github.dovecoteescapee.byedpi.data.Mode +import io.github.dovecoteescapee.byedpi.data.SENDER +import io.github.dovecoteescapee.byedpi.data.STARTED_BROADCAST +import io.github.dovecoteescapee.byedpi.data.STOPPED_BROADCAST +import io.github.dovecoteescapee.byedpi.data.Sender +import io.github.dovecoteescapee.byedpi.fragments.SettingsFragment +import io.github.dovecoteescapee.byedpi.databinding.ActivityMainBinding +import io.github.dovecoteescapee.byedpi.services.ByeDpiProxyService +import io.github.dovecoteescapee.byedpi.services.ByeDpiVpnService +import io.github.dovecoteescapee.byedpi.utility.getPreferences +import io.github.dovecoteescapee.byedpi.utility.mode +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.io.IOException + +class MainActivity : AppCompatActivity() { + private lateinit var binding: ActivityMainBinding + + companion object { + private val TAG: String = MainActivity::class.java.simpleName + + private var status: AppStatus = AppStatus.Halted + private var mode: Mode = Mode.VPN + + private fun collectLogs(): String? = + try { + Runtime.getRuntime() + .exec("logcat *:I -d") + .inputStream.bufferedReader() + .use { it.readText() } + } catch (e: Exception) { + Log.e(TAG, "Failed to collect logs", e) + null + } + } + + private val vpnRegister = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == RESULT_OK) { + startVpn() + } else { + Toast.makeText(this, R.string.vpn_permission_denied, Toast.LENGTH_SHORT).show() + updateStatus(AppStatus.Halted) + } + } + + private val logsRegister = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + lifecycleScope.launch(Dispatchers.IO) { + val logs = collectLogs() + + if (logs == null) { + Toast.makeText( + this@MainActivity, + R.string.logs_failed, + Toast.LENGTH_SHORT + ).show() + } else { + val uri = it.data?.data ?: run { + Log.e(TAG, "No data in result") + return@launch + } + contentResolver.openOutputStream(uri)?.use { + try { + it.write(logs.toByteArray()) + } catch (e: IOException) { + Log.e(TAG, "Failed to save logs", e) + } + } ?: run { + Log.e(TAG, "Failed to open output stream") + } + } + } + } + + private val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + Log.d(TAG, "Received intent: ${intent?.action}") + + if (intent == null) { + Log.w(TAG, "Received null intent") + return + } + + val senderOrd = intent.getIntExtra(SENDER, -1) + val sender = Sender.entries.getOrNull(senderOrd) + if (sender == null) { + Log.w(TAG, "Received intent with unknown sender: $senderOrd") + return + } + + when (val action = intent.action) { + STARTED_BROADCAST -> if ( + status == AppStatus.Halted || status == AppStatus.Starting + ) { + updateStatus(AppStatus.Running, Mode.fromSender(sender)) + } else { + Log.w(TAG, "Received STARTED while status is $status") + } + + STOPPED_BROADCAST -> if ( + mode == Mode.fromSender(sender) && + (status == AppStatus.Running || status == AppStatus.Stopping) + ) { + updateStatus(AppStatus.Halted) + } else { + Log.w(TAG, "Received STOPPED $sender while status is $status") + } + + FAILED_BROADCAST -> { + Toast.makeText( + context, + getString(R.string.failed_to_start, sender.name), + Toast.LENGTH_SHORT, + ).show() + updateStatus(AppStatus.Halted) + } + + else -> Log.w(TAG, "Unknown action: $action") + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + val intentFilter = IntentFilter().apply { + addAction(STARTED_BROADCAST) + addAction(STOPPED_BROADCAST) + addAction(FAILED_BROADCAST) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(receiver, intentFilter, RECEIVER_EXPORTED) + } else { + registerReceiver(receiver, intentFilter) + } + + binding.statusButton.setOnClickListener { + when (status) { + AppStatus.Halted -> start() + AppStatus.Running -> stop() + else -> { + // ignore + } + } + } + + val theme = getPreferences(this) + .getString("app_theme", null) + SettingsFragment.setTheme(theme ?: "system") + } + + override fun onResume() { + super.onResume() + updateStatus() + } + + override fun onDestroy() { + super.onDestroy() + unregisterReceiver(receiver) + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + menuInflater.inflate(R.menu.menu_main, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean = + when (item.itemId) { + R.id.action_settings -> { + if (status == AppStatus.Halted) { + val intent = Intent(this, SettingsActivity::class.java) + startActivity(intent) + } else { + Toast.makeText(this, R.string.settings_unavailable, Toast.LENGTH_SHORT) + .show() + } + true + } + + R.id.action_save_logs -> { + val intent = + Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "text/plain" + putExtra(Intent.EXTRA_TITLE, "byedpi.log") + } + + logsRegister.launch(intent) + true + } + + else -> super.onOptionsItemSelected(item) + } + + private fun start() { +// Starting and stopping is too fast +// updateStatus(AppStatus.Starting) + + val preferences = getPreferences(this) + when (val mode = preferences.getString("byedpi_mode", null) ?: "vpn") { + "vpn" -> { + val intentPrepare = VpnService.prepare(this) + if (intentPrepare != null) { + vpnRegister.launch(intentPrepare) + } else { + startVpn() + } + } + + "proxy" -> startProxy() + else -> Log.e(TAG, "Unknown mode: $mode") + } + } + + private fun startVpn() { + Log.i(TAG, "Starting VPN") + val intent = Intent(this, ByeDpiVpnService::class.java) + intent.action = START_ACTION + startService(intent) + } + + private fun startProxy() { + Log.i(TAG, "Starting proxy") + val intent = Intent(this, ByeDpiProxyService::class.java) + intent.action = START_ACTION + startService(intent) + } + + private fun stop() { +// Starting and stopping is too fast +// updateStatus(AppStatus.Stopping) + when (mode) { + Mode.VPN -> stopVpn() + Mode.Proxy -> stopProxy() + } + } + + private fun stopVpn() { + Log.i(TAG, "Stopping VPN") + val intent = Intent(this, ByeDpiVpnService::class.java) + intent.action = STOP_ACTION + startService(intent) + } + + private fun stopProxy() { + Log.i(TAG, "Stopping proxy") + val intent = Intent(this, ByeDpiProxyService::class.java) + intent.action = STOP_ACTION + startService(intent) + } + + private fun updateStatus( + status: AppStatus = MainActivity.status, + mode: Mode = MainActivity.mode, + ) { + Log.i(TAG, "Updating from ${MainActivity.status} to $status") + + MainActivity.mode = mode + + val preferences = getPreferences(this) + val proxyIp = preferences.getString("byedpi_proxy_ip", null) ?: "127.0.0.1" + val proxyPort = preferences.getString("byedpi_proxy_port", null) ?: "1080" + binding.proxyAddress.text = getString(R.string.proxy_address, proxyIp, proxyPort) + + when (status) { + AppStatus.Halted -> { + val newMode = preferences.mode() + MainActivity.mode = newMode + when (newMode) { + Mode.VPN -> { + binding.statusText.setText(R.string.vpn_disconnected) + binding.statusButton.setText(R.string.vpn_connect) + } + + Mode.Proxy -> { + binding.statusText.setText(R.string.proxy_down) + binding.statusButton.setText(R.string.proxy_start) + } + } + MainActivity.status = AppStatus.Halted + binding.statusButton.isEnabled = true + } + + AppStatus.Running -> { + when (mode) { + Mode.VPN -> { + binding.statusText.setText(R.string.vpn_connected) + binding.statusButton.setText(R.string.vpn_disconnect) + } + + Mode.Proxy -> { + binding.statusText.setText(R.string.proxy_up) + binding.statusButton.setText(R.string.proxy_stop) + } + } + MainActivity.status = AppStatus.Running + binding.statusButton.isEnabled = true + } + + AppStatus.Starting -> { + if (MainActivity.status == AppStatus.Halted) { + when (mode) { + Mode.VPN -> { + binding.statusText.setText(R.string.vpn_connecting) + } + + Mode.Proxy -> { + binding.statusText.setText(R.string.proxy_starting) + } + } + MainActivity.status = AppStatus.Starting + binding.statusButton.isEnabled = false + } + } + + AppStatus.Stopping -> { + if (MainActivity.status == AppStatus.Running) { + when (mode) { + Mode.VPN -> { + binding.statusText.setText(R.string.vpn_disconnecting) + } + + Mode.Proxy -> { + binding.statusText.setText(R.string.proxy_stopping) + } + } + MainActivity.status = AppStatus.Stopping + binding.statusButton.isEnabled = false + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/github/dovecoteescapee/byedpi/activities/SettingsActivity.kt b/app/src/main/java/io/github/dovecoteescapee/byedpi/activities/SettingsActivity.kt new file mode 100644 index 0000000..5b95624 --- /dev/null +++ b/app/src/main/java/io/github/dovecoteescapee/byedpi/activities/SettingsActivity.kt @@ -0,0 +1,50 @@ +package io.github.dovecoteescapee.byedpi.activities + +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import androidx.appcompat.app.AppCompatActivity +import io.github.dovecoteescapee.byedpi.R +import io.github.dovecoteescapee.byedpi.fragments.SettingsFragment +import io.github.dovecoteescapee.byedpi.utility.getPreferences + +class SettingsActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_settings) + + supportFragmentManager + .beginTransaction() + .replace(R.id.settings, SettingsFragment()) + .commit() + + supportActionBar?.setDisplayHomeAsUpEnabled(true) + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + menuInflater.inflate(R.menu.menu_settings, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { + android.R.id.home -> { + onBackPressedDispatcher.onBackPressed() + true + } + + R.id.action_reset_settings -> { + getPreferences(this) + .edit() + .clear() + .apply() + + supportFragmentManager + .beginTransaction() + .replace(R.id.settings, SettingsFragment()) + .commit() + true + } + + else -> super.onOptionsItemSelected(item) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/github/dovecoteescapee/byedpi/core/ByeDpiProxy.kt b/app/src/main/java/io/github/dovecoteescapee/byedpi/core/ByeDpiProxy.kt new file mode 100644 index 0000000..8ee663f --- /dev/null +++ b/app/src/main/java/io/github/dovecoteescapee/byedpi/core/ByeDpiProxy.kt @@ -0,0 +1,68 @@ +package io.github.dovecoteescapee.byedpi.core + +import java.io.IOException + +class ByeDpiProxy { + companion object { + init { + System.loadLibrary("byedpi") + } + } + + private val fd = jniEventFd() + + init { + if (fd < 0) { + throw IOException("Failed to create eventfd") + } + } + + fun startProxy(preferences: ByeDpiProxyPreferences): Int = + jniStartProxy( + eventFd = fd, + ip = preferences.ip, + port = preferences.port, + maxConnections = preferences.maxConnections, + bufferSize = preferences.bufferSize, + defaultTtl = preferences.defaultTtl, + noDomain = preferences.noDomain, + desyncKnown = preferences.desyncKnown, + desyncMethod = preferences.desyncMethod.ordinal, + splitPosition = preferences.splitPosition, + splitAtHost = preferences.splitAtHost, + fakeTtl = preferences.fakeTtl, + hostMixedCase = preferences.hostMixedCase, + domainMixedCase = preferences.domainMixedCase, + hostRemoveSpaces = preferences.hostRemoveSpaces, + tlsRecordSplit = preferences.tlsRecordSplit, + tlsRecordSplitPosition = preferences.tlsRecordSplitPosition, + tlsRecordSplitAtSni = preferences.tlsRecordSplitAtSni, + ) + + fun stopProxy(): Int = jniStopProxy(fd) + + private external fun jniEventFd(): Int + + private external fun jniStartProxy( + eventFd: Int, + ip: String, + port: Int, + maxConnections: Int, + bufferSize: Int, + defaultTtl: Int, + noDomain: Boolean, + desyncKnown: Boolean, + desyncMethod: Int, + splitPosition: Int, + splitAtHost: Boolean, + fakeTtl: Int, + hostMixedCase: Boolean, + domainMixedCase: Boolean, + hostRemoveSpaces: Boolean, + tlsRecordSplit: Boolean, + tlsRecordSplitPosition: Int, + tlsRecordSplitAtSni: Boolean, + ): Int + + private external fun jniStopProxy(eventFd: Int): Int +} \ No newline at end of file diff --git a/app/src/main/java/io/github/dovecoteescapee/byedpi/ByeDpiProxyPreferences.kt b/app/src/main/java/io/github/dovecoteescapee/byedpi/core/ByeDpiProxyPreferences.kt similarity index 54% rename from app/src/main/java/io/github/dovecoteescapee/byedpi/ByeDpiProxyPreferences.kt rename to app/src/main/java/io/github/dovecoteescapee/byedpi/core/ByeDpiProxyPreferences.kt index f3583f3..b92391d 100644 --- a/app/src/main/java/io/github/dovecoteescapee/byedpi/ByeDpiProxyPreferences.kt +++ b/app/src/main/java/io/github/dovecoteescapee/byedpi/core/ByeDpiProxyPreferences.kt @@ -1,6 +1,9 @@ -package io.github.dovecoteescapee.byedpi +package io.github.dovecoteescapee.byedpi.core + +import android.content.SharedPreferences class ByeDpiProxyPreferences( + ip: String? = null, port: Int? = null, maxConnections: Int? = null, bufferSize: Int? = null, @@ -18,6 +21,7 @@ class ByeDpiProxyPreferences( tlsRecordSplitPosition: Int? = null, tlsRecordSplitAtSni: Boolean? = null, ) { + val ip: String = ip ?: "127.0.0.1" val port: Int = port ?: 1080 val maxConnections: Int = maxConnections ?: 512 val bufferSize: Int = bufferSize ?: 16384 @@ -35,6 +39,29 @@ class ByeDpiProxyPreferences( val tlsRecordSplitPosition: Int = tlsRecordSplitPosition ?: 0 val tlsRecordSplitAtSni: Boolean = tlsRecordSplitAtSni ?: false + constructor(preferences: SharedPreferences) : this( + ip = preferences.getString("byedpi_proxy_ip", null), + port = preferences.getString("byedpi_proxy_port", null)?.toInt(), + maxConnections = preferences.getString("byedpi_max_connections", null)?.toInt(), + bufferSize = preferences.getString("byedpi_buffer_size", null)?.toInt(), + defaultTtl = preferences.getString("byedpi_default_ttl", null)?.toInt(), + noDomain = preferences.getBoolean("byedpi_no_domain", false), + desyncKnown = preferences.getBoolean("byedpi_desync_known", false), + desyncMethod = preferences.getString("byedpi_desync_method", null) + ?.let { DesyncMethod.fromName(it) }, + splitPosition = preferences.getString("byedpi_split_position", null)?.toInt(), + splitAtHost = preferences.getBoolean("byedpi_split_at_host", false), + fakeTtl = preferences.getString("byedpi_fake_ttl", null)?.toInt(), + hostMixedCase = preferences.getBoolean("byedpi_host_mixed_case", false), + domainMixedCase = preferences.getBoolean("byedpi_domain_mixed_case", false), + hostRemoveSpaces = preferences.getBoolean("byedpi_host_remove_spaces", false), + tlsRecordSplit = preferences.getBoolean("byedpi_tlsrec_enabled", false), + tlsRecordSplitPosition = preferences.getString("byedpi_tlsrec_position", null)?.toInt(), + tlsRecordSplitAtSni = preferences.getBoolean("byedpi_tlsrec_at_sni", false), + + + ) + enum class DesyncMethod { None, Split, diff --git a/app/src/main/java/io/github/dovecoteescapee/byedpi/data/Actions.kt b/app/src/main/java/io/github/dovecoteescapee/byedpi/data/Actions.kt new file mode 100644 index 0000000..c8c9331 --- /dev/null +++ b/app/src/main/java/io/github/dovecoteescapee/byedpi/data/Actions.kt @@ -0,0 +1,4 @@ +package io.github.dovecoteescapee.byedpi.data + +const val START_ACTION = "start" +const val STOP_ACTION = "stop" diff --git a/app/src/main/java/io/github/dovecoteescapee/byedpi/data/AppStatus.kt b/app/src/main/java/io/github/dovecoteescapee/byedpi/data/AppStatus.kt new file mode 100644 index 0000000..9bade08 --- /dev/null +++ b/app/src/main/java/io/github/dovecoteescapee/byedpi/data/AppStatus.kt @@ -0,0 +1,26 @@ +package io.github.dovecoteescapee.byedpi.data + +enum class AppStatus { + Halted, + Running, + Starting, + Stopping, +} + +enum class Mode { + Proxy, + VPN; + + companion object { + fun fromSender(sender: Sender): Mode = when (sender) { + Sender.Proxy -> Proxy + Sender.VPN -> VPN + } + + fun fromString(name: String): Mode = when (name) { + "proxy" -> Proxy + "vpn" -> VPN + else -> throw IllegalArgumentException("Invalid mode: $name") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/github/dovecoteescapee/byedpi/data/Broadcasts.kt b/app/src/main/java/io/github/dovecoteescapee/byedpi/data/Broadcasts.kt new file mode 100644 index 0000000..54b0f4e --- /dev/null +++ b/app/src/main/java/io/github/dovecoteescapee/byedpi/data/Broadcasts.kt @@ -0,0 +1,12 @@ +package io.github.dovecoteescapee.byedpi.data + +const val STARTED_BROADCAST = "io.github.dovecoteescapee.byedpi.STARTED" +const val STOPPED_BROADCAST = "io.github.dovecoteescapee.byedpi.STOPPED" +const val FAILED_BROADCAST = "io.github.dovecoteescapee.byedpi.FAILED" + +const val SENDER = "sender" + +enum class Sender(val senderName: String) { + Proxy("Proxy"), + VPN("VPN") +} diff --git a/app/src/main/java/io/github/dovecoteescapee/byedpi/data/ServiceStatus.kt b/app/src/main/java/io/github/dovecoteescapee/byedpi/data/ServiceStatus.kt new file mode 100644 index 0000000..213959f --- /dev/null +++ b/app/src/main/java/io/github/dovecoteescapee/byedpi/data/ServiceStatus.kt @@ -0,0 +1,7 @@ +package io.github.dovecoteescapee.byedpi.data + +enum class ServiceStatus { + DISCONNECTED, + CONNECTED, + FAILED, +} diff --git a/app/src/main/java/io/github/dovecoteescapee/byedpi/fragments/SettingsFragment.kt b/app/src/main/java/io/github/dovecoteescapee/byedpi/fragments/SettingsFragment.kt new file mode 100644 index 0000000..dc919a5 --- /dev/null +++ b/app/src/main/java/io/github/dovecoteescapee/byedpi/fragments/SettingsFragment.kt @@ -0,0 +1,131 @@ +package io.github.dovecoteescapee.byedpi.fragments + +import android.net.InetAddresses +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.util.Patterns +import android.widget.Toast +import androidx.appcompat.app.AppCompatDelegate +import androidx.preference.DropDownPreference +import androidx.preference.EditTextPreference +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import io.github.dovecoteescapee.byedpi.BuildConfig +import io.github.dovecoteescapee.byedpi.R + +class SettingsFragment : PreferenceFragmentCompat() { + companion object { + private val TAG: String = SettingsFragment::class.java.simpleName + + fun setTheme(name: String): Boolean = when (val theme = themeByName(name)) { + null -> { + Log.w(TAG, "Invalid value for app_theme: $name") + false + } + + else -> { + AppCompatDelegate.setDefaultNightMode(theme) + true + } + } + + private fun themeByName(name: String): Int? = when (name) { + "system" -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + "light" -> AppCompatDelegate.MODE_NIGHT_NO + "dark" -> AppCompatDelegate.MODE_NIGHT_YES + else -> { + Log.w(TAG, "Invalid value for app_theme: $name") + null + } + } + + private fun checkIp(ip: String): Boolean = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + InetAddresses.isNumericAddress(ip) + } else { + // This pattern doesn't not support IPv6 + Patterns.IP_ADDRESS.matcher(ip).matches() + } + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.settings, rootKey) + + findPreference("app_theme") + ?.setOnPreferenceChangeListener { preference, newValue -> + setTheme(newValue as String) + } + + setEditTextPreferenceListener("dns_ip") { checkIp(it) } + setEditTextPreferenceListener("byedpi_proxy_ip") { checkIp(it) } + setEditTestPreferenceListenerPort("byedpi_proxy_port") + setEditTestPreferenceListenerInt( + "byedpi_max_connections", + 1, + Short.MAX_VALUE.toInt() + ) + setEditTestPreferenceListenerInt( + "byedpi_buffer_size", + 1, + Int.MAX_VALUE / 4 + ) + setEditTestPreferenceListenerInt("byedpi_default_ttl", 0, 255) + setEditTestPreferenceListenerInt( + "byedpi_split_position", + Int.MIN_VALUE, + Int.MAX_VALUE + ) + setEditTestPreferenceListenerInt("byedpi_fake_ttl", 1, 255) + setEditTestPreferenceListenerInt( + "byedpi_tlsrec_position", + 2 * Short.MIN_VALUE, + 2 * Short.MAX_VALUE, + ) + + findPreference("version")?.summary = + BuildConfig.VERSION_NAME + } + + private fun setEditTestPreferenceListenerPort(key: String) { + setEditTestPreferenceListenerInt(key, 1, 65535) + } + + private fun setEditTestPreferenceListenerInt( + key: String, + min: Int = Int.MIN_VALUE, + max: Int = Int.MAX_VALUE + ) { + setEditTextPreferenceListener(key) { value -> + value.toIntOrNull()?.let { it in min..max } ?: false + } + } + + private fun setEditTextPreferenceListener(key: String, check: (String) -> Boolean) { + findPreference(key) + ?.setOnPreferenceChangeListener { preference, newValue -> + when (newValue) { + is String -> { + val valid = check(newValue) + if (!valid) { + Toast.makeText( + requireContext(), + "Invalid value for ${preference.title}: $newValue", + Toast.LENGTH_SHORT + ).show() + } + valid + } + + else -> { + Log.w( + TAG, + "Invalid type for ${preference.key}: " + + "$newValue has type ${newValue::class.java}" + ) + false + } + } + } + } +} diff --git a/app/src/main/java/io/github/dovecoteescapee/byedpi/services/ByeDpiProxyService.kt b/app/src/main/java/io/github/dovecoteescapee/byedpi/services/ByeDpiProxyService.kt new file mode 100644 index 0000000..da9942d --- /dev/null +++ b/app/src/main/java/io/github/dovecoteescapee/byedpi/services/ByeDpiProxyService.kt @@ -0,0 +1,180 @@ +package io.github.dovecoteescapee.byedpi.services + +import android.app.Notification +import android.content.Intent +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE +import android.os.Build +import android.util.Log +import androidx.lifecycle.LifecycleService +import androidx.lifecycle.lifecycleScope +import io.github.dovecoteescapee.byedpi.R +import io.github.dovecoteescapee.byedpi.core.ByeDpiProxy +import io.github.dovecoteescapee.byedpi.core.ByeDpiProxyPreferences +import io.github.dovecoteescapee.byedpi.data.START_ACTION +import io.github.dovecoteescapee.byedpi.data.STOP_ACTION +import io.github.dovecoteescapee.byedpi.data.FAILED_BROADCAST +import io.github.dovecoteescapee.byedpi.data.SENDER +import io.github.dovecoteescapee.byedpi.data.STARTED_BROADCAST +import io.github.dovecoteescapee.byedpi.data.STOPPED_BROADCAST +import io.github.dovecoteescapee.byedpi.data.Sender +import io.github.dovecoteescapee.byedpi.data.ServiceStatus +import io.github.dovecoteescapee.byedpi.utility.createConnectionNotification +import io.github.dovecoteescapee.byedpi.utility.getPreferences +import io.github.dovecoteescapee.byedpi.utility.registerNotificationChannel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ByeDpiProxyService : LifecycleService() { + private var proxy: ByeDpiProxy? = null + private var proxyJob: Job? = null + + companion object { + private val TAG: String = ByeDpiProxyService::class.java.simpleName + private const val FOREGROUND_SERVICE_ID: Int = 2 + private const val NOTIFICATION_CHANNEL_ID: String = "ByeDPI Proxy" + + @Volatile + private var status: ServiceStatus = ServiceStatus.DISCONNECTED + } + + override fun onCreate() { + super.onCreate() + registerNotificationChannel( + this, + NOTIFICATION_CHANNEL_ID, + R.string.proxy_channel_name, + ) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) + return when (val action = intent?.action) { + START_ACTION -> { + lifecycleScope.launch { start() } + START_STICKY + } + + STOP_ACTION -> { + lifecycleScope.launch { stop() } + START_NOT_STICKY + } + + else -> { + Log.w(TAG, "Unknown action: $action") + START_NOT_STICKY + } + } + } + + private suspend fun start() { + Log.i(TAG, "Starting") + + if (status == ServiceStatus.CONNECTED) { + Log.w(TAG, "Proxy already connected") + return + } + + try { + startProxy() + updateStatus(ServiceStatus.CONNECTED) + startForeground() + } catch (e: Exception) { + Log.e(TAG, "Failed to start proxy", e) + updateStatus(ServiceStatus.FAILED) + stop() + } + } + + private fun startForeground() { + val notification: Notification = createNotification() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground( + FOREGROUND_SERVICE_ID, + notification, + FOREGROUND_SERVICE_TYPE_SPECIAL_USE, + ) + } else { + startForeground(FOREGROUND_SERVICE_ID, notification) + } + } + + private suspend fun stop() { + Log.i(TAG, "Stopping VPN") + + stopProxy() + updateStatus(ServiceStatus.DISCONNECTED) + stopSelf() + } + + private suspend fun startProxy() { + Log.i(TAG, "Starting proxy") + + if (proxy != null || proxyJob != null) { + Log.w(TAG, "Proxy fields not null") + throw IllegalStateException("Proxy fields not null") + } + + proxy = ByeDpiProxy() + val preferences = getByeDpiPreferences() + + proxyJob = lifecycleScope.launch(Dispatchers.IO) { + val code = proxy?.startProxy(preferences) + + withContext(Dispatchers.Main) { + if (code != 0) { + Log.e(TAG, "Proxy stopped with code $code") + updateStatus(ServiceStatus.FAILED) + } else { + updateStatus(ServiceStatus.DISCONNECTED) + } + } + } + + Log.i(TAG, "Proxy started") + } + + private suspend fun stopProxy() { + Log.i(TAG, "Stopping proxy") + + if (status == ServiceStatus.DISCONNECTED) { + Log.w(TAG, "Proxy already disconnected") + return + } else { + proxy?.stopProxy() + proxyJob?.join() + proxy = null + proxyJob = null + } + + Log.i(TAG, "Proxy stopped") + } + + private fun getByeDpiPreferences(): ByeDpiProxyPreferences = + ByeDpiProxyPreferences(getPreferences(this)) + + private fun updateStatus(newStatus: ServiceStatus) { + Log.d(TAG, "Proxy status changed from $status to $newStatus") + + status = newStatus + val intent = Intent( + when (newStatus) { + ServiceStatus.CONNECTED -> STARTED_BROADCAST + ServiceStatus.DISCONNECTED -> STOPPED_BROADCAST + ServiceStatus.FAILED -> FAILED_BROADCAST + } + ) + intent.putExtra(SENDER, Sender.Proxy.ordinal) + sendBroadcast(intent) + } + + private fun createNotification(): Notification = + createConnectionNotification( + this, + NOTIFICATION_CHANNEL_ID, + R.string.notification_title, + R.string.proxy_notification_content, + ByeDpiProxyService::class.java, + ) +} diff --git a/app/src/main/java/io/github/dovecoteescapee/byedpi/services/ByeDpiVpnService.kt b/app/src/main/java/io/github/dovecoteescapee/byedpi/services/ByeDpiVpnService.kt new file mode 100644 index 0000000..87e06f1 --- /dev/null +++ b/app/src/main/java/io/github/dovecoteescapee/byedpi/services/ByeDpiVpnService.kt @@ -0,0 +1,286 @@ +package io.github.dovecoteescapee.byedpi.services + +import android.app.Notification +import android.app.PendingIntent +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.ParcelFileDescriptor +import android.util.Log +import androidx.lifecycle.lifecycleScope +import engine.Engine +import engine.Key +import io.github.dovecoteescapee.byedpi.BuildConfig +import io.github.dovecoteescapee.byedpi.R +import io.github.dovecoteescapee.byedpi.activities.MainActivity +import io.github.dovecoteescapee.byedpi.core.ByeDpiProxy +import io.github.dovecoteescapee.byedpi.core.ByeDpiProxyPreferences +import io.github.dovecoteescapee.byedpi.data.START_ACTION +import io.github.dovecoteescapee.byedpi.data.STOP_ACTION +import io.github.dovecoteescapee.byedpi.data.FAILED_BROADCAST +import io.github.dovecoteescapee.byedpi.data.SENDER +import io.github.dovecoteescapee.byedpi.data.STARTED_BROADCAST +import io.github.dovecoteescapee.byedpi.data.STOPPED_BROADCAST +import io.github.dovecoteescapee.byedpi.data.Sender +import io.github.dovecoteescapee.byedpi.data.ServiceStatus +import io.github.dovecoteescapee.byedpi.utility.createConnectionNotification +import io.github.dovecoteescapee.byedpi.utility.getPreferences +import io.github.dovecoteescapee.byedpi.utility.registerNotificationChannel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext + +class ByeDpiVpnService : LifecycleVpnService() { + private var proxy: ByeDpiProxy? = null + private var proxyJob: Job? = null + private var vpn: ParcelFileDescriptor? = null + private val semaphore = Semaphore(1) + + @Volatile + private var stopping: Boolean = false + + companion object { + private val TAG: String = ByeDpiVpnService::class.java.simpleName + private const val FOREGROUND_SERVICE_ID: Int = 1 + private const val NOTIFICATION_CHANNEL_ID: String = "ByeDPIVpn" + + @Volatile + private var status: ServiceStatus = ServiceStatus.DISCONNECTED + } + + override fun onCreate() { + super.onCreate() + registerNotificationChannel( + this, + NOTIFICATION_CHANNEL_ID, + R.string.vpn_channel_name, + ) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + super.onStartCommand(intent, flags, startId) + return when (val action = intent?.action) { + START_ACTION -> { + lifecycleScope.launch { start() } + START_STICKY + } + + STOP_ACTION -> { + lifecycleScope.launch { stop() } + START_NOT_STICKY + } + + else -> { + Log.w(TAG, "Unknown action: $action") + START_NOT_STICKY + } + } + } + + override fun onRevoke() { + Log.i(TAG, "VPN revoked") + lifecycleScope.launch { stop() } + } + + private suspend fun start() { + Log.i(TAG, "Starting") + + if (status == ServiceStatus.CONNECTED) { + Log.w(TAG, "VPN already connected") + return + } + + try { + semaphore.withPermit { + startProxy() + startTun2Socks() + } + updateStatus(ServiceStatus.CONNECTED) + startForeground() + } catch (e: Exception) { + Log.e(TAG, "Failed to start VPN", e) + updateStatus(ServiceStatus.FAILED) + stop() + } + } + + private fun startForeground() { + val notification: Notification = createNotification() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground( + FOREGROUND_SERVICE_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE, + ) + } else { + startForeground(FOREGROUND_SERVICE_ID, notification) + } + } + + private suspend fun stop() { + Log.i(TAG, "Stopping") + + // Wait end of starting + semaphore.withPermit { + stopping = true + try { + stopTun2Socks() + stopProxy() + } catch (e: Exception) { + Log.e(TAG, "Failed to stop VPN", e) + } finally { + stopping = false + } + } + + updateStatus(ServiceStatus.DISCONNECTED) + stopSelf() + } + + private suspend fun startProxy() { + Log.i(TAG, "Starting proxy") + + if (proxy != null || proxyJob != null) { + Log.w(TAG, "Proxy fields not null") + throw IllegalStateException("Proxy fields not null") + } + + proxy = ByeDpiProxy() + val preferences = getByeDpiPreferences() + + proxyJob = lifecycleScope.launch(Dispatchers.IO) { + val code = proxy?.startProxy(preferences) + + withContext(Dispatchers.Main) { + if (code != 0) { + Log.e(TAG, "Proxy stopped with code $code") + updateStatus(ServiceStatus.FAILED) + } else { + if (!stopping) { + stop() + updateStatus(ServiceStatus.DISCONNECTED) + } + } + } + } + + Log.i(TAG, "Proxy started") + } + + private suspend fun stopProxy() { + Log.i(TAG, "Stopping proxy") + + if (status == ServiceStatus.DISCONNECTED) { + Log.w(TAG, "Proxy already disconnected") + return + } else { + proxy?.stopProxy() ?: throw IllegalStateException("Proxy field null") + proxyJob?.join() ?: throw IllegalStateException("ProxyJob field null") + proxy = null + proxyJob = null + } + + Log.i(TAG, "Proxy stopped") + } + + private fun startTun2Socks() { + Log.i(TAG, "Starting tun2socks") + + if (vpn != null) { + throw IllegalStateException("VPN field not null") + } + + val sharedPreferences = getPreferences(this) + val port = sharedPreferences.getString("byedpi_proxy_port", null)?.toInt() ?: 1080 + val dns = sharedPreferences.getString("dns_ip", null) ?: "9.9.9.9" + + val vpn = createBuilder(dns).establish() + ?: throw IllegalStateException("VPN connection failed") + + this.vpn = vpn +// val fd = vpn.detachFd() + Engine.insert(createKey(vpn.fd, port)) + Engine.start() + + Log.i(TAG, "Tun2Socks started") + } + + private fun stopTun2Socks() { + Log.i(TAG, "Stopping tun2socks") +// Engine.stop() // sometimes crashes with fdsan + vpn?.close() ?: Log.w(TAG, "VPN not running") // Is engine close sockets? + vpn = null + Log.i(TAG, "Tun2socks stopped") + } + + private fun getByeDpiPreferences(): ByeDpiProxyPreferences = + ByeDpiProxyPreferences(getPreferences(this)) + + private fun updateStatus(newStatus: ServiceStatus) { + Log.d(TAG, "VPN status changed from $status to $newStatus") + + status = newStatus + val intent = Intent( + when (newStatus) { + ServiceStatus.CONNECTED -> STARTED_BROADCAST + ServiceStatus.DISCONNECTED -> STOPPED_BROADCAST + ServiceStatus.FAILED -> FAILED_BROADCAST + } + ) + intent.putExtra(SENDER, Sender.VPN.ordinal) + sendBroadcast(intent) + } + + private fun createNotification(): Notification = + createConnectionNotification( + this, + NOTIFICATION_CHANNEL_ID, + R.string.notification_title, + R.string.vpn_notification_content, + ByeDpiVpnService::class.java, + ) + + private fun createBuilder(dns: String): Builder { + val builder = Builder() + builder.setSession("ByeDPI") + builder.setConfigureIntent( + PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE, + ) + ) + + builder.addAddress("10.10.10.10", 32) + builder.addRoute("0.0.0.0", 0) + builder.addRoute("0:0:0:0:0:0:0:0", 0) + builder.addDnsServer(dns) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + builder.setMetered(false) + } + + builder.addDisallowedApplication("io.github.dovecoteescapee.byedpi") + + return builder + } + + private fun createKey(fd: Int, port: Int): Key = Key().apply { + mark = 0 + mtu = 0 + device = "fd://${fd}" + + setInterface("") + logLevel = if (BuildConfig.DEBUG) "debug" else "info" + udpProxy = "direct://" + tcpProxy = "socks5://127.0.0.1:$port" + + restAPI = "" + tcpSendBufferSize = "" + tcpReceiveBufferSize = "" + tcpModerateReceiveBuffer = false + } +} diff --git a/app/src/main/java/io/github/dovecoteescapee/byedpi/services/LifecycleVpnService.kt b/app/src/main/java/io/github/dovecoteescapee/byedpi/services/LifecycleVpnService.kt new file mode 100644 index 0000000..d4bd7e4 --- /dev/null +++ b/app/src/main/java/io/github/dovecoteescapee/byedpi/services/LifecycleVpnService.kt @@ -0,0 +1,51 @@ +package io.github.dovecoteescapee.byedpi.services + +import android.content.Intent +import android.net.VpnService +import android.os.IBinder +import androidx.annotation.CallSuper +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ServiceLifecycleDispatcher + +open class LifecycleVpnService : VpnService(), LifecycleOwner { + private val dispatcher = ServiceLifecycleDispatcher(this) + + @CallSuper + override fun onCreate() { + dispatcher.onServicePreSuperOnCreate() + super.onCreate() + } + + @CallSuper + override fun onBind(intent: Intent): IBinder? { + dispatcher.onServicePreSuperOnBind() + return super.onBind(intent) + } + + @Deprecated("Deprecated in Java") + @Suppress("DEPRECATION") + @CallSuper + override fun onStart(intent: Intent?, startId: Int) { + dispatcher.onServicePreSuperOnStart() + super.onStart(intent, startId) + } + + // this method is added only to annotate it with @CallSuper. + // In usual Service, super.onStartCommand is no-op, but in LifecycleService + // it results in dispatcher.onServicePreSuperOnStart() call, because + // super.onStartCommand calls onStart(). + @CallSuper + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + return super.onStartCommand(intent, flags, startId) + } + + @CallSuper + override fun onDestroy() { + dispatcher.onServicePreSuperOnDestroy() + super.onDestroy() + } + + override val lifecycle: Lifecycle + get() = dispatcher.lifecycle +} \ No newline at end of file diff --git a/app/src/main/java/io/github/dovecoteescapee/byedpi/utility/NotificationUtils.kt b/app/src/main/java/io/github/dovecoteescapee/byedpi/utility/NotificationUtils.kt new file mode 100644 index 0000000..e7850a7 --- /dev/null +++ b/app/src/main/java/io/github/dovecoteescapee/byedpi/utility/NotificationUtils.kt @@ -0,0 +1,61 @@ +package io.github.dovecoteescapee.byedpi.utility + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.annotation.StringRes +import androidx.core.app.NotificationCompat +import io.github.dovecoteescapee.byedpi.R +import io.github.dovecoteescapee.byedpi.activities.MainActivity +import io.github.dovecoteescapee.byedpi.data.STOP_ACTION + +fun registerNotificationChannel(context: Context, id: String, @StringRes name: Int) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val manager = context.getSystemService(NotificationManager::class.java) ?: return + + val channel = NotificationChannel( + id, + context.getString(name), + NotificationManager.IMPORTANCE_DEFAULT + ) + channel.enableLights(false) + channel.enableVibration(false) + channel.setShowBadge(false) + + manager.createNotificationChannel(channel) + } +} + +fun createConnectionNotification( + context: Context, + channelId: String, + @StringRes title: Int, + @StringRes content: Int, + service: Class<*>, +): Notification = + NotificationCompat.Builder(context, channelId) + .setSmallIcon(R.mipmap.ic_launcher) + .setSilent(true) + .setContentTitle(context.getString(title)) + .setContentText(context.getString(content)) + .addAction(0, "Stop", + PendingIntent.getService( + context, + 0, + Intent(context, service).setAction(STOP_ACTION), + PendingIntent.FLAG_IMMUTABLE, + ) + ) + .setContentIntent( + PendingIntent.getActivity( + context, + 0, + Intent(context, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE, + ) + ) + .build() \ No newline at end of file diff --git a/app/src/main/java/io/github/dovecoteescapee/byedpi/utility/PreferencesUtils.kt b/app/src/main/java/io/github/dovecoteescapee/byedpi/utility/PreferencesUtils.kt new file mode 100644 index 0000000..92ef3a5 --- /dev/null +++ b/app/src/main/java/io/github/dovecoteescapee/byedpi/utility/PreferencesUtils.kt @@ -0,0 +1,15 @@ +package io.github.dovecoteescapee.byedpi.utility + +import android.content.Context +import android.content.SharedPreferences +import androidx.preference.PreferenceManager +import io.github.dovecoteescapee.byedpi.data.Mode + +fun getPreferences(context: Context): SharedPreferences = + PreferenceManager.getDefaultSharedPreferences(context) + +fun SharedPreferences.getStringNotNull(key: String, defValue: String): String = + getString(key, defValue) ?: defValue + +fun SharedPreferences.mode(): Mode = + Mode.fromString(getStringNotNull("byedpi_mode", "vpn")) diff --git a/app/src/main/res/drawable/ic_github_36.xml b/app/src/main/res/drawable/ic_github_36.xml new file mode 100644 index 0000000..1a054d2 --- /dev/null +++ b/app/src/main/res/drawable/ic_github_36.xml @@ -0,0 +1,8 @@ + + + diff --git a/app/src/main/res/drawable/outline_settings_24.xml b/app/src/main/res/drawable/outline_settings_24.xml deleted file mode 100644 index 549f086..0000000 --- a/app/src/main/res/drawable/outline_settings_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 5d31311..2681fbb 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -4,23 +4,49 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - tools:context=".MainActivity"> + tools:context=".activities.MainActivity"> + app:layout_constraintVertical_chainStyle="packed" + tools:text="@string/vpn_connect" /> + + + + diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml index cf98ff6..6361c00 100644 --- a/app/src/main/res/menu/menu_main.xml +++ b/app/src/main/res/menu/menu_main.xml @@ -2,7 +2,7 @@ + tools:context=".activities.MainActivity"> + + diff --git a/app/src/main/res/menu/menu_settings.xml b/app/src/main/res/menu/menu_settings.xml index 7b83655..84c8730 100644 --- a/app/src/main/res/menu/menu_settings.xml +++ b/app/src/main/res/menu/menu_settings.xml @@ -2,7 +2,7 @@ + tools:context=".activities.MainActivity"> - - diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index 0e99879..bfbf657 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -4,13 +4,10 @@ @color/white #E5E5E5 - @color/white + @color/black - #38AF42 - #1F8C28 - @color/black - - ?attr/colorPrimaryVariant - + #5976DF + #3C52A3 + @color/white diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 31ce441..6b27fa8 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -1,5 +1,25 @@ + + System + Light + Dark + + + system + light + dark + + + + VPN + Proxy + + + vpn + proxy + + None Split diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a4a522a..cd6688a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,7 +1,17 @@ ByeDPI - Start - Stop + Connect + Disconnect + Connected + Disconnected + Proxy is up + Proxy is down + Start + Stop + Connecting… + Disconnecting… + Starting + Stopping Settings VPN permission denied Please stop the VPN service before changing settings @@ -9,4 +19,34 @@ Save logs Reset Failed to collect logs + %1$s:%2$s + Theme + Mode + DNS + Listen address + Port + Maximum number of connections + Buffer size + Default TTL + No domain + Desync only HTTPS and TLS + Desync method + Split position + Split at host + TTL of fake packets + Host mixed case + Domain mixed case + Host remove spaces + Split TLS record + TLS record split position + Split TLS record at SNI + Version + Source code + What does all of this mean? + Failed to start %1$s + VPN + Proxy + ByeDPI + VPN is running + Proxy is running \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index bf9d93b..5640a33 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -6,9 +6,9 @@ #3C52A3 @color/white - #38AF42 - #1F8C28 - @color/black + #5976DF + #3C52A3 + @color/white ?attr/colorPrimaryVariant diff --git a/app/src/main/res/xml/settings.xml b/app/src/main/res/xml/settings.xml index c318e6b..78d957a 100644 --- a/app/src/main/res/xml/settings.xml +++ b/app/src/main/res/xml/settings.xml @@ -1,109 +1,172 @@ - + - + - + - + - + - + + + - + - + - + - + - + - + - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + +