diff --git a/client/android/aidl/com/github/shadowsocks/aidl/IShadowsocksService.aidl b/client/android/aidl/com/github/shadowsocks/aidl/IShadowsocksService.aidl
new file mode 100644
index 000000000..026455be5
--- /dev/null
+++ b/client/android/aidl/com/github/shadowsocks/aidl/IShadowsocksService.aidl
@@ -0,0 +1,13 @@
+package com.github.shadowsocks.aidl;
+
+import com.github.shadowsocks.aidl.IShadowsocksServiceCallback;
+
+interface IShadowsocksService {
+ int getState();
+ String getProfileName();
+
+ void registerCallback(in IShadowsocksServiceCallback cb);
+ void startListeningForBandwidth(in IShadowsocksServiceCallback cb, long timeout);
+ oneway void stopListeningForBandwidth(in IShadowsocksServiceCallback cb);
+ oneway void unregisterCallback(in IShadowsocksServiceCallback cb);
+}
diff --git a/client/android/aidl/com/github/shadowsocks/aidl/IShadowsocksServiceCallback.aidl b/client/android/aidl/com/github/shadowsocks/aidl/IShadowsocksServiceCallback.aidl
new file mode 100644
index 000000000..5d4caa817
--- /dev/null
+++ b/client/android/aidl/com/github/shadowsocks/aidl/IShadowsocksServiceCallback.aidl
@@ -0,0 +1,11 @@
+package com.github.shadowsocks.aidl;
+
+import com.github.shadowsocks.aidl.TrafficStats;
+
+//"oneway" unexpected. xinlake
+interface IShadowsocksServiceCallback {
+ oneway void stateChanged(int state, String profileName, String msg);
+ oneway void trafficUpdated(long profileId, in TrafficStats stats);
+ // Traffic data has persisted to database, listener should refetch their data from database
+ oneway void trafficPersisted(long profileId);
+}
diff --git a/client/android/aidl/com/github/shadowsocks/aidl/TrafficStats.aidl b/client/android/aidl/com/github/shadowsocks/aidl/TrafficStats.aidl
new file mode 100644
index 000000000..8668fa849
--- /dev/null
+++ b/client/android/aidl/com/github/shadowsocks/aidl/TrafficStats.aidl
@@ -0,0 +1,3 @@
+package com.github.shadowsocks.aidl;
+
+parcelable TrafficStats;
diff --git a/client/android/assets/acl/bypass-lan.acl b/client/android/assets/acl/bypass-lan.acl
new file mode 100644
index 000000000..89ca7a726
--- /dev/null
+++ b/client/android/assets/acl/bypass-lan.acl
@@ -0,0 +1,20 @@
+[proxy_all]
+
+[bypass_list]
+0.0.0.0/8
+10.0.0.0/8
+100.64.0.0/10
+127.0.0.0/8
+169.254.0.0/16
+172.16.0.0/12
+192.0.0.0/24
+192.0.2.0/24
+192.31.196.0/24
+192.52.193.0/24
+192.88.99.0/24
+192.168.0.0/16
+192.175.48.0/24
+198.18.0.0/15
+198.51.100.0/24
+203.0.113.0/24
+224.0.0.0/3
diff --git a/client/android/build.gradle b/client/android/build.gradle
index fd2afa467..3796c1456 100644
--- a/client/android/build.gradle
+++ b/client/android/build.gradle
@@ -1,6 +1,6 @@
buildscript {
ext{
- kotlin_version = "1.4.30-M1"
+ kotlin_version = "1.5.0"
// for libwg
appcompatVersion = '1.1.0'
annotationsVersion = '1.0.1'
@@ -43,6 +43,15 @@ dependencies {
implementation "androidx.security:security-identity-credential:1.0.0-alpha02"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.2.2"
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:1.0.10"
+ //ss
+ implementation "androidx.preference:preference:1.1.0"
+ implementation "androidx.work:work-runtime-ktx:2.3.4"
+ implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0"
+ implementation "androidx.room:room-runtime:2.2.5" // runtime
+ implementation "dnsjava:dnsjava:2.1.9"
+ implementation "com.google.code.gson:gson:2.8.5"
+ implementation "org.connectbot.jsocks:jsocks:1.0.0"
+ annotationProcessor "androidx.room:room-compiler:2.3.0"
}
android {
@@ -88,6 +97,7 @@ android {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
+ kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8
lintOptions {
abortOnError false
diff --git a/client/android/gradlew b/client/android/gradlew
old mode 100644
new mode 100755
diff --git a/client/android/res/drawable/ic_navigation_close.xml b/client/android/res/drawable/ic_navigation_close.xml
new file mode 100644
index 000000000..e5cc60ced
--- /dev/null
+++ b/client/android/res/drawable/ic_navigation_close.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/client/android/res/drawable/ic_service_active.xml b/client/android/res/drawable/ic_service_active.xml
new file mode 100644
index 000000000..33062676e
--- /dev/null
+++ b/client/android/res/drawable/ic_service_active.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/client/android/res/drawable/ic_service_busy.xml b/client/android/res/drawable/ic_service_busy.xml
new file mode 100644
index 000000000..910b53508
--- /dev/null
+++ b/client/android/res/drawable/ic_service_busy.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/client/android/res/drawable/ic_service_connected.xml b/client/android/res/drawable/ic_service_connected.xml
new file mode 100644
index 000000000..4d6a3272b
--- /dev/null
+++ b/client/android/res/drawable/ic_service_connected.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
diff --git a/client/android/res/drawable/ic_service_connecting.xml b/client/android/res/drawable/ic_service_connecting.xml
new file mode 100644
index 000000000..428938b23
--- /dev/null
+++ b/client/android/res/drawable/ic_service_connecting.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/android/res/drawable/ic_service_idle.xml b/client/android/res/drawable/ic_service_idle.xml
new file mode 100644
index 000000000..6cea1b698
--- /dev/null
+++ b/client/android/res/drawable/ic_service_idle.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
diff --git a/client/android/res/drawable/ic_service_stopped.xml b/client/android/res/drawable/ic_service_stopped.xml
new file mode 100644
index 000000000..b06e907c3
--- /dev/null
+++ b/client/android/res/drawable/ic_service_stopped.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/android/res/drawable/ic_service_stopping.xml b/client/android/res/drawable/ic_service_stopping.xml
new file mode 100644
index 000000000..49a937a6c
--- /dev/null
+++ b/client/android/res/drawable/ic_service_stopping.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
diff --git a/client/android/res/drawable/ic_social_share.xml b/client/android/res/drawable/ic_social_share.xml
new file mode 100644
index 000000000..fb58d5635
--- /dev/null
+++ b/client/android/res/drawable/ic_social_share.xml
@@ -0,0 +1,11 @@
+
+
+
diff --git a/client/android/res/values/arrays.xml b/client/android/res/values/arrays.xml
new file mode 100644
index 000000000..d4a91258f
--- /dev/null
+++ b/client/android/res/values/arrays.xml
@@ -0,0 +1,210 @@
+
+
+
+ - @string/add_profile_methods_scan_qr_code
+ - @string/action_import_file
+ - @string/add_profile_methods_manual_settings
+
+
+
+ - RC4-MD5
+ - AES-128-CFB
+ - AES-192-CFB
+ - AES-256-CFB
+ - AES-128-CTR
+ - AES-192-CTR
+ - AES-256-CTR
+ - BF-CFB
+ - CAMELLIA-128-CFB
+ - CAMELLIA-192-CFB
+ - CAMELLIA-256-CFB
+ - SALSA20
+ - CHACHA20
+ - CHACHA20-IETF
+ - AES-128-GCM
+ - AES-192-GCM
+ - AES-256-GCM
+ - CHACHA20-IETF-POLY1305
+ - XCHACHA20-IETF-POLY1305
+
+
+
+ - rc4-md5
+ - aes-128-cfb
+ - aes-192-cfb
+ - aes-256-cfb
+ - aes-128-ctr
+ - aes-192-ctr
+ - aes-256-ctr
+ - bf-cfb
+ - camellia-128-cfb
+ - camellia-192-cfb
+ - camellia-256-cfb
+ - salsa20
+ - chacha20
+ - chacha20-ietf
+ - aes-128-gcm
+ - aes-192-gcm
+ - aes-256-gcm
+ - chacha20-ietf-poly1305
+ - xchacha20-ietf-poly1305
+
+
+
+ - 1.0.0.0/8
+ - 2.0.0.0/7
+ - 4.0.0.0/6
+ - 8.0.0.0/7
+ - 11.0.0.0/8
+ - 12.0.0.0/6
+ - 16.0.0.0/4
+ - 32.0.0.0/3
+ - 64.0.0.0/3
+ - 96.0.0.0/6
+ - 100.0.0.0/10
+ - 100.128.0.0/9
+ - 101.0.0.0/8
+ - 102.0.0.0/7
+ - 104.0.0.0/5
+ - 112.0.0.0/10
+ - 112.64.0.0/11
+ - 112.96.0.0/12
+ - 112.112.0.0/13
+ - 112.120.0.0/14
+ - 112.124.0.0/19
+ - 112.124.32.0/21
+ - 112.124.40.0/22
+ - 112.124.44.0/23
+ - 112.124.46.0/24
+ - 112.124.48.0/20
+ - 112.124.64.0/18
+ - 112.124.128.0/17
+ - 112.125.0.0/16
+ - 112.126.0.0/15
+ - 112.128.0.0/9
+ - 113.0.0.0/8
+ - 114.0.0.0/10
+ - 114.64.0.0/11
+ - 114.96.0.0/12
+ - 114.112.0.0/15
+ - 114.114.0.0/18
+ - 114.114.64.0/19
+ - 114.114.96.0/20
+ - 114.114.112.0/23
+ - 114.114.115.0/24
+ - 114.114.116.0/22
+ - 114.114.120.0/21
+ - 114.114.128.0/17
+ - 114.115.0.0/16
+ - 114.116.0.0/14
+ - 114.120.0.0/13
+ - 114.128.0.0/9
+ - 115.0.0.0/8
+ - 116.0.0.0/6
+ - 120.0.0.0/6
+ - 124.0.0.0/7
+ - 126.0.0.0/8
+ - 128.0.0.0/3
+ - 160.0.0.0/5
+ - 168.0.0.0/8
+ - 169.0.0.0/9
+ - 169.128.0.0/10
+ - 169.192.0.0/11
+ - 169.224.0.0/12
+ - 169.240.0.0/13
+ - 169.248.0.0/14
+ - 169.252.0.0/15
+ - 169.255.0.0/16
+ - 170.0.0.0/7
+ - 172.0.0.0/12
+ - 172.32.0.0/11
+ - 172.64.0.0/10
+ - 172.128.0.0/9
+ - 173.0.0.0/8
+ - 174.0.0.0/7
+ - 176.0.0.0/4
+ - 192.0.0.8/29
+ - 192.0.0.16/28
+ - 192.0.0.32/27
+ - 192.0.0.64/26
+ - 192.0.0.128/25
+ - 192.0.1.0/24
+ - 192.0.3.0/24
+ - 192.0.4.0/22
+ - 192.0.8.0/21
+ - 192.0.16.0/20
+ - 192.0.32.0/19
+ - 192.0.64.0/18
+ - 192.0.128.0/17
+ - 192.1.0.0/16
+ - 192.2.0.0/15
+ - 192.4.0.0/14
+ - 192.8.0.0/13
+ - 192.16.0.0/12
+ - 192.32.0.0/11
+ - 192.64.0.0/12
+ - 192.80.0.0/13
+ - 192.88.0.0/18
+ - 192.88.64.0/19
+ - 192.88.96.0/23
+ - 192.88.98.0/24
+ - 192.88.100.0/22
+ - 192.88.104.0/21
+ - 192.88.112.0/20
+ - 192.88.128.0/17
+ - 192.89.0.0/16
+ - 192.90.0.0/15
+ - 192.92.0.0/14
+ - 192.96.0.0/11
+ - 192.128.0.0/11
+ - 192.160.0.0/13
+ - 192.169.0.0/16
+ - 192.170.0.0/15
+ - 192.172.0.0/14
+ - 192.176.0.0/12
+ - 192.192.0.0/10
+ - 193.0.0.0/8
+ - 194.0.0.0/7
+ - 196.0.0.0/7
+ - 198.0.0.0/12
+ - 198.16.0.0/15
+ - 198.20.0.0/14
+ - 198.24.0.0/13
+ - 198.32.0.0/12
+ - 198.48.0.0/15
+ - 198.50.0.0/16
+ - 198.51.0.0/18
+ - 198.51.64.0/19
+ - 198.51.96.0/22
+ - 198.51.101.0/24
+ - 198.51.102.0/23
+ - 198.51.104.0/21
+ - 198.51.112.0/20
+ - 198.51.128.0/17
+ - 198.52.0.0/14
+ - 198.56.0.0/13
+ - 198.64.0.0/10
+ - 198.128.0.0/9
+ - 199.0.0.0/8
+ - 200.0.0.0/7
+ - 202.0.0.0/8
+ - 203.0.0.0/18
+ - 203.0.64.0/19
+ - 203.0.96.0/20
+ - 203.0.112.0/24
+ - 203.0.114.0/23
+ - 203.0.116.0/22
+ - 203.0.120.0/21
+ - 203.0.128.0/17
+ - 203.1.0.0/16
+ - 203.2.0.0/15
+ - 203.4.0.0/14
+ - 203.8.0.0/13
+ - 203.16.0.0/12
+ - 203.32.0.0/11
+ - 203.64.0.0/10
+ - 203.128.0.0/9
+ - 204.0.0.0/6
+ - 208.0.0.0/4
+
+
diff --git a/client/android/res/values/colors.xml b/client/android/res/values/colors.xml
new file mode 100644
index 000000000..422f8369d
--- /dev/null
+++ b/client/android/res/values/colors.xml
@@ -0,0 +1,35 @@
+
+
+ @color/material_primary_100
+ @color/material_primary_300
+ #7488A1
+
+
+ #388E3C
+ #00C853
+ #CFD8DC
+ #90A4AE
+ #607D8B
+ #546E7A
+ #455A64
+ @color/material_blue_grey_100
+ @color/material_blue_grey_300
+ @color/material_blue_grey_500
+ @color/material_blue_grey_600
+ @color/material_blue_grey_700
+ @color/material_blue_grey_800
+ @color/material_blue_grey_900
+ @color/material_green_a700
+
+ @color/material_primary_500
+ @color/material_primary_700
+ @color/material_primary_500
+ @color/material_primary_800
+ @color/material_primary_900
+ @color/material_primary_300
+
+ @color/light_color_primary
+ @color/light_color_primary_dark
+ @color/light_color_primary_text
+
+
diff --git a/client/android/res/values/dimen.xml b/client/android/res/values/dimen.xml
new file mode 100644
index 000000000..beb517f7b
--- /dev/null
+++ b/client/android/res/values/dimen.xml
@@ -0,0 +1,7 @@
+
+
+ 250dp
+ 8dp
+ 88dp
+ 8dp
+
diff --git a/client/android/res/values/strings.xml b/client/android/res/values/strings.xml
new file mode 100644
index 000000000..19b311346
--- /dev/null
+++ b/client/android/res/values/strings.xml
@@ -0,0 +1,113 @@
+
+
+ Shadowsocks
+ Send email
+
+
+ Server Settings
+ Feature Settings
+ Changes not saved. Do you want to save?
+ Yes
+ No
+ Apply
+ File Explorer Missing
+ Browse…
+
+
+ Profile Name
+ Server
+ Remote Port
+ Password
+ Encrypt Method
+
+
+ IPv6 Route
+ Redirect IPv6 traffic to remote
+ On
+ Off
+ Toggling might require ROOT permission
+ Unsupported kernel version: %s < 3.7.1
+ Toggle failed
+ Send DNS over UDP
+ Requires UDP forwarding on server side
+
+
+ VPN Service
+ Shadowsocks started.
+ Invalid server name
+ Failed to connect the remote server
+ Stop
+ Shutting down…
+ %s
+ Permission denied to create a VPN service
+ Failed to start VPN service. You might need to reboot your device.
+ No valid profile data found.
+
+
+ Please select a profile
+ Proxy/Password should not be empty
+ Connect
+
+
+ Profiles
+ Settings
+ About
+ Shadowsocks %s
+ Edit
+ Share
+ Add Profile
+ Apply Settings to All Profiles
+ Export…
+ Export to file…
+ Export to Clipboard
+ Import from Clipboard
+ Import from file…
+ Replace from file…
+ Successfully export!
+ Failed to export.
+ Successfully import!
+ Failed to import.
+ Fetch location
+
+
+ Profile config
+ Remove
+ Are you sure you want to remove this profile?
+ QR code
+ Add this Shadowsocks Profile?
+ Scan QR code
+ Manual Settings
+ Camera permission is required for scanning QR code.
+ Undo
+
+
+ Connecting…
+ Connected, tap to check connection
+ Not connected
+
+ Sent
+ Received
+
+
+ There is no profile currently, would you like to add it now?
+ SOCKS5 proxy port
+ Local DNS port
+
+ Toggle
+ Remote DNS
+ Sent: \t\t\t\t\t%3$s\t↑\t%1$s\nReceived: \t%4$s\t↓\t%2$s
+ Check Connectivity
+ Testing…
+ Success: HTTPS handshake took %dms
+ Fail to detect internet connection: %s
+ Internet Unavailable
+ Error code: #%d
+
+ %s/s
+ %1$s↑\t%2$s↓
+
+
+ - Removed
+ - %d items removed
+
+
diff --git a/client/android/src/com/github/shadowsocks/Core.kt b/client/android/src/com/github/shadowsocks/Core.kt
new file mode 100644
index 000000000..d98df7e4f
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/Core.kt
@@ -0,0 +1,165 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2018 by Max Lv *
+ * Copyright (C) 2018 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks
+
+import android.app.Application
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.admin.DevicePolicyManager
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.pm.PackageInfo
+import android.content.pm.PackageManager
+import android.net.ConnectivityManager
+import android.os.Build
+import android.os.UserManager
+import androidx.annotation.RequiresApi
+import androidx.core.content.ContextCompat
+import androidx.core.content.getSystemService
+import androidx.work.Configuration
+import androidx.work.WorkManager
+import com.github.shadowsocks.aidl.ShadowsocksConnection
+import com.github.shadowsocks.database.Profile
+import com.github.shadowsocks.database.ProfileManager
+import com.github.shadowsocks.net.TcpFastOpen
+import com.github.shadowsocks.preference.DataStore
+import com.github.shadowsocks.utils.*
+import kotlinx.coroutines.DEBUG_PROPERTY_NAME
+import kotlinx.coroutines.DEBUG_PROPERTY_VALUE_ON
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+import org.amnezia.vpn.R
+import java.io.File
+import java.io.IOException
+import kotlin.reflect.KClass
+
+object Core {
+ const val TAG = "Core"
+
+ lateinit var app: Application
+ lateinit var configureIntent: (Context) -> PendingIntent
+ val connectivity by lazy { app.getSystemService()!! }
+ val packageInfo: PackageInfo by lazy { getPackageInfo(app.packageName) }
+ val deviceStorage by lazy { if (Build.VERSION.SDK_INT < 24) app else DeviceStorageApp(app) }
+ val directBootSupported by lazy {
+ Build.VERSION.SDK_INT >= 24 && app.getSystemService()?.storageEncryptionStatus == DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE_PER_USER
+ }
+
+ val activeProfileIds
+ get() = ProfileManager.getProfile(DataStore.profileId).let {
+ if (it == null) emptyList() else listOfNotNull(it.id)
+ }
+ val currentProfile: Pair?
+ get() {
+ if (DataStore.directBootAware) DirectBoot.getDeviceProfile()?.apply { return this }
+ return ProfileManager.expand(ProfileManager.getProfile(DataStore.profileId)
+ ?: return null)
+ }
+
+ fun switchProfile(id: Long): Profile {
+ val result = ProfileManager.getProfile(id) ?: ProfileManager.createProfile()
+ DataStore.profileId = result.id
+ return result
+ }
+
+ fun init(app: Application, configureClass: KClass) {
+ this.app = app
+ this.configureIntent = {
+ PendingIntent.getActivity(it,
+ 0,
+ Intent(it,
+ configureClass.java).setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
+ 0)
+ }
+
+ if (Build.VERSION.SDK_INT >= 24) { // migrate old files
+ deviceStorage.moveDatabaseFrom(app, Key.DB_PUBLIC)
+ }
+
+ // overhead of debug mode is minimal: https://github.com/Kotlin/kotlinx.coroutines/blob/f528898/docs/debugging.md#debug-mode
+ System.setProperty(DEBUG_PROPERTY_NAME, DEBUG_PROPERTY_VALUE_ON)
+ // Fabric.with(deviceStorage, Crashlytics()) // multiple processes needs manual set-up
+ // FirebaseApp.initializeApp(deviceStorage)
+ WorkManager.initialize(deviceStorage, Configuration.Builder().apply {
+ setExecutor { GlobalScope.launch { it.run() } }
+ setTaskExecutor { GlobalScope.launch { it.run() } }
+ }.build())
+
+ // handle data restored/crash
+ if (Build.VERSION.SDK_INT >= 24 && DataStore.directBootAware && app.getSystemService()?.isUserUnlocked == true) DirectBoot.flushTrafficStats()
+ if (DataStore.tcpFastOpen && !TcpFastOpen.sendEnabled) TcpFastOpen.enableTimeout()
+ if (DataStore.publicStore.getLong(Key.assetUpdateTime, -1) != packageInfo.lastUpdateTime) {
+ val assetManager = app.assets
+ try {
+ for (file in assetManager.list("acl")!!) assetManager.open("acl/$file").use { input ->
+ File(deviceStorage.noBackupFilesDir, file).outputStream()
+ .use { output -> input.copyTo(output) }
+ }
+ } catch (e: IOException) {
+ printLog(e)
+ }
+ DataStore.publicStore.putLong(Key.assetUpdateTime, packageInfo.lastUpdateTime)
+ }
+ updateNotificationChannels()
+ }
+
+ fun updateNotificationChannels() {
+ if (Build.VERSION.SDK_INT >= 26) @RequiresApi(26) {
+ val nm = app.getSystemService()!!
+ nm.createNotificationChannels(listOf(NotificationChannel("service-vpn",
+ app.getText(R.string.service_vpn),
+ if (Build.VERSION.SDK_INT >= 28) NotificationManager.IMPORTANCE_MIN
+ else NotificationManager.IMPORTANCE_LOW) // #1355
+ ))
+ nm.deleteNotificationChannel("service-nat") // NAT mode is gone for good
+ }
+ }
+
+ fun getPackageInfo(packageName: String) = app.packageManager.getPackageInfo(packageName,
+ if (Build.VERSION.SDK_INT >= 28) PackageManager.GET_SIGNING_CERTIFICATES
+ else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES)!!
+
+ fun startService() =
+ ContextCompat.startForegroundService(app, Intent(app, ShadowsocksConnection.serviceClass))
+
+ fun reloadService() = app.sendBroadcast(Intent(Action.RELOAD))
+ fun stopService() = app.sendBroadcast(Intent(Action.CLOSE))
+
+ fun listenForPackageChanges(onetime: Boolean = true, callback: () -> Unit) =
+ object : BroadcastReceiver() {
+ init {
+ app.registerReceiver(this, IntentFilter().apply {
+ addAction(Intent.ACTION_PACKAGE_ADDED)
+ addAction(Intent.ACTION_PACKAGE_REMOVED)
+ addDataScheme("package")
+ })
+ }
+
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)) return
+ callback()
+ if (onetime) app.unregisterReceiver(this)
+ }
+ }
+}
diff --git a/client/android/src/com/github/shadowsocks/LocalVpnService.kt b/client/android/src/com/github/shadowsocks/LocalVpnService.kt
new file mode 100644
index 000000000..f5bdf7dd7
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/LocalVpnService.kt
@@ -0,0 +1,215 @@
+package com.github.shadowsocks
+
+import android.app.Service
+import android.content.Intent
+import android.net.LocalSocket
+import android.net.LocalSocketAddress
+import android.net.Network
+import android.net.VpnService
+import android.os.Build
+import android.os.ParcelFileDescriptor
+import android.system.ErrnoException
+import android.system.Os
+import com.github.shadowsocks.bg.*
+import com.github.shadowsocks.net.ConcurrentLocalSocketListener
+import com.github.shadowsocks.net.DefaultNetworkListener
+import com.github.shadowsocks.net.HostsFile
+import com.github.shadowsocks.net.Subnet
+import com.github.shadowsocks.preference.DataStore
+import com.github.shadowsocks.utils.printLog
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import java.io.Closeable
+import java.io.File
+import java.io.FileDescriptor
+import java.io.IOException
+import java.net.URL
+import org.amnezia.vpn.R
+
+class LocalVpnService : VpnService(), LocalDnsService.Interface {
+ companion object {
+ private const val VPN_MTU = 1500
+ private const val PRIVATE_VLAN4_CLIENT = "172.19.0.1"
+ private const val PRIVATE_VLAN4_ROUTER = "172.19.0.2"
+ private const val PRIVATE_VLAN6_CLIENT = "fdfe:dcba:9876::1"
+ private const val PRIVATE_VLAN6_ROUTER = "fdfe:dcba:9876::2"
+
+ /**
+ * https://android.googlesource.com/platform/prebuilts/runtime/+/94fec32/appcompat/hiddenapi-light-greylist.txt#9466
+ */
+ private val getInt = FileDescriptor::class.java.getDeclaredMethod("getInt$")
+ }
+
+ class CloseableFd(val fd: FileDescriptor) : Closeable {
+ override fun close() = Os.close(fd)
+ }
+
+ private inner class ProtectWorker : ConcurrentLocalSocketListener(
+ "ShadowsocksVpnThread",
+ File(Core.deviceStorage.noBackupFilesDir, "protect_path")
+ ) {
+ override fun acceptInternal(socket: LocalSocket) {
+ socket.inputStream.read()
+ val fd = socket.ancillaryFileDescriptors!!.single()!!
+ CloseableFd(fd).use {
+ socket.outputStream.write(if (underlyingNetwork.let { network ->
+ if (network != null && Build.VERSION.SDK_INT >= 23) try {
+ network.bindSocket(fd)
+ true
+ } catch (e: IOException) {
+ // suppress ENONET (Machine is not on the network)
+ if ((e.cause as? ErrnoException)?.errno != 64) printLog(e)
+ false
+ } else protect(getInt.invoke(fd) as Int)
+ }) 0 else 1)
+ }
+ }
+ }
+
+ inner class NullConnectionException : NullPointerException(), BaseService.ExpectedException {
+ override fun getLocalizedMessage() = getString(R.string.reboot_required)
+ }
+
+ override val data = BaseService.Data(this)
+ override val tag: String get() = "ShadowsocksVpnService"
+ override fun createNotification(profileName: String): ServiceNotification =
+ ServiceNotification(this, profileName, "service-vpn")
+
+ private var conn: ParcelFileDescriptor? = null
+ private var worker: ProtectWorker? = null
+ private var active = false
+
+ // metered = false. xinlake
+ private var underlyingNetwork: Network? = null
+ set(value) {
+ field = value
+ if (active) setUnderlyingNetworks(underlyingNetworks)
+ }
+ private val underlyingNetworks
+ get() = // clearing underlyingNetworks makes Android 9 consider the network to be metered
+ underlyingNetwork?.let { arrayOf(it) }
+
+ override fun onBind(intent: Intent) = when (intent.action) {
+ SERVICE_INTERFACE -> super.onBind(intent)
+ else -> super.onBind(intent)
+ }
+
+ override fun onRevoke() = stopRunner()
+
+ override fun killProcesses(scope: CoroutineScope) {
+ super.killProcesses(scope)
+ active = false
+ scope.launch { DefaultNetworkListener.stop(this) }
+ worker?.shutdown(scope)
+ worker = null
+ conn?.close()
+ conn = null
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (prepare(this) != null) {
+// startActivity(Intent(this, VpnRequestActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
+ } else return super.onStartCommand(intent, flags, startId)
+
+ stopRunner()
+ return Service.START_NOT_STICKY
+ }
+
+ override suspend fun preInit() = DefaultNetworkListener.start(this) { underlyingNetwork = it }
+ override suspend fun resolver(host: String) =
+ DnsResolverCompat.resolve(DefaultNetworkListener.get(), host)
+
+ override suspend fun openConnection(url: URL) = DefaultNetworkListener.get().openConnection(url)
+
+ override suspend fun startProcesses(hosts: HostsFile) {
+ worker = ProtectWorker().apply { start() }
+ super.startProcesses(hosts)
+ sendFd(startVpn())
+ }
+
+ override fun buildAdditionalArguments(cmd: ArrayList): ArrayList {
+ cmd += "-V"
+ return cmd
+ }
+
+ private suspend fun startVpn(): FileDescriptor {
+ val profile = data.proxy!!.profile
+ val builder = Builder()
+ .setConfigureIntent(Core.configureIntent(this))
+ .setSession(profile.formattedName)
+ .setMtu(VPN_MTU)
+ .addAddress(PRIVATE_VLAN4_CLIENT, 30)
+ .addDnsServer(PRIVATE_VLAN4_ROUTER)
+
+ if (profile.ipv6) {
+ builder.addAddress(PRIVATE_VLAN6_CLIENT, 126)
+ builder.addRoute("::", 0)
+ }
+
+ // XinLake. bypass lan
+ resources.getStringArray(R.array.bypass_private_route).forEach {
+ val subnet = Subnet.fromString(it)!!
+ builder.addRoute(subnet.address.hostAddress, subnet.prefixSize)
+ }
+ builder.addRoute(PRIVATE_VLAN4_ROUTER, 32)
+
+ active = true // possible race condition here?
+ if (Build.VERSION.SDK_INT >= 22) {
+ builder.setUnderlyingNetworks(underlyingNetworks)
+ }
+
+ val conn = builder.establish() ?: throw NullConnectionException()
+ this.conn = conn
+
+ val cmd = arrayListOf(
+ File(applicationInfo.nativeLibraryDir, Executable.TUN2SOCKS).absolutePath,
+ "--netif-ipaddr", PRIVATE_VLAN4_ROUTER,
+ "--socks-server-addr", "${DataStore.listenAddress}:${DataStore.portProxy}",
+ "--tunmtu", VPN_MTU.toString(),
+ "--sock-path", "sock_path",
+ "--dnsgw", "127.0.0.1:${DataStore.portLocalDns}",
+ "--loglevel", "warning"
+ )
+ if (profile.ipv6) {
+ cmd += "--netif-ip6addr"
+ cmd += PRIVATE_VLAN6_ROUTER
+ }
+ cmd += "--enable-udprelay"
+ data.processes!!.start(cmd, onRestartCallback = {
+ try {
+ sendFd(conn.fileDescriptor)
+ } catch (e: ErrnoException) {
+ stopRunner(false, e.message)
+ }
+ })
+ return conn.fileDescriptor
+ }
+
+ private suspend fun sendFd(fd: FileDescriptor) {
+ var tries = 0
+ val path = File(Core.deviceStorage.noBackupFilesDir, "sock_path").absolutePath
+ while (true) try {
+ delay(50L shl tries)
+ LocalSocket().use { localSocket ->
+ localSocket.connect(
+ LocalSocketAddress(
+ path,
+ LocalSocketAddress.Namespace.FILESYSTEM
+ )
+ )
+ localSocket.setFileDescriptorsForSend(arrayOf(fd))
+ localSocket.outputStream.write(42)
+ }
+ return
+ } catch (e: IOException) {
+ if (tries > 5) throw e
+ tries += 1
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ data.binder.close()
+ }
+}
diff --git a/client/android/src/com/github/shadowsocks/aidl/ShadowsocksConnection.kt b/client/android/src/com/github/shadowsocks/aidl/ShadowsocksConnection.kt
new file mode 100644
index 000000000..9c67a51e5
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/aidl/ShadowsocksConnection.kt
@@ -0,0 +1,160 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2017 by Max Lv *
+ * Copyright (C) 2017 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.aidl
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.ServiceConnection
+import android.os.Handler
+import android.os.IBinder
+import android.os.RemoteException
+import com.github.shadowsocks.LocalVpnService
+import com.github.shadowsocks.bg.BaseService
+import com.github.shadowsocks.utils.Action
+
+/**
+ * This object should be compact as it will not get GC-ed.
+ */
+class ShadowsocksConnection(
+ private val handler: Handler = Handler(),
+ private var listenForDeath: Boolean = false
+) : ServiceConnection,
+ IBinder.DeathRecipient {
+ companion object {
+ val serviceClass = LocalVpnService::class.java
+ }
+
+ interface Callback {
+ fun stateChanged(state: BaseService.State, profileName: String?, msg: String?)
+ fun trafficUpdated(profileId: Long, stats: TrafficStats) {}
+ fun trafficPersisted(profileId: Long) {}
+
+ fun onServiceConnected(service: IShadowsocksService)
+
+ /**
+ * Different from Android framework, this method will be called even when you call `detachService`.
+ */
+ fun onServiceDisconnected() {}
+
+ fun onBinderDied() {}
+ }
+
+ private var connectionActive = false
+ private var callbackRegistered = false
+ private var callback: Callback? = null
+ private val serviceCallback = object : IShadowsocksServiceCallback.Stub() {
+ override fun stateChanged(state: Int, profileName: String?, msg: String?) {
+ val callback = callback ?: return
+ handler.post {
+ callback.stateChanged(BaseService.State.values()[state], profileName, msg)
+ }
+ }
+
+ override fun trafficUpdated(profileId: Long, stats: TrafficStats) {
+ val callback = callback ?: return
+ handler.post { callback.trafficUpdated(profileId, stats) }
+ }
+
+ override fun trafficPersisted(profileId: Long) {
+ val callback = callback ?: return
+ handler.post { callback.trafficPersisted(profileId) }
+ }
+ }
+ private var binder: IBinder? = null
+
+ var bandwidthTimeout = 0L
+ set(value) {
+ try {
+ if (value > 0) service?.startListeningForBandwidth(serviceCallback, value)
+ else service?.stopListeningForBandwidth(serviceCallback)
+ } catch (_: RemoteException) {
+ }
+ field = value
+ }
+ var service: IShadowsocksService? = null
+
+ override fun onServiceConnected(name: ComponentName?, binder: IBinder) {
+ this.binder = binder
+ val service = IShadowsocksService.Stub.asInterface(binder)!!
+ this.service = service
+ try {
+ if (listenForDeath) binder.linkToDeath(this, 0)
+ check(!callbackRegistered)
+ service.registerCallback(serviceCallback)
+ callbackRegistered = true
+ if (bandwidthTimeout > 0) service.startListeningForBandwidth(
+ serviceCallback,
+ bandwidthTimeout
+ )
+ } catch (_: RemoteException) {
+ }
+ callback!!.onServiceConnected(service)
+ }
+
+ override fun onServiceDisconnected(name: ComponentName?) {
+ unregisterCallback()
+ callback?.onServiceDisconnected()
+ service = null
+ binder = null
+ }
+
+ override fun binderDied() {
+ service = null
+ callbackRegistered = false
+ callback?.also { handler.post(it::onBinderDied) }
+ }
+
+ private fun unregisterCallback() {
+ val service = service
+ if (service != null && callbackRegistered) try {
+ service.unregisterCallback(serviceCallback)
+ } catch (_: RemoteException) {
+ }
+ callbackRegistered = false
+ }
+
+ fun connect(context: Context, callback: Callback) {
+ if (connectionActive) return
+ connectionActive = true
+ check(this.callback == null)
+ this.callback = callback
+ val intent = Intent(context, serviceClass).setAction(Action.SERVICE)
+ context.bindService(intent, this, Context.BIND_AUTO_CREATE)
+ }
+
+ fun disconnect(context: Context) {
+ unregisterCallback()
+ if (connectionActive) try {
+ context.unbindService(this)
+ } catch (_: IllegalArgumentException) {
+ } // ignore
+ connectionActive = false
+ if (listenForDeath) binder?.unlinkToDeath(this, 0)
+ binder = null
+ try {
+ service?.stopListeningForBandwidth(serviceCallback)
+ } catch (_: RemoteException) {
+ }
+ service = null
+ callback = null
+ }
+}
diff --git a/client/android/src/com/github/shadowsocks/aidl/TrafficStats.kt b/client/android/src/com/github/shadowsocks/aidl/TrafficStats.kt
new file mode 100644
index 000000000..65bcdbf03
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/aidl/TrafficStats.kt
@@ -0,0 +1,55 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2019 by Max Lv *
+ * Copyright (C) 2019 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.aidl
+
+import android.os.Parcel
+import android.os.Parcelable
+
+data class TrafficStats(
+ // Bytes per second
+ var txRate: Long = 0L, var rxRate: Long = 0L,
+
+ // Bytes for the current session
+ var txTotal: Long = 0L, var rxTotal: Long = 0L) : Parcelable {
+ operator fun plus(other: TrafficStats) = TrafficStats(txRate + other.txRate,
+ rxRate + other.rxRate,
+ txTotal + other.txTotal,
+ rxTotal + other.rxTotal)
+
+ constructor(parcel: Parcel) : this(parcel.readLong(),
+ parcel.readLong(),
+ parcel.readLong(),
+ parcel.readLong())
+
+ override fun writeToParcel(parcel: Parcel, flags: Int) {
+ parcel.writeLong(txRate)
+ parcel.writeLong(rxRate)
+ parcel.writeLong(txTotal)
+ parcel.writeLong(rxTotal)
+ }
+
+ override fun describeContents() = 0
+
+ companion object CREATOR : Parcelable.Creator {
+ override fun createFromParcel(parcel: Parcel) = TrafficStats(parcel)
+ override fun newArray(size: Int): Array = arrayOfNulls(size)
+ }
+}
diff --git a/client/android/src/com/github/shadowsocks/bg/BaseService.kt b/client/android/src/com/github/shadowsocks/bg/BaseService.kt
new file mode 100644
index 000000000..3ec5a2917
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/bg/BaseService.kt
@@ -0,0 +1,348 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2017 by Max Lv *
+ * Copyright (C) 2017 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.bg
+
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.*
+import androidx.core.content.getSystemService
+import com.github.shadowsocks.Core
+import com.github.shadowsocks.Core.app
+import com.github.shadowsocks.aidl.IShadowsocksService
+import com.github.shadowsocks.aidl.IShadowsocksServiceCallback
+import com.github.shadowsocks.aidl.TrafficStats
+import com.github.shadowsocks.net.HostsFile
+import com.github.shadowsocks.preference.DataStore
+import com.github.shadowsocks.utils.*
+import kotlinx.coroutines.*
+import java.io.File
+import java.net.URL
+import java.net.UnknownHostException
+import java.util.*
+import org.amnezia.vpn.R
+
+/**
+ * This object uses WeakMap to simulate the effects of multi-inheritance.
+ */
+object BaseService {
+ enum class State(val canStop: Boolean = false) {
+ /**
+ * Idle state is only used by UI and will never be returned by BaseService.
+ */
+ Idle,
+ Connecting(true), Connected(true), Stopping, Stopped,
+ }
+
+ const val CONFIG_FILE = "shadowsocks.conf"
+ const val CONFIG_FILE_UDP = "shadowsocks-udp.conf"
+
+ interface ExpectedException
+ class ExpectedExceptionWrapper(e: Exception) : Exception(e.localizedMessage, e),
+ ExpectedException
+
+ class Data internal constructor(private val service: Interface) {
+ var state = State.Stopped
+ var processes: GuardedProcessPool? = null
+ var proxy: ProxyInstance? = null
+ // no udpFallback. xinlake
+
+ var notification: ServiceNotification? = null
+ val closeReceiver = broadcastReceiver { _, intent ->
+ when (intent.action) {
+ Intent.ACTION_SHUTDOWN -> service.persistStats()
+ Action.RELOAD -> service.forceLoad()
+ else -> service.stopRunner()
+ }
+ }
+ var closeReceiverRegistered = false
+
+ val binder = Binder(this)
+ var connectingJob: Job? = null
+
+ fun changeState(s: State, msg: String? = null) {
+ if (state == s && msg == null) return
+ binder.stateChanged(s, msg)
+ state = s
+ }
+ }
+
+ class Binder(private var data: Data? = null) : IShadowsocksService.Stub(), CoroutineScope, AutoCloseable {
+ private val callbacks = object : RemoteCallbackList() {
+ override fun onCallbackDied(callback: IShadowsocksServiceCallback?, cookie: Any?) {
+ super.onCallbackDied(callback, cookie)
+ stopListeningForBandwidth(callback ?: return)
+ }
+ }
+ private val bandwidthListeners = mutableMapOf() // the binder is the real identifier
+ override val coroutineContext = Dispatchers.Main.immediate + Job()
+ private var looper: Job? = null
+
+ override fun getState(): Int = (data?.state ?: State.Idle).ordinal
+ override fun getProfileName(): String = data?.proxy?.profile?.name ?: "Idle"
+
+ override fun registerCallback(cb: IShadowsocksServiceCallback) {
+ callbacks.register(cb)
+ }
+
+ private fun broadcast(work: (IShadowsocksServiceCallback) -> Unit) {
+ val count = callbacks.beginBroadcast()
+ try {
+ repeat(count) {
+ try {
+ work(callbacks.getBroadcastItem(it))
+ } catch (_: RemoteException) {
+ } catch (e: Exception) {
+ printLog(e)
+ }
+ }
+ } finally {
+ callbacks.finishBroadcast()
+ }
+ }
+
+ private suspend fun loop() {
+ while (true) {
+// delay(bandwidthListeners.values.min() ?: return)
+ delay(5000)
+ val proxies = listOfNotNull(data?.proxy)
+ val stats = proxies.map { Pair(it.profile.id, it.trafficMonitor?.requestUpdate()) }
+ .filter { it.second != null }
+ .map { Triple(it.first, it.second!!.first, it.second!!.second) }
+ if (stats.any { it.third } && data?.state == State.Connected && bandwidthListeners.isNotEmpty()) {
+ val sum = stats.fold(TrafficStats()) { a, b -> a + b.second }
+ broadcast { item ->
+ if (bandwidthListeners.contains(item.asBinder())) {
+ stats.forEach { (id, stats) -> item.trafficUpdated(id, stats) }
+ item.trafficUpdated(0, sum)
+ }
+ }
+ }
+ }
+ }
+
+ override fun startListeningForBandwidth(cb: IShadowsocksServiceCallback, timeout: Long) {
+ launch {
+ if (bandwidthListeners.isEmpty() and (bandwidthListeners.put(cb.asBinder(), timeout) == null)) {
+ check(looper == null)
+ looper = launch { loop() }
+ }
+ if (data?.state != State.Connected) return@launch
+ var sum = TrafficStats()
+ val data = data
+ val proxy = data?.proxy ?: return@launch
+ proxy.trafficMonitor?.out.also { stats ->
+ cb.trafficUpdated(proxy.profile.id, if (stats == null) sum else {
+ sum += stats
+ stats
+ })
+ }
+
+ cb.trafficUpdated(0, sum)
+ }
+ }
+
+ override fun stopListeningForBandwidth(cb: IShadowsocksServiceCallback) {
+ launch {
+ if (bandwidthListeners.remove(cb.asBinder()) != null && bandwidthListeners.isEmpty()) {
+ looper!!.cancel()
+ looper = null
+ }
+ }
+ }
+
+ override fun unregisterCallback(cb: IShadowsocksServiceCallback) {
+ stopListeningForBandwidth(cb) // saves an RPC, and safer
+ callbacks.unregister(cb)
+ }
+
+ fun stateChanged(s: State, msg: String?) {
+ val profileName = profileName
+ broadcast { it.stateChanged(s.ordinal, profileName, msg) }
+ }
+
+ fun trafficPersisted(ids: List) {
+ if (bandwidthListeners.isNotEmpty() && ids.isNotEmpty()) broadcast { item ->
+ if (bandwidthListeners.contains(item.asBinder())) ids.forEach(item::trafficPersisted)
+ }
+ }
+
+ override fun close() {
+ callbacks.kill()
+ cancel()
+ data = null
+ }
+ }
+
+ interface Interface {
+ val data: Data
+ val tag: String
+ fun createNotification(profileName: String): ServiceNotification
+
+ fun onBind(intent: Intent): IBinder? =
+ if (intent.action == Action.SERVICE) data.binder else null
+
+ fun forceLoad() {
+ val (profile, fallback) = Core.currentProfile
+ ?: return stopRunner(false, (this as Context).getString(R.string.profile_empty))
+ if (profile.host.isEmpty() || profile.password.isEmpty() || fallback != null && (fallback.host.isEmpty() || fallback.password.isEmpty())) {
+ stopRunner(false, (this as Context).getString(R.string.proxy_empty))
+ return
+ }
+ val s = data.state
+ when {
+ s == State.Stopped -> startRunner()
+ s.canStop -> stopRunner(true)
+ // else -> Crashlytics.log(Log.WARN, tag, "Illegal state when invoking use: $s")
+ }
+ }
+
+ fun buildAdditionalArguments(cmd: ArrayList): ArrayList = cmd
+
+ suspend fun startProcesses(hosts: HostsFile) {
+ val configRoot =
+ (if (Build.VERSION.SDK_INT < 24 || app.getSystemService()
+ ?.isUserUnlocked != false) app else Core.deviceStorage).noBackupFilesDir
+
+ data.proxy!!.start(this,
+ File(Core.deviceStorage.noBackupFilesDir, "stat_main"),
+ File(configRoot, CONFIG_FILE),
+ "-u")
+ }
+
+ fun startRunner() {
+ this as Context
+ if (Build.VERSION.SDK_INT >= 26) startForegroundService(Intent(this, javaClass))
+ else startService(Intent(this, javaClass))
+ }
+
+ fun killProcesses(scope: CoroutineScope) {
+ data.processes?.run {
+ close(scope)
+ data.processes = null
+ }
+ }
+
+ fun stopRunner(restart: Boolean = false, msg: String? = null) {
+ if (data.state == State.Stopping) return
+ // channge the state
+ data.changeState(State.Stopping)
+ GlobalScope.launch(Dispatchers.Main.immediate) {
+ // Core.analytics.logEvent("stop", bundleOf(Pair(FirebaseAnalytics.Param.METHOD, tag)))
+ data.connectingJob?.cancelAndJoin() // ensure stop connecting first
+ this@Interface as Service
+ // we use a coroutineScope here to allow clean-up in parallel
+ coroutineScope {
+ killProcesses(this)
+ // clean up receivers
+ val data = data
+ if (data.closeReceiverRegistered) {
+ unregisterReceiver(data.closeReceiver)
+ data.closeReceiverRegistered = false
+ }
+
+ data.notification?.destroy()
+ data.notification = null
+
+ val ids = listOfNotNull(data.proxy).map {
+ it.shutdown(this)
+ it.profile.id
+ }
+ data.proxy = null
+ data.binder.trafficPersisted(ids)
+ }
+
+ // change the state
+ data.changeState(State.Stopped, msg)
+
+ // stop the service if nothing has bound to it
+ if (restart) startRunner() else {
+ stopSelf()
+ }
+ }
+ }
+
+ fun persistStats() =
+ listOfNotNull(data.proxy).forEach { it.trafficMonitor?.persistStats(it.profile.id) }
+
+ suspend fun preInit() {}
+ suspend fun resolver(host: String) = DnsResolverCompat.resolveOnActiveNetwork(host)
+ suspend fun openConnection(url: URL) = url.openConnection()
+
+ fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ val data = data
+ if (data.state != State.Stopped) return Service.START_NOT_STICKY
+ val profilePair = Core.currentProfile
+ this as Context
+ if (profilePair == null) {
+ // gracefully shutdown: https://stackoverflow.com/q/47337857/2245107
+ data.notification = createNotification("")
+ stopRunner(false, getString(R.string.profile_empty))
+ return Service.START_NOT_STICKY
+ }
+ val (profile, _) = profilePair
+ profile.name = profile.formattedName // save name for later queries
+ val proxy = ProxyInstance(profile)
+ data.proxy = proxy
+
+ if (!data.closeReceiverRegistered) {
+ registerReceiver(data.closeReceiver, IntentFilter().apply {
+ addAction(Action.RELOAD)
+ addAction(Intent.ACTION_SHUTDOWN)
+ addAction(Action.CLOSE)
+ })
+ data.closeReceiverRegistered = true
+ }
+
+ data.notification = createNotification(profile.formattedName)
+ // Core.analytics.logEvent("start", bundleOf(Pair(FirebaseAnalytics.Param.METHOD, tag)))
+
+ data.changeState(State.Connecting)
+ data.connectingJob = GlobalScope.launch(Dispatchers.Main) {
+ try {
+ Executable.killAll() // clean up old processes
+ preInit()
+ val hosts = HostsFile(DataStore.publicStore.getString(Key.hosts) ?: "")
+ proxy.init(this@Interface, hosts)
+
+ data.processes = GuardedProcessPool {
+ printLog(it)
+ stopRunner(false, it.readableMessage)
+ }
+ startProcesses(hosts)
+ // proxy.scheduleUpdate() // XinLake. Bypass-LAN only
+ data.changeState(State.Connected)
+ } catch (_: CancellationException) {
+ // if the job was cancelled, it is canceller's responsibility to call stopRunner
+ } catch (_: UnknownHostException) {
+ stopRunner(false, getString(R.string.invalid_server))
+ } catch (exc: Throwable) {
+ if (exc is ExpectedException) exc.printStackTrace() else printLog(exc)
+ stopRunner(false, "${getString(R.string.service_failed)}: ${exc.readableMessage}")
+ } finally {
+ data.connectingJob = null
+ }
+ }
+ return Service.START_NOT_STICKY
+ }
+ }
+}
diff --git a/client/android/src/com/github/shadowsocks/bg/DnsResolverCompat.kt b/client/android/src/com/github/shadowsocks/bg/DnsResolverCompat.kt
new file mode 100644
index 000000000..8c93208ed
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/bg/DnsResolverCompat.kt
@@ -0,0 +1,99 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2019 by Max Lv *
+ * Copyright (C) 2019 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.bg
+
+import android.annotation.TargetApi
+import android.app.ActivityManager
+import android.net.DnsResolver
+import android.net.Network
+import android.os.Build
+import android.os.CancellationSignal
+import androidx.core.content.getSystemService
+import com.github.shadowsocks.Core
+import com.github.shadowsocks.Core.app
+import kotlinx.coroutines.*
+import java.io.IOException
+import java.net.InetAddress
+import java.util.concurrent.Executor
+import java.util.concurrent.Executors
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+
+sealed class DnsResolverCompat {
+ companion object : DnsResolverCompat() {
+ private val instance by lazy { if (Build.VERSION.SDK_INT >= 29) DnsResolverCompat29 else DnsResolverCompat21 }
+
+ override suspend fun resolve(network: Network, host: String) =
+ instance.resolve(network, host)
+
+ override suspend fun resolveOnActiveNetwork(host: String) =
+ instance.resolveOnActiveNetwork(host)
+ }
+
+ abstract suspend fun resolve(network: Network, host: String): Array
+ abstract suspend fun resolveOnActiveNetwork(host: String): Array
+
+ private object DnsResolverCompat21 : DnsResolverCompat() {
+ /**
+ * This dispatcher is used for noncancellable possibly-forever-blocking operations in network IO.
+ *
+ * See also: https://issuetracker.google.com/issues/133874590
+ */
+ private val unboundedIO by lazy {
+ if (app.getSystemService()!!.isLowRamDevice) Dispatchers.IO
+ else Executors.newCachedThreadPool().asCoroutineDispatcher()
+ }
+
+ override suspend fun resolve(network: Network, host: String) =
+ GlobalScope.async(unboundedIO) { network.getAllByName(host) }.await()
+
+ override suspend fun resolveOnActiveNetwork(host: String) =
+ GlobalScope.async(unboundedIO) { InetAddress.getAllByName(host) }.await()
+ }
+
+ @TargetApi(29)
+ private object DnsResolverCompat29 : DnsResolverCompat(), Executor {
+ /**
+ * This executor will run on its caller directly. On Q beta 3 thru 4, this results in calling in main thread.
+ */
+ override fun execute(command: Runnable) = command.run()
+
+ override suspend fun resolve(network: Network, host: String): Array {
+ return suspendCancellableCoroutine { cont ->
+ val signal = CancellationSignal()
+ cont.invokeOnCancellation { signal.cancel() }
+ // retry should be handled by client instead
+ DnsResolver.getInstance().query(network, host, DnsResolver.FLAG_NO_RETRY, this, signal,
+ object : DnsResolver.Callback> {
+ override fun onAnswer(answer: Collection, rcode: Int) =
+ cont.resume(answer.toTypedArray())
+
+ override fun onError(error: DnsResolver.DnsException) =
+ cont.resumeWithException(IOException(error))
+ })
+ }
+ }
+
+ override suspend fun resolveOnActiveNetwork(host: String): Array {
+ return resolve(Core.connectivity.activeNetwork ?: return emptyArray(), host)
+ }
+ }
+}
diff --git a/client/android/src/com/github/shadowsocks/bg/Executable.kt b/client/android/src/com/github/shadowsocks/bg/Executable.kt
new file mode 100644
index 000000000..830f6ccf4
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/bg/Executable.kt
@@ -0,0 +1,56 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2017 by Max Lv *
+ * Copyright (C) 2017 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.bg
+
+import android.system.ErrnoException
+import android.system.Os
+import android.system.OsConstants
+import android.text.TextUtils
+import java.io.File
+import java.io.IOException
+
+object Executable {
+ // libredsocks.so is not required. xinlake
+ const val SS_LOCAL = "libss-local.so"
+ const val TUN2SOCKS = "libtun2socks.so"
+
+ private val EXECUTABLES = setOf(SS_LOCAL, TUN2SOCKS)
+
+ fun killAll() {
+ for (process in File("/proc").listFiles { _, name -> TextUtils.isDigitsOnly(name) }
+ ?: return) {
+ val exe = File(try {
+ File(process, "cmdline").inputStream().bufferedReader().readText()
+ } catch (_: IOException) {
+ continue
+ }.split(Character.MIN_VALUE, limit = 2).first())
+ if (EXECUTABLES.contains(exe.name)) try {
+ Os.kill(process.name.toInt(), OsConstants.SIGKILL)
+ } catch (e: ErrnoException) {
+ if (e.errno != OsConstants.ESRCH) {
+ e.printStackTrace()
+ // Crashlytics.log(Log.WARN, "kill", "SIGKILL ${exe.absolutePath} (${process.name}) failed")
+ // Crashlytics.logException(e)
+ }
+ }
+ }
+ }
+}
diff --git a/client/android/src/com/github/shadowsocks/bg/GuardedProcessPool.kt b/client/android/src/com/github/shadowsocks/bg/GuardedProcessPool.kt
new file mode 100644
index 000000000..cd58c9c52
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/bg/GuardedProcessPool.kt
@@ -0,0 +1,129 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2017 by Max Lv *
+ * Copyright (C) 2017 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.bg
+
+import android.os.Build
+import android.os.SystemClock
+import android.system.ErrnoException
+import android.system.Os
+import android.system.OsConstants
+import androidx.annotation.MainThread
+import com.github.shadowsocks.Core
+import kotlinx.coroutines.*
+import kotlinx.coroutines.channels.Channel
+import java.io.File
+import java.io.IOException
+import java.io.InputStream
+import kotlin.concurrent.thread
+
+class GuardedProcessPool(private val onFatal: suspend (IOException) -> Unit) : CoroutineScope {
+ companion object {
+ private const val TAG = "GuardedProcessPool"
+ private val pid by lazy {
+ Class.forName("java.lang.ProcessManager\$ProcessImpl").getDeclaredField("pid").apply { isAccessible = true }
+ }
+ }
+
+ private inner class Guard(private val cmd: List) {
+ private lateinit var process: Process
+
+ private fun streamLogger(input: InputStream, logger: (String) -> Unit) = try {
+ input.bufferedReader().forEachLine(logger)
+ } catch (_: IOException) {
+ } // ignore
+
+ fun start() {
+ process = ProcessBuilder(cmd).directory(Core.deviceStorage.noBackupFilesDir).start()
+ }
+
+ suspend fun looper(onRestartCallback: (suspend () -> Unit)?) {
+ var running = true
+ val cmdName = File(cmd.first()).nameWithoutExtension
+ val exitChannel = Channel()
+ try {
+ while (true) {
+ thread(name = "stderr-$cmdName") {
+ streamLogger(process.errorStream) {
+ // Crashlytics.log(Log.ERROR, cmdName, it)
+ }
+ }
+ thread(name = "stdout-$cmdName") {
+ streamLogger(process.inputStream) {
+ // Crashlytics.log(Log.VERBOSE, cmdName, it)
+ }
+ // this thread also acts as a daemon thread for waitFor
+ runBlocking { exitChannel.send(process.waitFor()) }
+ }
+ val startTime = SystemClock.elapsedRealtime()
+ val exitCode = exitChannel.receive()
+ running = false
+ when {
+ SystemClock.elapsedRealtime() - startTime < 1000 -> throw IOException("$cmdName exits too fast (exit code: $exitCode)")
+ // exitCode == 128 + OsConstants.SIGKILL -> Crashlytics.log(Log.WARN, TAG, "$cmdName was killed")
+ // else -> Crashlytics.logException(IOException("$cmdName unexpectedly exits with code $exitCode"))
+ }
+ // Crashlytics.log(Log.DEBUG, TAG, "restart process: ${Commandline.toString(cmd)} (last exit code: $exitCode)")
+ start()
+ running = true
+ onRestartCallback?.invoke()
+ }
+ } catch (e: IOException) {
+ // Crashlytics.log(Log.WARN, TAG, "error occurred. stop guard: " + Commandline.toString(cmd))
+ GlobalScope.launch(Dispatchers.Main) { onFatal(e) }
+ } finally {
+ if (running) withContext(NonCancellable) {
+ // clean-up cannot be cancelled
+ if (Build.VERSION.SDK_INT < 24) {
+ try {
+ Os.kill(pid.get(process) as Int, OsConstants.SIGTERM)
+ } catch (e: ErrnoException) {
+ if (e.errno != OsConstants.ESRCH) throw e
+ }
+ if (withTimeoutOrNull(500) { exitChannel.receive() } != null) return@withContext
+ }
+ process.destroy() // kill the process
+ if (Build.VERSION.SDK_INT >= 26) {
+ if (withTimeoutOrNull(1000) { exitChannel.receive() } != null) return@withContext
+ process.destroyForcibly() // Force to kill the process if it's still alive
+ }
+ exitChannel.receive()
+ } // otherwise process already exited, nothing to be done
+ }
+ }
+ }
+
+ override val coroutineContext = Dispatchers.Main.immediate + Job()
+
+ @MainThread
+ fun start(cmd: List, onRestartCallback: (suspend () -> Unit)? = null) {
+ // Crashlytics.log(Log.DEBUG, TAG, "start process: " + Commandline.toString(cmd))
+ Guard(cmd).apply {
+ start() // if start fails, IOException will be thrown directly
+ launch { looper(onRestartCallback) }
+ }
+ }
+
+ @MainThread
+ fun close(scope: CoroutineScope) {
+ cancel()
+ coroutineContext[Job]!!.also { job -> scope.launch { job.join() } }
+ }
+}
diff --git a/client/android/src/com/github/shadowsocks/bg/LocalDnsService.kt b/client/android/src/com/github/shadowsocks/bg/LocalDnsService.kt
new file mode 100644
index 000000000..a638eba57
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/bg/LocalDnsService.kt
@@ -0,0 +1,62 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2017 by Max Lv *
+ * Copyright (C) 2017 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.bg
+
+import com.github.shadowsocks.net.HostsFile
+import com.github.shadowsocks.net.LocalDnsServer
+import com.github.shadowsocks.net.Socks5Endpoint
+import com.github.shadowsocks.preference.DataStore
+import kotlinx.coroutines.CoroutineScope
+import java.net.InetSocketAddress
+import java.net.URI
+import java.net.URISyntaxException
+import java.util.*
+
+object LocalDnsService {
+ private val googleApisTester =
+ "(^|\\.)googleapis(\\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?){1,2}\$".toRegex()
+
+ private val servers = WeakHashMap()
+
+ interface Interface : BaseService.Interface {
+ override suspend fun startProcesses(hosts: HostsFile) {
+ super.startProcesses(hosts)
+ val profile = data.proxy!!.profile
+ val dns = try {
+ URI("dns://${profile.remoteDns}")
+ } catch (e: URISyntaxException) {
+ throw BaseService.ExpectedExceptionWrapper(e)
+ }
+ LocalDnsServer(this::resolver,
+ Socks5Endpoint(dns.host, if (dns.port < 0) 53 else dns.port),
+ DataStore.proxyAddress,
+ hosts).apply {
+ tcp = !profile.udpdns
+ forwardOnly = true
+ }.also { servers[this] = it }.start(InetSocketAddress(DataStore.listenAddress, DataStore.portLocalDns))
+ }
+
+ override fun killProcesses(scope: CoroutineScope) {
+ servers.remove(this)?.shutdown(scope)
+ super.killProcesses(scope)
+ }
+ }
+}
diff --git a/client/android/src/com/github/shadowsocks/bg/ProxyInstance.kt b/client/android/src/com/github/shadowsocks/bg/ProxyInstance.kt
new file mode 100644
index 000000000..c85d03a18
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/bg/ProxyInstance.kt
@@ -0,0 +1,95 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2019 by Max Lv *
+ * Copyright (C) 2019 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.bg
+
+import android.content.Context
+import com.github.shadowsocks.Core
+import com.github.shadowsocks.database.Profile
+import com.github.shadowsocks.net.HostsFile
+import com.github.shadowsocks.preference.DataStore
+import com.github.shadowsocks.utils.parseNumericAddress
+import kotlinx.coroutines.CoroutineScope
+import java.io.File
+import java.io.IOException
+import java.net.UnknownHostException
+
+/**
+ * This class sets up environment for ss-local.
+ */
+class ProxyInstance(val profile: Profile) {
+ private var configFile: File? = null
+ var trafficMonitor: TrafficMonitor? = null
+
+ fun getFile(context: Context = Core.deviceStorage) =
+ File(context.noBackupFilesDir, "bypass-lan.acl")
+
+ suspend fun init(service: BaseService.Interface, hosts: HostsFile) {
+ // it's hard to resolve DNS on a specific interface so we'll do it here
+ if (profile.host.parseNumericAddress() == null) {
+ profile.host = (hosts.resolve(profile.host).firstOrNull() ?: try {
+ service.resolver(profile.host).firstOrNull()
+ } catch (_: IOException) {
+ null
+ })?.hostAddress ?: throw UnknownHostException()
+ }
+ }
+
+ /**
+ * Sensitive shadowsocks configuration file requires extra protection. It may be stored in encrypted storage or
+ * device storage, depending on which is currently available.
+ */
+ fun start(service: BaseService.Interface, stat: File, configFile: File, extraFlag: String? = null) {
+ trafficMonitor = TrafficMonitor(stat)
+
+ this.configFile = configFile
+ val config = profile.toJson()
+ configFile.writeText(config.toString())
+
+ val cmd = service.buildAdditionalArguments(arrayListOf(
+ File((service as Context).applicationInfo.nativeLibraryDir, Executable.SS_LOCAL).absolutePath,
+ "-b", DataStore.listenAddress,
+ "-l", DataStore.portProxy.toString(),
+ "-t", "600",
+ "-S", stat.absolutePath,
+ "-c", configFile.absolutePath))
+ if (extraFlag != null) cmd.add(extraFlag)
+
+ cmd += "--acl"
+ cmd += getFile().absolutePath
+
+ // for UDP profile, it's only going to operate in UDP relay mode-only so this flag has no effect
+ cmd += "-D"
+
+ if (DataStore.tcpFastOpen) cmd += "--fast-open"
+
+ service.data.processes!!.start(cmd)
+ }
+
+ fun shutdown(scope: CoroutineScope) {
+ trafficMonitor?.apply {
+ thread.shutdown(scope)
+ persistStats(profile.id) // Make sure update total traffic when stopping the runner
+ }
+ trafficMonitor = null
+ configFile?.delete() // remove old config possibly in device storage
+ configFile = null
+ }
+}
diff --git a/client/android/src/com/github/shadowsocks/bg/ServiceNotification.kt b/client/android/src/com/github/shadowsocks/bg/ServiceNotification.kt
new file mode 100644
index 000000000..49d8d106b
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/bg/ServiceNotification.kt
@@ -0,0 +1,123 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2017 by Max Lv *
+ * Copyright (C) 2017 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.bg
+
+import android.app.PendingIntent
+import android.app.Service
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Build
+import android.os.PowerManager
+import android.text.format.Formatter
+import androidx.core.app.NotificationCompat
+import androidx.core.content.ContextCompat
+import androidx.core.content.getSystemService
+import com.github.shadowsocks.Core
+import org.amnezia.vpn.R
+import com.github.shadowsocks.aidl.IShadowsocksServiceCallback
+import com.github.shadowsocks.aidl.TrafficStats
+import com.github.shadowsocks.utils.Action
+
+/**
+ * User can customize visibility of notification since Android 8.
+ * The default visibility:
+ *
+ * Android 8.x: always visible due to system limitations
+ * VPN: always invisible because of VPN notification/icon
+ * Other: always visible
+ *
+ * See also: https://github.com/aosp-mirror/platform_frameworks_base/commit/070d142993403cc2c42eca808ff3fafcee220ac4
+ */
+class ServiceNotification(private val service: BaseService.Interface, profileName: String, channel: String, visible: Boolean = false)
+ : BroadcastReceiver() {
+ private val callback: IShadowsocksServiceCallback by lazy {
+ object : IShadowsocksServiceCallback.Stub() {
+ override fun stateChanged(state: Int, profileName: String?, msg: String?) {} // ignore
+ override fun trafficUpdated(profileId: Long, stats: TrafficStats) {
+ if (profileId != 0L) return
+ builder.apply {
+ setContentText((service as Context).getString(R.string.traffic,
+ service.getString(R.string.speed, Formatter.formatFileSize(service, stats.txRate)),
+ service.getString(R.string.speed, Formatter.formatFileSize(service, stats.rxRate))))
+ setSubText(service.getString(R.string.traffic,
+ Formatter.formatFileSize(service, stats.txTotal),
+ Formatter.formatFileSize(service, stats.rxTotal)))
+ }
+ show()
+ }
+
+ override fun trafficPersisted(profileId: Long) {}
+ }
+ }
+ private var callbackRegistered = false
+
+ private val builder = NotificationCompat.Builder(service as Context, channel)
+ .setWhen(0)
+ .setColor(ContextCompat.getColor(service, R.color.material_primary_500))
+ .setTicker(service.getString(R.string.forward_success))
+ .setContentTitle(profileName)
+ .setContentIntent(Core.configureIntent(service))
+ .setSmallIcon(R.drawable.ic_service_active)
+ .setCategory(NotificationCompat.CATEGORY_SERVICE)
+ .setPriority(if (visible) NotificationCompat.PRIORITY_LOW else NotificationCompat.PRIORITY_MIN)
+
+ init {
+ service as Context
+ val closeAction = NotificationCompat.Action.Builder(
+ R.drawable.ic_navigation_close,
+ service.getString(R.string.stop),
+ PendingIntent.getBroadcast(service, 0, Intent(Action.CLOSE), 0)).apply {
+ setShowsUserInterface(false)
+ }.build()
+ if (Build.VERSION.SDK_INT < 24) builder.addAction(closeAction) else builder.addInvisibleAction(closeAction)
+ updateCallback(service.getSystemService()?.isInteractive != false)
+ service.registerReceiver(this, IntentFilter().apply {
+ addAction(Intent.ACTION_SCREEN_ON)
+ addAction(Intent.ACTION_SCREEN_OFF)
+ })
+ show()
+ }
+
+ override fun onReceive(context: Context, intent: Intent) {
+ if (service.data.state == BaseService.State.Connected) updateCallback(intent.action == Intent.ACTION_SCREEN_ON)
+ }
+
+ private fun updateCallback(screenOn: Boolean) {
+ if (screenOn) {
+ service.data.binder.registerCallback(callback)
+ service.data.binder.startListeningForBandwidth(callback, 1000)
+ callbackRegistered = true
+ } else if (callbackRegistered) { // unregister callback to save battery
+ service.data.binder.unregisterCallback(callback)
+ callbackRegistered = false
+ }
+ }
+
+ private fun show() = (service as Service).startForeground(1, builder.build())
+
+ fun destroy() {
+ (service as Service).unregisterReceiver(this)
+ updateCallback(false)
+ service.stopForeground(true)
+ }
+}
diff --git a/client/android/src/com/github/shadowsocks/bg/TrafficMonitor.kt b/client/android/src/com/github/shadowsocks/bg/TrafficMonitor.kt
new file mode 100644
index 000000000..08f96645f
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/bg/TrafficMonitor.kt
@@ -0,0 +1,108 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2017 by Max Lv *
+ * Copyright (C) 2017 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.bg
+
+import android.net.LocalSocket
+import android.os.SystemClock
+import com.github.shadowsocks.aidl.TrafficStats
+import com.github.shadowsocks.database.ProfileManager
+import com.github.shadowsocks.net.LocalSocketListener
+import com.github.shadowsocks.preference.DataStore
+import com.github.shadowsocks.utils.DirectBoot
+import java.io.File
+import java.io.IOException
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+
+class TrafficMonitor(statFile: File) {
+ val thread = object : LocalSocketListener("TrafficMonitor-" + statFile.name, statFile) {
+ private val buffer = ByteArray(16)
+ private val stat = ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN)
+ override fun acceptInternal(socket: LocalSocket) {
+ if (socket.inputStream.read(buffer) != 16) throw IOException("Unexpected traffic stat length")
+ val tx = stat.getLong(0)
+ val rx = stat.getLong(8)
+ if (current.txTotal != tx) {
+ current.txTotal = tx
+ dirty = true
+ }
+ if (current.rxTotal != rx) {
+ current.rxTotal = rx
+ dirty = true
+ }
+ }
+ }.apply { start() }
+
+ val current = TrafficStats()
+ var out = TrafficStats()
+ private var timestampLast = 0L
+ private var dirty = false
+ private var persisted: TrafficStats? = null
+
+ fun requestUpdate(): Pair {
+ val now = SystemClock.elapsedRealtime()
+ val delta = now - timestampLast
+ timestampLast = now
+ var updated = false
+ if (delta != 0L) {
+ if (dirty) {
+ out = current.copy().apply {
+ txRate = (txTotal - out.txTotal) * 1000 / delta
+ rxRate = (rxTotal - out.rxTotal) * 1000 / delta
+ }
+ dirty = false
+ updated = true
+ } else {
+ if (out.txRate != 0L) {
+ out.txRate = 0
+ updated = true
+ }
+ if (out.rxRate != 0L) {
+ out.rxRate = 0
+ updated = true
+ }
+ }
+ }
+ return Pair(out, updated)
+ }
+
+ fun persistStats(id: Long) {
+ val current = current
+ check(persisted == null || persisted == current) { "Data loss occurred" }
+ persisted = current
+
+ try {
+ // profile may have host, etc. modified and thus a re-fetch is necessary (possible race condition)
+ val profile = ProfileManager.getProfile(id) ?: return
+ profile.tx += current.txTotal
+ profile.rx += current.rxTotal
+ ProfileManager.updateProfile(profile)
+ } catch (e: IOException) {
+ if (!DataStore.directBootAware) throw e // we should only reach here because we're in direct boot
+ val profile = DirectBoot.getDeviceProfile()!!.toList().filterNotNull().single { it.id == id }
+ profile.tx += current.txTotal
+ profile.rx += current.rxTotal
+ profile.dirty = true
+ DirectBoot.update(profile)
+ DirectBoot.listenForUnlock()
+ }
+ }
+}
diff --git a/client/android/src/com/github/shadowsocks/bg/VpnService.kt b/client/android/src/com/github/shadowsocks/bg/VpnService.kt
new file mode 100644
index 000000000..c777896da
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/bg/VpnService.kt
@@ -0,0 +1,229 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2017 by Max Lv *
+ * Copyright (C) 2017 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.bg
+
+import android.app.Service
+import android.content.Intent
+import android.net.LocalSocket
+import android.net.LocalSocketAddress
+import android.net.Network
+import android.os.Build
+import android.os.ParcelFileDescriptor
+import android.system.ErrnoException
+import android.system.Os
+import com.github.shadowsocks.Core
+import org.amnezia.vpn.R
+import com.github.shadowsocks.net.ConcurrentLocalSocketListener
+import com.github.shadowsocks.net.DefaultNetworkListener
+import com.github.shadowsocks.net.HostsFile
+import com.github.shadowsocks.net.Subnet
+import com.github.shadowsocks.preference.DataStore
+import com.github.shadowsocks.utils.printLog
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import java.io.Closeable
+import java.io.File
+import java.io.FileDescriptor
+import java.io.IOException
+import java.net.URL
+
+import java.util.*
+import android.net.VpnService as BaseVpnService
+
+class VpnService : BaseVpnService(), LocalDnsService.Interface {
+ companion object {
+ private const val VPN_MTU = 1500
+ private const val PRIVATE_VLAN4_CLIENT = "172.19.0.1"
+ private const val PRIVATE_VLAN4_ROUTER = "172.19.0.2"
+ private const val PRIVATE_VLAN6_CLIENT = "fdfe:dcba:9876::1"
+ private const val PRIVATE_VLAN6_ROUTER = "fdfe:dcba:9876::2"
+
+ /**
+ * https://android.googlesource.com/platform/prebuilts/runtime/+/94fec32/appcompat/hiddenapi-light-greylist.txt#9466
+ */
+ private val getInt = FileDescriptor::class.java.getDeclaredMethod("getInt$")
+ }
+
+ class CloseableFd(val fd: FileDescriptor) : Closeable {
+ override fun close() = Os.close(fd)
+ }
+
+ private inner class ProtectWorker : ConcurrentLocalSocketListener("ShadowsocksVpnThread",
+ File(Core.deviceStorage.noBackupFilesDir, "protect_path")) {
+ override fun acceptInternal(socket: LocalSocket) {
+ socket.inputStream.read()
+ val fd = socket.ancillaryFileDescriptors!!.single()!!
+ CloseableFd(fd).use {
+ socket.outputStream.write(if (underlyingNetwork.let { network ->
+ if (network != null && Build.VERSION.SDK_INT >= 23) try {
+ network.bindSocket(fd)
+ true
+ } catch (e: IOException) {
+ // suppress ENONET (Machine is not on the network)
+ if ((e.cause as? ErrnoException)?.errno != 64) printLog(e)
+ false
+ } else protect(getInt.invoke(fd) as Int)
+ }) 0 else 1)
+ }
+ }
+ }
+
+ inner class NullConnectionException : NullPointerException(), BaseService.ExpectedException {
+ override fun getLocalizedMessage() = getString(R.string.reboot_required)
+ }
+
+ override val data = BaseService.Data(this)
+ override val tag: String get() = "ShadowsocksVpnService"
+ override fun createNotification(profileName: String): ServiceNotification =
+ ServiceNotification(this, profileName, "service-vpn")
+
+ private var conn: ParcelFileDescriptor? = null
+ private var worker: ProtectWorker? = null
+ private var active = false
+ // metered = false. xinlake
+ private var underlyingNetwork: Network? = null
+ set(value) {
+ field = value
+ if (active) setUnderlyingNetworks(underlyingNetworks)
+ }
+ private val underlyingNetworks
+ get() = // clearing underlyingNetworks makes Android 9 consider the network to be metered
+ underlyingNetwork?.let { arrayOf(it) }
+
+ override fun onBind(intent: Intent) = when (intent.action) {
+ SERVICE_INTERFACE -> super.onBind(intent)
+ else -> super.onBind(intent)
+ }
+
+ override fun onRevoke() = stopRunner()
+
+ override fun killProcesses(scope: CoroutineScope) {
+ super.killProcesses(scope)
+ active = false
+ scope.launch { DefaultNetworkListener.stop(this) }
+ worker?.shutdown(scope)
+ worker = null
+ conn?.close()
+ conn = null
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (prepare(this) != null) {
+// startActivity(Intent(this, VpnRequestActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
+ } else {
+ return super.onStartCommand(intent, flags, startId)
+ }
+
+ stopRunner()
+ return Service.START_NOT_STICKY
+ }
+
+ override suspend fun preInit() = DefaultNetworkListener.start(this) { underlyingNetwork = it }
+ override suspend fun resolver(host: String) =
+ DnsResolverCompat.resolve(DefaultNetworkListener.get(), host)
+
+ override suspend fun openConnection(url: URL) = DefaultNetworkListener.get().openConnection(url)
+
+ override suspend fun startProcesses(hosts: HostsFile) {
+ worker = ProtectWorker().apply { start() }
+ super.startProcesses(hosts)
+ sendFd(startVpn())
+ }
+
+ override fun buildAdditionalArguments(cmd: ArrayList): ArrayList {
+ cmd += "-V"
+ return cmd
+ }
+
+ private suspend fun startVpn(): FileDescriptor {
+ val profile = data.proxy!!.profile
+ val builder = Builder()
+ .setConfigureIntent(Core.configureIntent(this))
+ .setSession(profile.formattedName)
+ .setMtu(VPN_MTU)
+ .addAddress(PRIVATE_VLAN4_CLIENT, 30)
+ .addDnsServer(PRIVATE_VLAN4_ROUTER)
+
+ if (profile.ipv6) {
+ builder.addAddress(PRIVATE_VLAN6_CLIENT, 126)
+ builder.addRoute("::", 0)
+ }
+
+ // XinLake. bypass lan
+ resources.getStringArray(R.array.bypass_private_route).forEach {
+ val subnet = Subnet.fromString(it)!!
+ builder.addRoute(subnet.address.hostAddress, subnet.prefixSize)
+ }
+ builder.addRoute(PRIVATE_VLAN4_ROUTER, 32)
+
+ active = true // possible race condition here?
+ if (Build.VERSION.SDK_INT >= 22) {
+ builder.setUnderlyingNetworks(underlyingNetworks)
+ }
+
+ val conn = builder.establish() ?: throw NullConnectionException()
+ this.conn = conn
+
+ val cmd = arrayListOf(File(applicationInfo.nativeLibraryDir, Executable.TUN2SOCKS).absolutePath,
+ "--netif-ipaddr", PRIVATE_VLAN4_ROUTER,
+ "--socks-server-addr", "${DataStore.listenAddress}:${DataStore.portProxy}",
+ "--tunmtu", VPN_MTU.toString(),
+ "--sock-path", "sock_path",
+ "--dnsgw", "127.0.0.1:${DataStore.portLocalDns}",
+ "--loglevel", "warning")
+ if (profile.ipv6) {
+ cmd += "--netif-ip6addr"
+ cmd += PRIVATE_VLAN6_ROUTER
+ }
+ cmd += "--enable-udprelay"
+ data.processes!!.start(cmd, onRestartCallback = {
+ try {
+ sendFd(conn.fileDescriptor)
+ } catch (e: ErrnoException) {
+ stopRunner(false, e.message)
+ }
+ })
+ return conn.fileDescriptor
+ }
+
+ private suspend fun sendFd(fd: FileDescriptor) {
+ var tries = 0
+ val path = File(Core.deviceStorage.noBackupFilesDir, "sock_path").absolutePath
+ while (true) try {
+ delay(50L shl tries)
+ LocalSocket().use { localSocket ->
+ localSocket.connect(LocalSocketAddress(path, LocalSocketAddress.Namespace.FILESYSTEM))
+ localSocket.setFileDescriptorsForSend(arrayOf(fd))
+ localSocket.outputStream.write(42)
+ }
+ return
+ } catch (e: IOException) {
+ if (tries > 5) throw e
+ tries += 1
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ data.binder.close()
+ }
+}
diff --git a/client/android/src/com/github/shadowsocks/database/KeyValuePair.kt b/client/android/src/com/github/shadowsocks/database/KeyValuePair.kt
new file mode 100644
index 000000000..1ee916f87
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/database/KeyValuePair.kt
@@ -0,0 +1,136 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2017 by Max Lv *
+ * Copyright (C) 2017 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.database
+
+import androidx.room.*
+import java.io.ByteArrayOutputStream
+import java.nio.ByteBuffer
+
+@Entity
+class KeyValuePair() {
+ companion object {
+ const val TYPE_UNINITIALIZED = 0
+ const val TYPE_BOOLEAN = 1
+ const val TYPE_FLOAT = 2
+ @Deprecated("Use TYPE_LONG.")
+ const val TYPE_INT = 3
+ const val TYPE_LONG = 4
+ const val TYPE_STRING = 5
+ const val TYPE_STRING_SET = 6
+ }
+
+ @androidx.room.Dao
+ interface Dao {
+ @Query("SELECT * FROM `KeyValuePair` WHERE `key` = :key")
+ operator fun get(key: String): KeyValuePair?
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ fun put(value: KeyValuePair): Long
+
+ @Query("DELETE FROM `KeyValuePair` WHERE `key` = :key")
+ fun delete(key: String): Int
+ }
+
+ @PrimaryKey
+ var key: String = ""
+ var valueType: Int = TYPE_UNINITIALIZED
+ var value: ByteArray = ByteArray(0)
+
+ val boolean: Boolean?
+ get() = if (valueType == TYPE_BOOLEAN) ByteBuffer.wrap(value).get() != 0.toByte() else null
+ val float: Float?
+ get() = if (valueType == TYPE_FLOAT) ByteBuffer.wrap(value).float else null
+ @Suppress("DEPRECATION")
+ @Deprecated("Use long.", ReplaceWith("long"))
+ val int: Int?
+ get() = if (valueType == TYPE_INT) ByteBuffer.wrap(value).int else null
+ val long: Long?
+ get() = when (valueType) {
+ @Suppress("DEPRECATION")
+ TYPE_INT -> ByteBuffer.wrap(value).int.toLong()
+ TYPE_LONG -> ByteBuffer.wrap(value).long
+ else -> null
+ }
+ val string: String?
+ get() = if (valueType == TYPE_STRING) String(value) else null
+ val stringSet: Set?
+ get() = if (valueType == TYPE_STRING_SET) {
+ val buffer = ByteBuffer.wrap(value)
+ val result = HashSet()
+ while (buffer.hasRemaining()) {
+ val chArr = ByteArray(buffer.int)
+ buffer.get(chArr)
+ result.add(String(chArr))
+ }
+ result
+ } else null
+
+ @Ignore
+ constructor(key: String) : this() {
+ this.key = key
+ }
+
+ // putting null requires using DataStore
+ fun put(value: Boolean): KeyValuePair {
+ valueType = TYPE_BOOLEAN
+ this.value = ByteBuffer.allocate(1).put((if (value) 1 else 0).toByte()).array()
+ return this
+ }
+
+ fun put(value: Float): KeyValuePair {
+ valueType = TYPE_FLOAT
+ this.value = ByteBuffer.allocate(4).putFloat(value).array()
+ return this
+ }
+
+ @Suppress("DEPRECATION")
+ @Deprecated("Use long.")
+ fun put(value: Int): KeyValuePair {
+ valueType = TYPE_INT
+ this.value = ByteBuffer.allocate(4).putInt(value).array()
+ return this
+ }
+
+ fun put(value: Long): KeyValuePair {
+ valueType = TYPE_LONG
+ this.value = ByteBuffer.allocate(8).putLong(value).array()
+ return this
+ }
+
+ fun put(value: String): KeyValuePair {
+ valueType = TYPE_STRING
+ this.value = value.toByteArray()
+ return this
+ }
+
+ fun put(value: Set): KeyValuePair {
+ valueType = TYPE_STRING_SET
+ val stream = ByteArrayOutputStream()
+ val intBuffer = ByteBuffer.allocate(4)
+ for (v in value) {
+ intBuffer.rewind()
+ stream.write(intBuffer.putInt(v.length).array())
+ stream.write(v.toByteArray())
+ }
+ this.value = stream.toByteArray()
+ return this
+ }
+}
diff --git a/client/android/src/com/github/shadowsocks/database/PrivateDatabase.kt b/client/android/src/com/github/shadowsocks/database/PrivateDatabase.kt
new file mode 100644
index 000000000..0556bbb3e
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/database/PrivateDatabase.kt
@@ -0,0 +1,63 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2017 by Max Lv *
+ * Copyright (C) 2017 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.database
+
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import androidx.sqlite.db.SupportSQLiteDatabase
+import com.github.shadowsocks.Core.app
+import com.github.shadowsocks.database.migration.RecreateSchemaMigration
+import com.github.shadowsocks.utils.Key
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+
+@Database(entities = [Profile::class, KeyValuePair::class], version = 1000)
+abstract class PrivateDatabase : RoomDatabase() {
+ companion object {
+ private val instance by lazy {
+ Room.databaseBuilder(app, PrivateDatabase::class.java, Key.DB_PROFILE).apply {
+ addMigrations(Migration1000)
+ allowMainThreadQueries()
+ enableMultiInstanceInvalidation()
+ fallbackToDestructiveMigration()
+ setQueryExecutor { GlobalScope.launch { it.run() } }
+ }.build()
+ }
+
+ val profileDao get() = instance.profileDao()
+ val kvPairDao get() = instance.keyValuePairDao()
+ }
+
+ abstract fun profileDao(): Profile.Dao
+ abstract fun keyValuePairDao(): KeyValuePair.Dao
+
+ object Migration1000 : RecreateSchemaMigration(999,
+ 1000,
+ "Profile",
+ "(`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `host` TEXT NOT NULL, `remotePort` INTEGER NOT NULL, `password` TEXT NOT NULL, `method` TEXT NOT NULL, `remoteDns` TEXT NOT NULL, `udpdns` INTEGER NOT NULL, `ipv6` INTEGER NOT NULL, `tx` INTEGER NOT NULL, `rx` INTEGER NOT NULL, `userOrder` INTEGER NOT NULL)",
+ "`id`, `name`, `host`, `remotePort`, `password`, `method`, `remoteDns`, `udpdns`, `ipv6`, `tx`, `rx`, `userOrder`") {
+ override fun migrate(database: SupportSQLiteDatabase) {
+ super.migrate(database)
+ PublicDatabase.Migration3.migrate(database)
+ }
+ }
+}
diff --git a/client/android/src/com/github/shadowsocks/database/Profile.kt b/client/android/src/com/github/shadowsocks/database/Profile.kt
new file mode 100644
index 000000000..af457ab89
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/database/Profile.kt
@@ -0,0 +1,266 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2017 by Max Lv *
+ * Copyright (C) 2017 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.database
+
+import android.net.Uri
+import android.os.Parcelable
+import android.util.Base64
+import android.util.Log
+import android.util.LongSparseArray
+import androidx.core.net.toUri
+import androidx.room.*
+import com.github.shadowsocks.preference.DataStore
+import com.github.shadowsocks.utils.Key
+import com.github.shadowsocks.utils.parsePort
+import com.google.gson.JsonArray
+import com.google.gson.JsonElement
+import com.google.gson.JsonObject
+import com.google.gson.JsonPrimitive
+import kotlinx.android.parcel.Parcelize
+import org.json.JSONObject
+import java.io.Serializable
+import java.net.URI
+import java.net.URISyntaxException
+import java.util.*
+
+@Entity
+@Parcelize
+data class Profile(
+ // XinLake. route mode is bypass-lan
+ @PrimaryKey(autoGenerate = true)
+ var id: Long = 0,
+ var name: String? = "",
+ var host: String = "0.0.0.0",
+ var remotePort: Int = 0,
+ var password: String = "0000",
+ var method: String = "aes-256-cfb",
+ var remoteDns: String = "8.8.8.8",
+ var udpdns: Boolean = false,
+ var ipv6: Boolean = false,
+ //@TargetApi(28)
+ var tx: Long = 0,
+ var rx: Long = 0,
+ var userOrder: Long = 0,
+
+ @Ignore // not persisted in db, only used by direct boot
+ var dirty: Boolean = false) : Parcelable, Serializable {
+ companion object {
+ private const val TAG = "ShadowParser"
+ private const val serialVersionUID = 1L
+ private val pattern =
+ """(?i)ss://[-a-zA-Z0-9+&@#/%?=.~*'()|!:,;\[\]]*[-a-zA-Z0-9+&@#/%=.~*'()|\[\]]""".toRegex()
+ private val userInfoPattern = "^(.+?):(.*)$".toRegex()
+ private val legacyPattern = "^(.+?):(.*)@(.+?):(\\d+?)$".toRegex()
+
+ fun findAllUrls(data: CharSequence?, feature: Profile? = null) =
+ pattern.findAll(data ?: "").map {
+ val uri = it.value.toUri()
+ try {
+ if (uri.userInfo == null) {
+ val match = legacyPattern.matchEntire(String(Base64.decode(uri.host, Base64.NO_PADDING)))
+ if (match != null) {
+ val profile = Profile()
+ feature?.copyFeatureSettingsTo(profile)
+ profile.method = match.groupValues[1].toLowerCase(Locale.ENGLISH)
+ profile.password = match.groupValues[2]
+ profile.host = match.groupValues[3]
+ profile.remotePort = match.groupValues[4].toInt()
+ profile.name = uri.fragment
+ profile
+ } else {
+ Log.e(TAG, "Unrecognized URI: ${it.value}")
+ null
+ }
+ } else {
+ val match = userInfoPattern.matchEntire(String(Base64.decode(uri.userInfo,
+ Base64.NO_PADDING or Base64.NO_WRAP or Base64.URL_SAFE)))
+ if (match != null) {
+ val profile = Profile()
+ feature?.copyFeatureSettingsTo(profile)
+ profile.method = match.groupValues[1]
+ profile.password = match.groupValues[2]
+ // bug in Android: https://code.google.com/p/android/issues/detail?id=192855
+ try {
+ val javaURI = URI(it.value)
+ profile.host = javaURI.host ?: ""
+ if (profile.host.firstOrNull() == '[' && profile.host.lastOrNull() == ']') {
+ profile.host = profile.host.substring(1, profile.host.length - 1)
+ }
+ profile.remotePort = javaURI.port
+ profile.name = uri.fragment ?: ""
+ profile
+ } catch (e: URISyntaxException) {
+ Log.e(TAG, "Invalid URI: ${it.value}")
+ null
+ }
+ } else {
+ Log.e(TAG, "Unknown user info: ${it.value}")
+ null
+ }
+ }
+ } catch (e: IllegalArgumentException) {
+ Log.e(TAG, "Invalid base64 detected: ${it.value}")
+ null
+ }
+ }.filterNotNull()
+
+ private class JsonParser(private val feature: Profile? = null) : ArrayList() {
+ private val JsonElement?.optString get() = (this as? JsonPrimitive)?.asString
+ private val JsonElement?.optBoolean
+ get() = // asBoolean attempts to cast everything to boolean
+ (this as? JsonPrimitive)?.run { if (isBoolean) asBoolean else null }
+ private val JsonElement?.optInt
+ get() = try {
+ (this as? JsonPrimitive)?.asInt
+ } catch (_: NumberFormatException) {
+ null
+ }
+
+ private fun tryParse(json: JsonObject, fallback: Boolean = false): Profile? {
+ val host = json["server"].optString
+ if (host.isNullOrEmpty()) return null
+ val remotePort = json["server_port"]?.optInt
+ if (remotePort == null || remotePort <= 0) return null
+ val password = json["password"].optString
+ if (password.isNullOrEmpty()) return null
+ val method = json["method"].optString
+ if (method.isNullOrEmpty()) return null
+ return Profile().also {
+ it.host = host
+ it.remotePort = remotePort
+ it.password = password
+ it.method = method
+ }.apply {
+ feature?.copyFeatureSettingsTo(this)
+ name = json["remarks"].optString
+ if (fallback) return@apply
+ remoteDns = json["remote_dns"].optString ?: remoteDns
+ ipv6 = json["ipv6"].optBoolean ?: ipv6
+ udpdns = json["udpdns"].optBoolean ?: udpdns
+ }
+ }
+
+ fun process(json: JsonElement?) {
+ when (json) {
+ is JsonObject -> {
+ val profile = tryParse(json)
+ if (profile != null) add(profile) else for ((_, value) in json.entrySet()) process(value)
+ }
+ is JsonArray -> json.asIterable().forEach(this::process)
+ // ignore other types
+ }
+ }
+ }
+
+ fun parseJson(json: JsonElement, feature: Profile? = null, create: (Profile) -> Unit) {
+ JsonParser(feature).run {
+ process(json)
+ for (profile in this) create(profile)
+ }
+ }
+ }
+
+ @androidx.room.Dao
+ interface Dao {
+ @Query("SELECT * FROM `Profile` WHERE `id` = :id")
+ operator fun get(id: Long): Profile?
+
+ @Query("SELECT * FROM `Profile` ORDER BY `userOrder`")
+ fun list(): List
+
+ @Query("SELECT MAX(`userOrder`) + 1 FROM `Profile`")
+ fun nextOrder(): Long?
+
+ @Query("SELECT 1 FROM `Profile` LIMIT 1")
+ fun isNotEmpty(): Boolean
+
+ @Insert
+ fun create(value: Profile): Long
+
+ @Update
+ fun update(value: Profile): Int
+
+ @Query("DELETE FROM `Profile` WHERE `id` = :id")
+ fun delete(id: Long): Int
+
+ @Query("DELETE FROM `Profile`")
+ fun deleteAll(): Int
+ }
+
+ val formattedAddress get() = (if (host.contains(":")) "[%s]:%d" else "%s:%d").format(host, remotePort)
+ val formattedName get() = if (name.isNullOrEmpty()) formattedAddress else name!!
+
+ fun copyFeatureSettingsTo(profile: Profile) {
+ profile.ipv6 = ipv6
+ profile.udpdns = udpdns
+ }
+
+ fun toUri(): Uri {
+ val auth = Base64.encodeToString("$method:$password".toByteArray(),
+ Base64.NO_PADDING or Base64.NO_WRAP or Base64.URL_SAFE)
+ val wrappedHost = if (host.contains(':')) "[$host]" else host
+ val builder = Uri.Builder().scheme("ss").encodedAuthority("$auth@$wrappedHost:$remotePort")
+ if (!name.isNullOrEmpty()) builder.fragment(name)
+ return builder.build()
+ }
+
+ override fun toString() = toUri().toString()
+
+ fun toJson(profiles: LongSparseArray? = null): JSONObject = JSONObject().apply {
+ put("server", host)
+ put("server_port", remotePort)
+ put("password", password)
+ put("method", method)
+ if (profiles == null) return@apply
+ put("remarks", name)
+ put("remote_dns", remoteDns)
+ put("ipv6", ipv6)
+ put("udpdns", udpdns)
+ }
+
+ fun serialize() {
+ DataStore.editingId = id
+ DataStore.privateStore.putString(Key.name, name)
+ DataStore.privateStore.putString(Key.host, host)
+ DataStore.privateStore.putString(Key.remotePort, remotePort.toString())
+ DataStore.privateStore.putString(Key.password, password)
+ DataStore.privateStore.putString(Key.remoteDns, remoteDns)
+ DataStore.privateStore.putString(Key.method, method)
+ DataStore.privateStore.putBoolean(Key.udpdns, udpdns)
+ DataStore.privateStore.putBoolean(Key.ipv6, ipv6)
+ DataStore.privateStore.remove(Key.dirty)
+ }
+
+ fun deserialize() {
+ check(id == 0L || DataStore.editingId == id)
+ DataStore.editingId = null
+ // It's assumed that default values are never used, so 0/false/null is always used even if that isn't the case
+ name = DataStore.privateStore.getString(Key.name) ?: ""
+ // It's safe to trim the hostname, as we expect no leading or trailing whitespaces here
+ host = (DataStore.privateStore.getString(Key.host) ?: "").trim()
+ remotePort = parsePort(DataStore.privateStore.getString(Key.remotePort), 8388, 1)
+ password = DataStore.privateStore.getString(Key.password) ?: ""
+ method = DataStore.privateStore.getString(Key.method) ?: ""
+ remoteDns = DataStore.privateStore.getString(Key.remoteDns) ?: ""
+ udpdns = DataStore.privateStore.getBoolean(Key.udpdns, false)
+ ipv6 = DataStore.privateStore.getBoolean(Key.ipv6, false)
+ }
+}
diff --git a/client/android/src/com/github/shadowsocks/database/ProfileManager.kt b/client/android/src/com/github/shadowsocks/database/ProfileManager.kt
new file mode 100644
index 000000000..87ee260ca
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/database/ProfileManager.kt
@@ -0,0 +1,140 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2017 by Max Lv *
+ * Copyright (C) 2017 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.database
+
+import android.database.sqlite.SQLiteCantOpenDatabaseException
+import android.util.LongSparseArray
+import com.github.shadowsocks.Core
+import com.github.shadowsocks.preference.DataStore
+import com.github.shadowsocks.utils.DirectBoot
+import com.github.shadowsocks.utils.forEachTry
+import com.github.shadowsocks.utils.printLog
+import com.google.gson.JsonStreamParser
+import org.json.JSONArray
+import java.io.IOException
+import java.io.InputStream
+import java.sql.SQLException
+
+/**
+ * SQLExceptions are not caught (and therefore will cause crash) for insert/update transactions
+ * to ensure we are in a consistent state.
+ */
+object ProfileManager {
+ interface Listener {
+ fun onAdd(profile: Profile)
+ fun onRemove(profileId: Long)
+ fun onCleared()
+ }
+
+ var listener: Listener? = null
+
+ @Throws(SQLException::class)
+ fun createProfile(profile: Profile = Profile()): Profile {
+ profile.id = 0
+ profile.userOrder = PrivateDatabase.profileDao.nextOrder() ?: 0
+ profile.id = PrivateDatabase.profileDao.create(profile)
+ listener?.onAdd(profile)
+ return profile
+ }
+
+ fun createProfilesFromJson(jsons: Sequence, replace: Boolean = false) {
+ val profiles = if (replace) getAllProfiles()?.associateBy { it.formattedAddress } else null
+ val feature = if (replace) {
+ profiles?.values?.singleOrNull { it.id == DataStore.profileId }
+ } else Core.currentProfile?.first
+ val lazyClear = lazy { clear() }
+ jsons.asIterable().forEachTry { json ->
+ Profile.parseJson(JsonStreamParser(json.bufferedReader()).asSequence().single(), feature) {
+ if (replace) {
+ lazyClear.value
+ // if two profiles has the same address, treat them as the same profile and copy stats over
+ profiles?.get(it.formattedAddress)?.apply {
+ it.tx = tx
+ it.rx = rx
+ }
+ }
+ createProfile(it)
+ }
+ }
+ }
+
+ fun serializeToJson(profiles: List? = getAllProfiles()): JSONArray? {
+ if (profiles == null) return null
+ val lookup = LongSparseArray(profiles.size).apply { profiles.forEach { put(it.id, it) } }
+ return JSONArray(profiles.map { it.toJson(lookup) }.toTypedArray())
+ }
+
+ /**
+ * Note: It's caller's responsibility to update DirectBoot profile if necessary.
+ */
+ @Throws(SQLException::class)
+ fun updateProfile(profile: Profile) = check(PrivateDatabase.profileDao.update(profile) == 1)
+
+ @Throws(IOException::class)
+ fun getProfile(id: Long): Profile? = try {
+ PrivateDatabase.profileDao[id]
+ } catch (ex: SQLiteCantOpenDatabaseException) {
+ throw IOException(ex)
+ } catch (ex: SQLException) {
+ printLog(ex)
+ null
+ }
+
+ @Throws(IOException::class)
+ fun expand(profile: Profile): Pair = Pair(profile, null)
+
+ @Throws(SQLException::class)
+ fun delProfile(id: Long) {
+ check(PrivateDatabase.profileDao.delete(id) == 1)
+ listener?.onRemove(id)
+ if (id in Core.activeProfileIds && DataStore.directBootAware) DirectBoot.clean()
+ }
+
+ @Throws(SQLException::class)
+ fun clear() = PrivateDatabase.profileDao.deleteAll().also {
+ // listener is not called since this won't be used in mobile submodule
+ DirectBoot.clean()
+ listener?.onCleared()
+ }
+
+ @Throws(IOException::class)
+ fun ensureNotEmpty() {
+ val nonEmpty = try {
+ PrivateDatabase.profileDao.isNotEmpty()
+ } catch (ex: SQLiteCantOpenDatabaseException) {
+ throw IOException(ex)
+ } catch (ex: SQLException) {
+ printLog(ex)
+ false
+ }
+ if (!nonEmpty) DataStore.profileId = createProfile().id
+ }
+
+ @Throws(IOException::class)
+ fun getAllProfiles(): List? = try {
+ PrivateDatabase.profileDao.list()
+ } catch (ex: SQLiteCantOpenDatabaseException) {
+ throw IOException(ex)
+ } catch (ex: SQLException) {
+ printLog(ex)
+ null
+ }
+}
diff --git a/client/android/src/com/github/shadowsocks/database/PublicDatabase.kt b/client/android/src/com/github/shadowsocks/database/PublicDatabase.kt
new file mode 100644
index 000000000..2a92d95f8
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/database/PublicDatabase.kt
@@ -0,0 +1,56 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2017 by Max Lv *
+ * Copyright (C) 2017 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.database
+
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import com.github.shadowsocks.Core
+import com.github.shadowsocks.database.migration.RecreateSchemaMigration
+import com.github.shadowsocks.utils.Key
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+
+@Database(entities = [KeyValuePair::class], version = 3)
+abstract class PublicDatabase : RoomDatabase() {
+ companion object {
+ private val instance by lazy {
+ Room.databaseBuilder(Core.deviceStorage, PublicDatabase::class.java, Key.DB_PUBLIC)
+ .apply {
+ addMigrations(Migration3)
+ allowMainThreadQueries()
+ enableMultiInstanceInvalidation()
+ fallbackToDestructiveMigration()
+ setQueryExecutor { GlobalScope.launch { it.run() } }
+ }.build()
+ }
+
+ val kvPairDao get() = instance.keyValuePairDao()
+ }
+
+ abstract fun keyValuePairDao(): KeyValuePair.Dao
+
+ internal object Migration3 : RecreateSchemaMigration(2,
+ 3,
+ "KeyValuePair",
+ "(`key` TEXT NOT NULL, `valueType` INTEGER NOT NULL, `value` BLOB NOT NULL, PRIMARY KEY(`key`))",
+ "`key`, `valueType`, `value`")
+}
diff --git a/client/android/src/com/github/shadowsocks/database/migration/RecreateSchemaMigration.kt b/client/android/src/com/github/shadowsocks/database/migration/RecreateSchemaMigration.kt
new file mode 100644
index 000000000..423521563
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/database/migration/RecreateSchemaMigration.kt
@@ -0,0 +1,35 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2018 by Max Lv *
+ * Copyright (C) 2018 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.database.migration
+
+import androidx.room.migration.Migration
+import androidx.sqlite.db.SupportSQLiteDatabase
+
+open class RecreateSchemaMigration(oldVersion: Int, newVersion: Int, private val table: String,
+ private val schema: String, private val keys: String)
+ : Migration(oldVersion, newVersion) {
+ override fun migrate(database: SupportSQLiteDatabase) {
+ database.execSQL("CREATE TABLE `tmp` $schema")
+ database.execSQL("INSERT INTO `tmp` ($keys) SELECT $keys FROM `$table`")
+ database.execSQL("DROP TABLE `$table`")
+ database.execSQL("ALTER TABLE `tmp` RENAME TO `$table`")
+ }
+}
diff --git a/client/android/src/com/github/shadowsocks/net/ChannelMonitor.kt b/client/android/src/com/github/shadowsocks/net/ChannelMonitor.kt
new file mode 100644
index 000000000..e2153f032
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/net/ChannelMonitor.kt
@@ -0,0 +1,129 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2019 by Max Lv *
+ * Copyright (C) 2019 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.net
+
+import android.os.Build
+import com.github.shadowsocks.utils.printLog
+import kotlinx.coroutines.*
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.sendBlocking
+import java.io.IOException
+import java.nio.ByteBuffer
+import java.nio.channels.*
+
+class ChannelMonitor : Thread("ChannelMonitor") {
+ private data class Registration(val channel: SelectableChannel,
+ val ops: Int,
+ val listener: (SelectionKey) -> Unit) {
+ val result = CompletableDeferred()
+ }
+
+ private val selector = Selector.open()
+ private val registrationPipe = Pipe.open()
+ private val pendingRegistrations = Channel(Channel.UNLIMITED)
+ private val closeChannel = Channel(1)
+ @Volatile
+ private var running = true
+
+ private fun registerInternal(channel: SelectableChannel, ops: Int, block: (SelectionKey) -> Unit) =
+ channel.register(selector, ops, block)
+
+ init {
+ registrationPipe.source().apply {
+ configureBlocking(false)
+ registerInternal(this, SelectionKey.OP_READ) {
+ val junk = ByteBuffer.allocateDirect(1)
+ while (read(junk) > 0) {
+ pendingRegistrations.poll()!!.apply {
+ try {
+ result.complete(registerInternal(channel, ops, listener))
+ } catch (e: Exception) {
+ result.completeExceptionally(e)
+ }
+ }
+ junk.clear()
+ }
+ }
+ }
+ start()
+ }
+
+ /**
+ * Prevent NetworkOnMainThreadException because people enable strict mode for no reasons.
+ */
+ private suspend fun WritableByteChannel.writeCompat(src: ByteBuffer) =
+ if (Build.VERSION.SDK_INT <= 23) withContext(Dispatchers.Default) { write(src) } else write(src)
+
+ suspend fun register(channel: SelectableChannel, ops: Int, block: (SelectionKey) -> Unit): SelectionKey {
+ val registration = Registration(channel, ops, block)
+ pendingRegistrations.send(registration)
+ ByteBuffer.allocateDirect(1).also { junk ->
+ loop@ while (running) when (registrationPipe.sink().writeCompat(junk)) {
+ 0 -> kotlinx.coroutines.yield()
+ 1 -> break@loop
+ else -> throw IOException("Failed to register in the channel")
+ }
+ }
+ if (!running) throw CancellationException()
+ return registration.result.await()
+ }
+
+ suspend fun wait(channel: SelectableChannel, ops: Int) =
+ CompletableDeferred().run {
+ register(channel, ops) {
+ if (it.isValid) try {
+ it.interestOps(0) // stop listening
+ } catch (_: CancelledKeyException) {
+ }
+ complete(it)
+ }
+ await()
+ }
+
+ override fun run() {
+ while (running) {
+ val num = try {
+ selector.select()
+ } catch (e: Exception) {
+ printLog(e)
+ continue
+ }
+ if (num <= 0) continue
+ val iterator = selector.selectedKeys().iterator()
+ while (iterator.hasNext()) {
+ val key = iterator.next()
+ iterator.remove()
+ (key.attachment() as (SelectionKey) -> Unit)(key)
+ }
+ }
+ closeChannel.sendBlocking(Unit)
+ }
+
+ fun close(scope: CoroutineScope) {
+ running = false
+ selector.wakeup()
+ scope.launch {
+ closeChannel.receive()
+ selector.keys().forEach { it.channel().close() }
+ selector.close()
+ }
+ }
+}
diff --git a/client/android/src/com/github/shadowsocks/net/ConcurrentLocalSocketListener.kt b/client/android/src/com/github/shadowsocks/net/ConcurrentLocalSocketListener.kt
new file mode 100644
index 000000000..b00d9bdc2
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/net/ConcurrentLocalSocketListener.kt
@@ -0,0 +1,43 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2019 by Max Lv *
+ * Copyright (C) 2019 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.net
+
+import android.net.LocalSocket
+import com.github.shadowsocks.utils.printLog
+import kotlinx.coroutines.*
+import java.io.File
+
+abstract class ConcurrentLocalSocketListener(name: String, socketFile: File) :
+ LocalSocketListener(name, socketFile), CoroutineScope {
+ override val coroutineContext =
+ Dispatchers.IO + SupervisorJob() + CoroutineExceptionHandler { _, t -> printLog(t) }
+
+ override fun accept(socket: LocalSocket) {
+ launch { super.accept(socket) }
+ }
+
+ override fun shutdown(scope: CoroutineScope) {
+ running = false
+ cancel()
+ super.shutdown(scope)
+ coroutineContext[Job]!!.also { job -> scope.launch { job.join() } }
+ }
+}
diff --git a/client/android/src/com/github/shadowsocks/net/DefaultNetworkListener.kt b/client/android/src/com/github/shadowsocks/net/DefaultNetworkListener.kt
new file mode 100644
index 000000000..9ce13513d
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/net/DefaultNetworkListener.kt
@@ -0,0 +1,145 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2019 by Max Lv *
+ * Copyright (C) 2019 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.net
+
+import android.annotation.TargetApi
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import android.os.Build
+import com.github.shadowsocks.Core
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.channels.actor
+import kotlinx.coroutines.runBlocking
+import java.net.UnknownHostException
+
+object DefaultNetworkListener {
+ private sealed class NetworkMessage {
+ class Start(val key: Any, val listener: (Network?) -> Unit) : NetworkMessage()
+ class Get : NetworkMessage() {
+ val response = CompletableDeferred()
+ }
+
+ class Stop(val key: Any) : NetworkMessage()
+
+ class Put(val network: Network) : NetworkMessage()
+ class Update(val network: Network) : NetworkMessage()
+ class Lost(val network: Network) : NetworkMessage()
+ }
+
+ private val networkActor = GlobalScope.actor(Dispatchers.Unconfined) {
+ val listeners = mutableMapOf Unit>()
+ var network: Network? = null
+ val pendingRequests = arrayListOf()
+ for (message in channel) when (message) {
+ is NetworkMessage.Start -> {
+ if (listeners.isEmpty()) register()
+ listeners[message.key] = message.listener
+ if (network != null) message.listener(network)
+ }
+ is NetworkMessage.Get -> {
+ check(listeners.isNotEmpty()) { "Getting network without any listeners is not supported" }
+ if (network == null) pendingRequests += message else message.response.complete(network)
+ }
+ is NetworkMessage.Stop -> if (listeners.isNotEmpty() && // was not empty
+ listeners.remove(message.key) != null && listeners.isEmpty()) {
+ network = null
+ unregister()
+ }
+
+ is NetworkMessage.Put -> {
+ network = message.network
+ pendingRequests.forEach { it.response.complete(message.network) }
+ pendingRequests.clear()
+ listeners.values.forEach { it(network) }
+ }
+ is NetworkMessage.Update -> if (network == message.network) listeners.values.forEach {
+ it(network)
+ }
+ is NetworkMessage.Lost -> if (network == message.network) {
+ network = null
+ listeners.values.forEach { it(null) }
+ }
+ }
+ }
+
+ suspend fun start(key: Any, listener: (Network?) -> Unit) =
+ networkActor.send(NetworkMessage.Start(key, listener))
+
+ suspend fun get() = if (fallback) @TargetApi(23) {
+ Core.connectivity.activeNetwork
+ ?: throw UnknownHostException() // failed to listen, return current if available
+ } else NetworkMessage.Get().run {
+ networkActor.send(this)
+ response.await()
+ }
+
+ suspend fun stop(key: Any) = networkActor.send(NetworkMessage.Stop(key))
+
+ // NB: this runs in ConnectivityThread, and this behavior cannot be changed until API 26
+// private object Callback : ConnectivityManager.NetworkCallback() {
+// override fun onAvailable(network: Network) =
+// runBlocking { networkActor.send(NetworkMessage.Put(network)) }
+//
+// override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities?) {
+// // it's a good idea to refresh capabilities
+// runBlocking { networkActor.send(NetworkMessage.Update(network)) }
+// }
+//
+// override fun onLost(network: Network) =
+// runBlocking { networkActor.send(NetworkMessage.Lost(network)) }
+// }
+
+ private var fallback = false
+ private val request = NetworkRequest.Builder().apply {
+ addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
+ }.build()
+
+ /**
+ * Unfortunately registerDefaultNetworkCallback is going to return VPN interface since Android P DP1:
+ * https://android.googlesource.com/platform/frameworks/base/+/dda156ab0c5d66ad82bdcf76cda07cbc0a9c8a2e
+ *
+ * This makes doing a requestNetwork with REQUEST necessary so that we don't get ALL possible networks that
+ * satisfies default network capabilities but only THE default network. Unfortunately, we need to have
+ * android.permission.CHANGE_NETWORK_STATE to be able to call requestNetwork.
+ *
+ * Source: https://android.googlesource.com/platform/frameworks/base/+/2df4c7d/services/core/java/com/android/server/ConnectivityService.java#887
+ */
+ private fun register() {
+// if (Build.VERSION.SDK_INT in 24..27) @TargetApi(24) {
+// Core.connectivity.registerDefaultNetworkCallback(Callback)
+// } else try {
+// fallback = false
+// // we want REQUEST here instead of LISTEN
+// Core.connectivity.requestNetwork(request, Callback)
+// } catch (e: SecurityException) {
+// // known bug: https://stackoverflow.com/a/33509180/2245107
+// // if (Build.VERSION.SDK_INT != 23) Crashlytics.logException(e)
+// fallback = true
+// }
+ }
+
+ private fun unregister() {}//= Core.connectivity.unregisterNetworkCallback(Callback)
+}
diff --git a/client/android/src/com/github/shadowsocks/net/HostsFile.kt b/client/android/src/com/github/shadowsocks/net/HostsFile.kt
new file mode 100644
index 000000000..9bffd7753
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/net/HostsFile.kt
@@ -0,0 +1,40 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2019 by Max Lv *
+ * Copyright (C) 2019 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.net
+
+import com.github.shadowsocks.utils.computeIfAbsentCompat
+import com.github.shadowsocks.utils.parseNumericAddress
+import java.net.InetAddress
+
+class HostsFile(input: String = "") {
+ private val map = mutableMapOf>()
+
+ init {
+ for (line in input.lineSequence()) {
+ val entries = line.substringBefore('#').splitToSequence(' ', '\t').filter { it.isNotEmpty() }
+ val address = entries.firstOrNull()?.parseNumericAddress() ?: continue
+ for (hostname in entries.drop(1)) map.computeIfAbsentCompat(hostname) { LinkedHashSet(1) }.add(address)
+ }
+ }
+
+ val configuredHostnames get() = map.size
+ fun resolve(hostname: String) = map[hostname]?.shuffled() ?: emptyList()
+}
diff --git a/client/android/src/com/github/shadowsocks/net/HttpsTest.kt b/client/android/src/com/github/shadowsocks/net/HttpsTest.kt
new file mode 100644
index 000000000..9c8e73ae5
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/net/HttpsTest.kt
@@ -0,0 +1,121 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2018 by Max Lv *
+ * Copyright (C) 2018 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.net
+
+import android.os.Build
+import android.os.SystemClock
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import com.github.shadowsocks.Core.app
+import org.amnezia.vpn.R
+import com.github.shadowsocks.utils.useCancellable
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import java.io.IOException
+import java.net.HttpURLConnection
+import java.net.URL
+import java.net.URLConnection
+
+/**
+ * Based on: https://android.googlesource.com/platform/frameworks/base/+/b19a838/services/core/java/com/android/server/connectivity/NetworkMonitor.java#1071
+ */
+class HttpsTest : ViewModel() {
+ sealed class Status {
+ protected abstract val status: CharSequence
+ open fun retrieve(setStatus: (CharSequence) -> Unit, errorCallback: (String) -> Unit) =
+ setStatus(status)
+
+ object Idle : Status() {
+ override val status get() = app.getText(R.string.vpn_connected)
+ }
+
+ object Testing : Status() {
+ override val status get() = app.getText(R.string.connection_test_testing)
+ }
+
+ class Success(private val elapsed: Long) : Status() {
+ override val status get() = app.getString(R.string.connection_test_available, elapsed)
+ }
+
+ sealed class Error : Status() {
+ override val status get() = app.getText(R.string.connection_test_fail)
+ protected abstract val error: String
+ private var shown = false
+ override fun retrieve(setStatus: (CharSequence) -> Unit, errorCallback: (String) -> Unit) {
+ super.retrieve(setStatus, errorCallback)
+ if (shown) return
+ shown = true
+ errorCallback(error)
+ }
+
+ class UnexpectedResponseCode(private val code: Int) : Error() {
+ override val error get() = app.getString(R.string.connection_test_error_status_code, code)
+ }
+
+ class IOFailure(private val e: IOException) : Error() {
+ override val error get() = app.getString(R.string.connection_test_error, e.message)
+ }
+ }
+ }
+
+ private var running: Job? = null
+ val status = MutableLiveData().apply { value = Status.Idle }
+
+ fun testConnection() {
+ cancelTest()
+ status.value = Status.Testing
+ val url = URL("https", "www.google.com", "/generate_204")
+ val conn = (url.openConnection()) as HttpURLConnection
+ conn.setRequestProperty("Connection", "close")
+ conn.instanceFollowRedirects = false
+ conn.useCaches = false
+ running = GlobalScope.launch(Dispatchers.Main.immediate) {
+ status.value = conn.useCancellable {
+ try {
+ val start = SystemClock.elapsedRealtime()
+ val code = responseCode
+ val elapsed = SystemClock.elapsedRealtime() - start
+ if (code == 204 || code == 200 && responseLength == 0L) Status.Success(elapsed)
+ else Status.Error.UnexpectedResponseCode(code)
+ } catch (e: IOException) {
+ Status.Error.IOFailure(e)
+ } finally {
+ disconnect()
+ }
+ }
+ }
+ }
+
+ private fun cancelTest() {
+ running?.cancel()
+ running = null
+ }
+
+ fun invalidate() {
+ cancelTest()
+ status.value = Status.Idle
+ }
+
+ private val URLConnection.responseLength: Long
+ get() = if (Build.VERSION.SDK_INT >= 24) contentLengthLong else contentLength.toLong()
+}
diff --git a/client/android/src/com/github/shadowsocks/net/LocalDnsServer.kt b/client/android/src/com/github/shadowsocks/net/LocalDnsServer.kt
new file mode 100644
index 000000000..99b5a8496
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/net/LocalDnsServer.kt
@@ -0,0 +1,194 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2019 by Max Lv *
+ * Copyright (C) 2019 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.net
+
+import com.github.shadowsocks.bg.BaseService
+import com.github.shadowsocks.utils.printLog
+import kotlinx.coroutines.*
+import org.xbill.DNS.*
+import java.io.IOException
+import java.net.*
+import java.nio.ByteBuffer
+import java.nio.channels.DatagramChannel
+import java.nio.channels.SelectionKey
+import java.nio.channels.SocketChannel
+
+/**
+ * A simple DNS conditional forwarder.
+ *
+ * No cache is provided as localResolver may change from time to time. We expect DNS clients to do cache themselves.
+ *
+ * Based on:
+ * https://github.com/bitcoinj/httpseed/blob/809dd7ad9280f4bc98a356c1ffb3d627bf6c7ec5/src/main/kotlin/dns.kt
+ * https://github.com/shadowsocks/overture/tree/874f22613c334a3b78e40155a55479b7b69fee04
+ */
+class LocalDnsServer(private val localResolver: suspend (String) -> Array,
+ private val remoteDns: Socks5Endpoint,
+ private val proxy: SocketAddress,
+ private val hosts: HostsFile) : CoroutineScope {
+ /**
+ * Forward all requests to remote and ignore localResolver.
+ */
+ var forwardOnly = false
+ /**
+ * Forward UDP queries to TCP.
+ */
+ var tcp = true
+ var remoteDomainMatcher: Regex? = null
+ var localIpMatcher: List = emptyList()
+
+ companion object {
+ private const val TAG = "LocalDnsServer"
+ private const val TIMEOUT = 10_000L
+ /**
+ * TTL returned from localResolver is set to 120. Android API does not provide TTL,
+ * so we suppose Android apps should not care about TTL either.
+ */
+ private const val TTL = 120L
+ private const val UDP_PACKET_SIZE = 512
+
+ private fun prepareDnsResponse(request: Message) = Message(request.header.id).apply {
+ header.setFlag(Flags.QR.toInt()) // this is a response
+ if (request.header.getFlag(Flags.RD.toInt())) header.setFlag(Flags.RD.toInt())
+ request.question?.also { addRecord(it, Section.QUESTION) }
+ }
+
+ private fun cookDnsResponse(request: Message, results: Iterable) =
+ ByteBuffer.wrap(prepareDnsResponse(request).apply {
+ header.setFlag(Flags.RA.toInt()) // recursion available
+ for (address in results) addRecord(when (address) {
+ is Inet4Address -> ARecord(question.name, DClass.IN, TTL, address)
+ is Inet6Address -> AAAARecord(question.name, DClass.IN, TTL, address)
+ else -> error("Unsupported address $address")
+ }, Section.ANSWER)
+ }.toWire())
+ }
+
+ private val monitor = ChannelMonitor()
+
+ override val coroutineContext =
+ SupervisorJob() + CoroutineExceptionHandler { _, t -> printLog(t) }
+
+ suspend fun start(listen: SocketAddress) = DatagramChannel.open().run {
+ configureBlocking(false)
+ try {
+ socket().bind(listen)
+ } catch (e: BindException) {
+ throw BaseService.ExpectedExceptionWrapper(e)
+ }
+ monitor.register(this, SelectionKey.OP_READ) { handlePacket(this) }
+ }
+
+ private fun handlePacket(channel: DatagramChannel) {
+ val buffer = ByteBuffer.allocateDirect(UDP_PACKET_SIZE)
+ val source = channel.receive(buffer)!!
+ buffer.flip()
+ launch {
+ val reply = resolve(buffer)
+ while (channel.send(reply, source) <= 0) monitor.wait(channel, SelectionKey.OP_WRITE)
+ }
+ }
+
+ private suspend fun resolve(packet: ByteBuffer): ByteBuffer {
+ val request = try {
+ Message(packet)
+ } catch (e: IOException) { // we cannot parse the message, do not attempt to handle it at all
+ // Crashlytics.log(Log.WARN, TAG, e.message)
+ return forward(packet)
+ }
+ return supervisorScope {
+ val remote = async { withTimeout(TIMEOUT) { forward(packet) } }
+ try {
+ if (request.header.opcode != Opcode.QUERY) return@supervisorScope remote.await()
+ val question = request.question
+ if (question?.type != Type.A) return@supervisorScope remote.await()
+ val host = question.name.toString(true)
+ val hostsResults = hosts.resolve(host)
+ if (hostsResults.isNotEmpty()) {
+ remote.cancel()
+ return@supervisorScope cookDnsResponse(request, hostsResults)
+ }
+ if (forwardOnly) return@supervisorScope remote.await()
+ if (remoteDomainMatcher?.containsMatchIn(host) == true) return@supervisorScope remote.await()
+ val localResults = try {
+ withTimeout(TIMEOUT) { localResolver(host) }
+ } catch (_: TimeoutCancellationException) {
+ // Crashlytics.log(Log.WARN, TAG, "Local resolving timed out, falling back to remote resolving")
+ return@supervisorScope remote.await()
+ } catch (_: UnknownHostException) {
+ return@supervisorScope remote.await()
+ }
+ if (localResults.isEmpty()) return@supervisorScope remote.await()
+ if (localIpMatcher.isEmpty() || localIpMatcher.any { subnet -> localResults.any(subnet::matches) }) {
+ remote.cancel()
+ cookDnsResponse(request, localResults.asIterable())
+ } else remote.await()
+ } catch (e: Exception) {
+ remote.cancel()
+ when (e) {
+ // is TimeoutCancellationException -> Crashlytics.log(Log.WARN, TAG, "Remote resolving timed out")
+ is CancellationException -> {
+ } // ignore
+ // is IOException -> Crashlytics.log(Log.WARN, TAG, e.message)
+ else -> printLog(e)
+ }
+ ByteBuffer.wrap(prepareDnsResponse(request).apply {
+ header.rcode = Rcode.SERVFAIL
+ }.toWire())
+ }
+ }
+ }
+
+ private suspend fun forward(packet: ByteBuffer): ByteBuffer {
+ packet.position(0) // the packet might have been parsed, reset to beginning
+ return if (tcp) SocketChannel.open().use { channel ->
+ channel.configureBlocking(false)
+ channel.connect(proxy)
+ val wrapped = remoteDns.tcpWrap(packet)
+ while (!channel.finishConnect()) monitor.wait(channel, SelectionKey.OP_CONNECT)
+ while (channel.write(wrapped) >= 0 && wrapped.hasRemaining()) monitor.wait(channel, SelectionKey.OP_WRITE)
+ val result = remoteDns.tcpReceiveBuffer(UDP_PACKET_SIZE)
+ remoteDns.tcpUnwrap(result, channel::read) {
+ monitor.wait(channel, SelectionKey.OP_READ)
+ }
+ result
+ } else DatagramChannel.open().use { channel ->
+ channel.configureBlocking(false)
+ monitor.wait(channel, SelectionKey.OP_WRITE)
+ check(channel.send(remoteDns.udpWrap(packet), proxy) > 0)
+ val result = remoteDns.udpReceiveBuffer(UDP_PACKET_SIZE)
+ while (isActive) {
+ monitor.wait(channel, SelectionKey.OP_READ)
+ if (channel.receive(result) == proxy) break
+ result.clear()
+ }
+ result.flip()
+ remoteDns.udpUnwrap(result)
+ result
+ }
+ }
+
+ fun shutdown(scope: CoroutineScope) {
+ cancel()
+ monitor.close(scope)
+ coroutineContext[Job]!!.also { job -> scope.launch { job.join() } }
+ }
+}
diff --git a/client/android/src/com/github/shadowsocks/net/LocalSocketListener.kt b/client/android/src/com/github/shadowsocks/net/LocalSocketListener.kt
new file mode 100644
index 000000000..8ac45f269
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/net/LocalSocketListener.kt
@@ -0,0 +1,80 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2017 by Max Lv *
+ * Copyright (C) 2017 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.net
+
+import android.net.LocalServerSocket
+import android.net.LocalSocket
+import android.net.LocalSocketAddress
+import android.system.ErrnoException
+import android.system.Os
+import android.system.OsConstants
+import com.github.shadowsocks.utils.printLog
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.sendBlocking
+import kotlinx.coroutines.launch
+import java.io.File
+import java.io.IOException
+
+abstract class LocalSocketListener(name: String, socketFile: File) : Thread(name) {
+ private val localSocket = LocalSocket().apply {
+ socketFile.delete() // It's a must-have to close and reuse previous local socket.
+ bind(LocalSocketAddress(socketFile.absolutePath, LocalSocketAddress.Namespace.FILESYSTEM))
+ }
+ private val serverSocket = LocalServerSocket(localSocket.fileDescriptor)
+ private val closeChannel = Channel(1)
+ @Volatile
+ protected var running = true
+
+ /**
+ * Inherited class do not need to close input/output streams as they will be closed automatically.
+ */
+ protected open fun accept(socket: LocalSocket) = socket.use { acceptInternal(socket) }
+
+ protected abstract fun acceptInternal(socket: LocalSocket)
+ final override fun run() {
+ localSocket.use {
+ while (running) {
+ try {
+ accept(serverSocket.accept())
+ } catch (e: IOException) {
+ if (running) printLog(e)
+ continue
+ }
+ }
+ }
+ closeChannel.sendBlocking(Unit)
+ }
+
+ open fun shutdown(scope: CoroutineScope) {
+ running = false
+ localSocket.fileDescriptor?.apply {
+ // see also: https://issuetracker.google.com/issues/36945762#comment15
+ if (valid()) try {
+ Os.shutdown(this, OsConstants.SHUT_RDWR)
+ } catch (e: ErrnoException) {
+ // suppress fd inactive or already closed
+ if (e.errno != OsConstants.EBADF && e.errno != OsConstants.ENOTCONN) throw IOException(e)
+ }
+ }
+ scope.launch { closeChannel.receive() }
+ }
+}
diff --git a/client/android/src/com/github/shadowsocks/net/Socks5Endpoint.kt b/client/android/src/com/github/shadowsocks/net/Socks5Endpoint.kt
new file mode 100644
index 000000000..f3ac56834
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/net/Socks5Endpoint.kt
@@ -0,0 +1,132 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2019 by Max Lv *
+ * Copyright (C) 2019 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.net
+
+import com.github.shadowsocks.utils.parseNumericAddress
+import net.sourceforge.jsocks.Socks4Message
+import net.sourceforge.jsocks.Socks5Message
+import java.io.EOFException
+import java.io.IOException
+import java.net.Inet4Address
+import java.net.Inet6Address
+import java.nio.ByteBuffer
+import kotlin.math.max
+
+class Socks5Endpoint(host: String, port: Int) {
+ private val dest = host.parseNumericAddress().let { numeric ->
+ val bytes = numeric?.address
+ ?: host.toByteArray().apply { check(size < 256) { "Hostname too long" } }
+ val type = when (numeric) {
+ null -> Socks5Message.SOCKS_ATYP_DOMAINNAME
+ is Inet4Address -> Socks5Message.SOCKS_ATYP_IPV4
+ is Inet6Address -> Socks5Message.SOCKS_ATYP_IPV6
+ else -> error("Unsupported address type $numeric")
+ }
+ ByteBuffer.allocate(bytes.size + (if (numeric == null) 1 else 0) + 3).apply {
+ put(type.toByte())
+ if (numeric == null) put(bytes.size.toByte())
+ put(bytes)
+ putShort(port.toShort())
+ }
+ }.array()
+ private val headerReserved = max(3 + 3 + 16, 3 + dest.size)
+
+ fun tcpWrap(message: ByteBuffer): ByteBuffer {
+ check(message.remaining() < 65536) { "TCP message too large" }
+ return ByteBuffer.allocateDirect(8 + dest.size + message.remaining()).apply {
+ put(Socks5Message.SOCKS_VERSION.toByte())
+ put(1) // nmethods
+ put(0) // no authentication required
+ // header
+ put(Socks5Message.SOCKS_VERSION.toByte())
+ put(Socks4Message.REQUEST_CONNECT.toByte())
+ put(0) // reserved
+ put(dest)
+ // data
+ putShort(message.remaining().toShort())
+ put(message)
+ flip()
+ }
+ }
+
+ fun tcpReceiveBuffer(size: Int) = ByteBuffer.allocateDirect(headerReserved + 4 + size)
+ suspend fun tcpUnwrap(buffer: ByteBuffer, reader: (ByteBuffer) -> Int, wait: suspend () -> Unit) {
+ suspend fun readBytes(till: Int) {
+ if (buffer.position() >= till) return
+ while (reader(buffer) >= 0 && buffer.position() < till) wait()
+ if (buffer.position() < till) throw EOFException("${buffer.position()} < $till")
+ }
+
+ suspend fun read(index: Int): Byte {
+ readBytes(index + 1)
+ return buffer[index]
+ }
+ if (read(0) != Socks5Message.SOCKS_VERSION.toByte()) throw IOException("Unsupported SOCKS version ${buffer[0]}")
+ if (read(1) != 0.toByte()) throw IOException("Unsupported authentication ${buffer[1]}")
+ if (read(2) != Socks5Message.SOCKS_VERSION.toByte()) throw IOException("Unsupported SOCKS version ${buffer[2]}")
+ if (read(3) != 0.toByte()) throw IOException("SOCKS5 server returned error ${buffer[3]}")
+ val dataOffset = when (val type = read(5)) {
+ Socks5Message.SOCKS_ATYP_IPV4.toByte() -> 4
+ Socks5Message.SOCKS_ATYP_DOMAINNAME.toByte() -> 1 + read(6)
+ Socks5Message.SOCKS_ATYP_IPV6.toByte() -> 16
+ else -> throw IOException("Unsupported address type $type")
+ } + 8
+ readBytes(dataOffset + 2)
+ buffer.limit(buffer.position()) // store old position to update mark
+ buffer.position(dataOffset)
+ val dataLength = buffer.short.toUShort().toInt()
+ val end = buffer.position() + dataLength
+ if (end > buffer.capacity()) throw IOException("Buffer too small to contain the message: $dataLength > ${buffer.capacity() - buffer.position()}")
+ buffer.mark()
+ buffer.position(buffer.limit()) // restore old position
+ buffer.limit(end)
+ readBytes(buffer.limit())
+ buffer.reset()
+ }
+
+ private fun ByteBuffer.tryPosition(newPosition: Int) {
+ if (limit() < newPosition) throw EOFException("${limit()} < $newPosition")
+ position(newPosition)
+ }
+
+ fun udpWrap(packet: ByteBuffer) =
+ ByteBuffer.allocateDirect(3 + dest.size + packet.remaining()).apply {
+ // header
+ putShort(0) // reserved
+ put(0) // fragment number
+ put(dest)
+ // data
+ put(packet)
+ flip()
+ }
+
+ fun udpReceiveBuffer(size: Int) = ByteBuffer.allocateDirect(headerReserved + size)
+ fun udpUnwrap(packet: ByteBuffer) {
+ packet.tryPosition(3)
+ packet.tryPosition(6 + when (val type = packet.get()) {
+ Socks5Message.SOCKS_ATYP_IPV4.toByte() -> 4
+ Socks5Message.SOCKS_ATYP_DOMAINNAME.toByte() -> 1 + packet.get()
+ Socks5Message.SOCKS_ATYP_IPV6.toByte() -> 16
+ else -> throw IOException("Unsupported address type $type")
+ })
+ packet.mark()
+ }
+}
diff --git a/client/android/src/com/github/shadowsocks/net/Subnet.kt b/client/android/src/com/github/shadowsocks/net/Subnet.kt
new file mode 100644
index 000000000..9cff92c6c
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/net/Subnet.kt
@@ -0,0 +1,85 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2017 by Max Lv *
+ * Copyright (C) 2017 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.net
+
+import com.github.shadowsocks.utils.parseNumericAddress
+import java.net.InetAddress
+import java.util.*
+
+class Subnet(val address: InetAddress, val prefixSize: Int) : Comparable {
+ companion object {
+ fun fromString(value: String): Subnet? {
+ @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
+ val parts = (value as java.lang.String).split("/", 2)
+ val addr = parts[0].parseNumericAddress() ?: return null
+ return if (parts.size == 2) try {
+ val prefixSize = parts[1].toInt()
+ if (prefixSize < 0 || prefixSize > addr.address.size shl 3) null else Subnet(addr, prefixSize)
+ } catch (_: NumberFormatException) {
+ null
+ } else Subnet(addr, addr.address.size shl 3)
+ }
+ }
+
+ private val addressLength get() = address.address.size shl 3
+
+ init {
+ require(prefixSize in 0..addressLength) { "prefixSize $prefixSize not in 0..$addressLength" }
+ }
+
+ fun matches(other: InetAddress): Boolean {
+ if (address.javaClass != other.javaClass) return false
+ // TODO optimize?
+ val a = address.address
+ val b = other.address
+ var i = 0
+ while (i * 8 < prefixSize && i * 8 + 8 <= prefixSize) {
+ if (a[i] != b[i]) return false
+ ++i
+ }
+ if (i * 8 == prefixSize) return true
+ val mask = 256 - (1 shl (i * 8 + 8 - prefixSize))
+ return (a[i].toInt() and mask) == (b[i].toInt() and mask)
+ }
+
+ override fun toString(): String =
+ if (prefixSize == addressLength) address.hostAddress else address.hostAddress + '/' + prefixSize
+
+ private fun Byte.unsigned() = toInt() and 0xFF
+ override fun compareTo(other: Subnet): Int {
+ val addrThis = address.address
+ val addrThat = other.address.address
+ var result = addrThis.size.compareTo(addrThat.size) // IPv4 address goes first
+ if (result != 0) return result
+ for ((x, y) in addrThis zip addrThat) {
+ result = x.unsigned().compareTo(y.unsigned()) // undo sign extension of signed byte
+ if (result != 0) return result
+ }
+ return prefixSize.compareTo(other.prefixSize)
+ }
+
+ override fun equals(other: Any?): Boolean {
+ val that = other as? Subnet
+ return address == that?.address && prefixSize == that.prefixSize
+ }
+
+ override fun hashCode(): Int = Objects.hash(address, prefixSize)
+}
diff --git a/client/android/src/com/github/shadowsocks/net/TcpFastOpen.kt b/client/android/src/com/github/shadowsocks/net/TcpFastOpen.kt
new file mode 100644
index 000000000..94aa753a5
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/net/TcpFastOpen.kt
@@ -0,0 +1,68 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2017 by Max Lv *
+ * Copyright (C) 2017 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.net
+
+import com.github.shadowsocks.utils.readableMessage
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeoutOrNull
+import java.io.File
+import java.io.IOException
+
+object TcpFastOpen {
+ private const val PATH = "/proc/sys/net/ipv4/tcp_fastopen"
+
+ /**
+ * Is kernel version >= 3.7.1.
+ */
+ val supported by lazy {
+ if (File(PATH).canRead()) return@lazy true
+ val match =
+ """^(\d+)\.(\d+)\.(\d+)""".toRegex().find(System.getProperty("os.version") ?: "")
+ if (match == null) false else when (match.groupValues[1].toInt()) {
+ in Int.MIN_VALUE..2 -> false
+ 3 -> when (match.groupValues[2].toInt()) {
+ in Int.MIN_VALUE..6 -> false
+ 7 -> match.groupValues[3].toInt() >= 1
+ else -> true
+ }
+ else -> true
+ }
+ }
+
+ val sendEnabled: Boolean
+ get() {
+ val file = File(PATH)
+ // File.readText doesn't work since this special file will return length 0
+ // on Android containers like Chrome OS, this file does not exist so we simply judge by the kernel version
+ return if (file.canRead()) file.bufferedReader().use { it.readText() }.trim().toInt() and 1 > 0 else supported
+ }
+
+ fun enable(): String? {
+ return try {
+ ProcessBuilder("su", "-c", "echo 3 > $PATH").redirectErrorStream(true).start()
+ .inputStream.bufferedReader().readText()
+ } catch (e: IOException) {
+ e.readableMessage
+ }
+ }
+
+ fun enableTimeout() = runBlocking { withTimeoutOrNull(1000) { enable() } }
+}
diff --git a/client/android/src/com/github/shadowsocks/preference/DataStore.kt b/client/android/src/com/github/shadowsocks/preference/DataStore.kt
new file mode 100644
index 000000000..3e7774768
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/preference/DataStore.kt
@@ -0,0 +1,90 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2017 by Max Lv *
+ * Copyright (C) 2017 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.preference
+
+import android.os.Binder
+import androidx.preference.PreferenceDataStore
+import com.github.shadowsocks.Core
+import com.github.shadowsocks.database.PrivateDatabase
+import com.github.shadowsocks.database.PublicDatabase
+import com.github.shadowsocks.net.TcpFastOpen
+import com.github.shadowsocks.utils.DirectBoot
+import com.github.shadowsocks.utils.Key
+import com.github.shadowsocks.utils.parsePort
+import java.net.InetSocketAddress
+
+object DataStore : OnPreferenceDataStoreChangeListener {
+ val publicStore = RoomPreferenceDataStore(PublicDatabase.kvPairDao)
+ // privateStore will only be used as temp storage for ProfileConfigFragment
+ val privateStore = RoomPreferenceDataStore(PrivateDatabase.kvPairDao)
+
+ init {
+ publicStore.registerChangeListener(this)
+ }
+
+ override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) {
+ when (key) {
+ Key.id -> if (directBootAware) DirectBoot.update()
+ }
+ }
+
+ // hopefully hashCode = mHandle doesn't change, currently this is true from KitKat to Nougat
+ private val userIndex by lazy { Binder.getCallingUserHandle().hashCode() }
+
+ private fun getLocalPort(key: String, default: Int): Int {
+ val value = publicStore.getInt(key)
+ return if (value != null) {
+ publicStore.putString(key, value.toString())
+ value
+ } else parsePort(publicStore.getString(key), default + userIndex)
+ }
+
+ var profileId: Long
+ get() = publicStore.getLong(Key.id) ?: 0
+ set(value) = publicStore.putLong(Key.id, value)
+ val canToggleLocked: Boolean get() = publicStore.getBoolean(Key.directBootAware) == true
+ val directBootAware: Boolean get() = Core.directBootSupported && canToggleLocked
+ val tcpFastOpen: Boolean get() = TcpFastOpen.sendEnabled && publicStore.getBoolean(Key.tfo, false)
+ val listenAddress get() = "127.0.0.1"
+ var portProxy: Int
+ get() = getLocalPort(Key.portProxy, 1080)
+ set(value) = publicStore.putString(Key.portProxy, value.toString())
+ val proxyAddress get() = InetSocketAddress("127.0.0.1", portProxy)
+ var portLocalDns: Int
+ get() = getLocalPort(Key.portLocalDns, 5450)
+ set(value) = publicStore.putString(Key.portLocalDns, value.toString())
+
+ /**
+ * Initialize settings that have complicated default values.
+ */
+ fun initGlobal() {
+ if (publicStore.getBoolean(Key.tfo) == null) publicStore.putBoolean(Key.tfo, tcpFastOpen)
+ if (publicStore.getString(Key.portProxy) == null) portProxy = portProxy
+ if (publicStore.getString(Key.portLocalDns) == null) portLocalDns = portLocalDns
+ }
+
+ var editingId: Long?
+ get() = privateStore.getLong(Key.id)
+ set(value) = privateStore.putLong(Key.id, value)
+ var dirty: Boolean
+ get() = privateStore.getBoolean(Key.dirty) ?: false
+ set(value) = privateStore.putBoolean(Key.dirty, value)
+}
diff --git a/client/android/src/com/github/shadowsocks/preference/EditTextPreferenceModifiers.kt b/client/android/src/com/github/shadowsocks/preference/EditTextPreferenceModifiers.kt
new file mode 100644
index 000000000..1385c77ed
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/preference/EditTextPreferenceModifiers.kt
@@ -0,0 +1,46 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2019 by Max Lv *
+ * Copyright (C) 2019 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.preference
+
+import android.graphics.Typeface
+import android.text.InputFilter
+import android.view.inputmethod.EditorInfo
+import android.widget.EditText
+import androidx.preference.EditTextPreference
+
+object EditTextPreferenceModifiers {
+ object Monospace : EditTextPreference.OnBindEditTextListener {
+ override fun onBindEditText(editText: EditText) {
+ editText.typeface = Typeface.MONOSPACE
+ }
+ }
+
+ object Port : EditTextPreference.OnBindEditTextListener {
+ private val portLengthFilter = arrayOf(InputFilter.LengthFilter(5))
+
+ override fun onBindEditText(editText: EditText) {
+ editText.inputType = EditorInfo.TYPE_CLASS_NUMBER
+ editText.filters = portLengthFilter
+ editText.setSingleLine()
+ editText.setSelection(editText.text.length)
+ }
+ }
+}
diff --git a/client/android/src/com/github/shadowsocks/preference/OnPreferenceDataStoreChangeListener.kt b/client/android/src/com/github/shadowsocks/preference/OnPreferenceDataStoreChangeListener.kt
new file mode 100644
index 000000000..e18484b33
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/preference/OnPreferenceDataStoreChangeListener.kt
@@ -0,0 +1,27 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2017 by Max Lv *
+ * Copyright (C) 2017 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.preference
+
+import androidx.preference.PreferenceDataStore
+
+interface OnPreferenceDataStoreChangeListener {
+ fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String)
+}
diff --git a/client/android/src/com/github/shadowsocks/preference/RoomPreferenceDataStore.kt b/client/android/src/com/github/shadowsocks/preference/RoomPreferenceDataStore.kt
new file mode 100644
index 000000000..4afbafbb1
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/preference/RoomPreferenceDataStore.kt
@@ -0,0 +1,100 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2017 by Max Lv *
+ * Copyright (C) 2017 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.preference
+
+import androidx.preference.PreferenceDataStore
+import com.github.shadowsocks.database.KeyValuePair
+import java.util.*
+
+@Suppress("MemberVisibilityCanBePrivate", "unused")
+open class RoomPreferenceDataStore(private val kvPairDao: KeyValuePair.Dao) :
+ PreferenceDataStore() {
+ fun getBoolean(key: String) = kvPairDao[key]?.boolean
+ fun getFloat(key: String) = kvPairDao[key]?.float
+ fun getInt(key: String) = kvPairDao[key]?.long?.toInt()
+ fun getLong(key: String) = kvPairDao[key]?.long
+ fun getString(key: String) = kvPairDao[key]?.string
+ fun getStringSet(key: String) = kvPairDao[key]?.stringSet
+
+ override fun getBoolean(key: String, defValue: Boolean) = getBoolean(key) ?: defValue
+ override fun getFloat(key: String, defValue: Float) = getFloat(key) ?: defValue
+ override fun getInt(key: String, defValue: Int) = getInt(key) ?: defValue
+ override fun getLong(key: String, defValue: Long) = getLong(key) ?: defValue
+ override fun getString(key: String, defValue: String?) = getString(key) ?: defValue
+ override fun getStringSet(key: String, defValue: MutableSet?) =
+ getStringSet(key) ?: defValue
+
+ fun putBoolean(key: String, value: Boolean?) =
+ if (value == null) remove(key) else putBoolean(key, value)
+
+ fun putFloat(key: String, value: Float?) =
+ if (value == null) remove(key) else putFloat(key, value)
+
+ fun putInt(key: String, value: Int?) =
+ if (value == null) remove(key) else putLong(key, value.toLong())
+
+ fun putLong(key: String, value: Long?) = if (value == null) remove(key) else putLong(key, value)
+ override fun putBoolean(key: String, value: Boolean) {
+ kvPairDao.put(KeyValuePair(key).put(value))
+ fireChangeListener(key)
+ }
+
+ override fun putFloat(key: String, value: Float) {
+ kvPairDao.put(KeyValuePair(key).put(value))
+ fireChangeListener(key)
+ }
+
+ override fun putInt(key: String, value: Int) {
+ kvPairDao.put(KeyValuePair(key).put(value.toLong()))
+ fireChangeListener(key)
+ }
+
+ override fun putLong(key: String, value: Long) {
+ kvPairDao.put(KeyValuePair(key).put(value))
+ fireChangeListener(key)
+ }
+
+ override fun putString(key: String, value: String?) = if (value == null) remove(key) else {
+ kvPairDao.put(KeyValuePair(key).put(value))
+ fireChangeListener(key)
+ }
+
+ override fun putStringSet(key: String, values: MutableSet?) =
+ if (values == null) remove(key) else {
+ kvPairDao.put(KeyValuePair(key).put(values))
+ fireChangeListener(key)
+ }
+
+ fun remove(key: String) {
+ kvPairDao.delete(key)
+ fireChangeListener(key)
+ }
+
+ private val listeners = HashSet()
+ private fun fireChangeListener(key: String) =
+ listeners.forEach { it.onPreferenceDataStoreChanged(this, key) }
+
+ fun registerChangeListener(listener: OnPreferenceDataStoreChangeListener) =
+ listeners.add(listener)
+
+ fun unregisterChangeListener(listener: OnPreferenceDataStoreChangeListener) =
+ listeners.remove(listener)
+}
diff --git a/client/android/src/com/github/shadowsocks/utils/ArrayIterator.kt b/client/android/src/com/github/shadowsocks/utils/ArrayIterator.kt
new file mode 100644
index 000000000..6b789bf54
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/utils/ArrayIterator.kt
@@ -0,0 +1,46 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2018 by Max Lv *
+ * Copyright (C) 2018 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.utils
+
+import android.content.ClipData
+import androidx.recyclerview.widget.SortedList
+
+private sealed class ArrayIterator : Iterator {
+ abstract val size: Int
+ abstract operator fun get(index: Int): T
+ private var count = 0
+ override fun hasNext() = count < size
+ override fun next(): T = if (hasNext()) this[count++] else throw NoSuchElementException()
+}
+
+private class ClipDataIterator(private val data: ClipData) : ArrayIterator() {
+ override val size get() = data.itemCount
+ override fun get(index: Int) = data.getItemAt(index)
+}
+
+fun ClipData.asIterable() = Iterable { ClipDataIterator(this) }
+
+private class SortedListIterator(private val list: SortedList) : ArrayIterator() {
+ override val size get() = list.size()
+ override fun get(index: Int) = list[index]
+}
+
+fun SortedList.asIterable() = Iterable { SortedListIterator(this) }
diff --git a/client/android/src/com/github/shadowsocks/utils/Constants.kt b/client/android/src/com/github/shadowsocks/utils/Constants.kt
new file mode 100644
index 000000000..87328ff82
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/utils/Constants.kt
@@ -0,0 +1,60 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2017 by Max Lv *
+ * Copyright (C) 2017 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.utils
+
+object Key {
+ /**
+ * Public config that doesn't need to be kept secret.
+ */
+ const val DB_PUBLIC = "config.db"
+ const val DB_PROFILE = "profile.db"
+
+ const val id = "profileId"
+ const val name = "profileName"
+
+ const val portProxy = "portProxy"
+ const val portLocalDns = "portLocalDns"
+
+ const val directBootAware = "directBootAware"
+
+ const val udpdns = "isUdpDns"
+ const val ipv6 = "isIpv6"
+
+ const val host = "proxy"
+ const val password = "sitekey"
+ const val method = "encMethod"
+ const val remotePort = "remotePortNum"
+ const val remoteDns = "remoteDns"
+
+ const val dirty = "profileDirty"
+
+ const val tfo = "tcp_fastopen"
+ const val hosts = "hosts"
+ const val assetUpdateTime = "assetUpdateTime"
+}
+
+object Action {
+ const val SERVICE = "com.github.shadowsocks.SERVICE"
+ const val CLOSE = "com.github.shadowsocks.CLOSE"
+ const val RELOAD = "com.github.shadowsocks.RELOAD"
+
+ const val EXTRA_PROFILE_ID = "com.github.shadowsocks.EXTRA_PROFILE_ID"
+}
diff --git a/client/android/src/com/github/shadowsocks/utils/DeviceStorageApp.kt b/client/android/src/com/github/shadowsocks/utils/DeviceStorageApp.kt
new file mode 100644
index 000000000..9dd73e481
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/utils/DeviceStorageApp.kt
@@ -0,0 +1,40 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2017 by Max Lv *
+ * Copyright (C) 2017 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.utils
+
+import android.annotation.SuppressLint
+import android.annotation.TargetApi
+import android.app.Application
+import android.content.Context
+
+@SuppressLint("Registered")
+@TargetApi(24)
+class DeviceStorageApp(context: Context) : Application() {
+ init {
+ attachBaseContext(context.createDeviceProtectedStorageContext())
+ }
+
+ /**
+ * Thou shalt not get the REAL underlying application context which would no longer be operating under device
+ * protected storage.
+ */
+ override fun getApplicationContext() = this
+}
diff --git a/client/android/src/com/github/shadowsocks/utils/DirectBoot.kt b/client/android/src/com/github/shadowsocks/utils/DirectBoot.kt
new file mode 100644
index 000000000..fda29f45c
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/utils/DirectBoot.kt
@@ -0,0 +1,64 @@
+package com.github.shadowsocks.utils
+
+import android.annotation.TargetApi
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import com.github.shadowsocks.Core
+import com.github.shadowsocks.Core.app
+import com.github.shadowsocks.bg.BaseService
+import com.github.shadowsocks.database.Profile
+import com.github.shadowsocks.database.ProfileManager
+import com.github.shadowsocks.preference.DataStore
+import java.io.File
+import java.io.IOException
+import java.io.ObjectInputStream
+import java.io.ObjectOutputStream
+
+@TargetApi(24)
+object DirectBoot : BroadcastReceiver() {
+ private val file = File(Core.deviceStorage.noBackupFilesDir, "directBootProfile")
+ private var registered = false
+
+ fun getDeviceProfile(): Pair? = try {
+ ObjectInputStream(file.inputStream()).use { it.readObject() as? Pair }
+ } catch (_: IOException) {
+ null
+ }
+
+ fun clean() {
+ file.delete()
+ File(Core.deviceStorage.noBackupFilesDir, BaseService.CONFIG_FILE).delete()
+ File(Core.deviceStorage.noBackupFilesDir, BaseService.CONFIG_FILE_UDP).delete()
+ }
+
+ /**
+ * app.currentProfile will call this.
+ */
+ fun update(profile: Profile? = ProfileManager.getProfile(DataStore.profileId)) =
+ if (profile == null) clean()
+ else ObjectOutputStream(file.outputStream()).use {
+ it.writeObject(ProfileManager.expand(profile))
+ }
+
+ fun flushTrafficStats() {
+ getDeviceProfile()?.also { (profile, fallback) ->
+ if (profile.dirty) ProfileManager.updateProfile(profile)
+ if (fallback?.dirty == true) ProfileManager.updateProfile(fallback)
+ }
+ update()
+ }
+
+ fun listenForUnlock() {
+ if (registered) return
+ app.registerReceiver(this, IntentFilter(Intent.ACTION_BOOT_COMPLETED))
+ registered = true
+ }
+
+ override fun onReceive(context: Context, intent: Intent) {
+ flushTrafficStats()
+ app.unregisterReceiver(this)
+ registered = false
+ }
+}
diff --git a/client/android/src/com/github/shadowsocks/utils/Utils.kt b/client/android/src/com/github/shadowsocks/utils/Utils.kt
new file mode 100644
index 000000000..a465f6d44
--- /dev/null
+++ b/client/android/src/com/github/shadowsocks/utils/Utils.kt
@@ -0,0 +1,133 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2018 by Max Lv *
+ * Copyright (C) 2018 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package com.github.shadowsocks.utils
+
+import android.annotation.SuppressLint
+import android.content.BroadcastReceiver
+import android.content.ContentResolver
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageInfo
+import android.content.res.Resources
+import android.graphics.BitmapFactory
+import android.graphics.ImageDecoder
+import android.net.Uri
+import android.os.Build
+import android.system.Os
+import android.system.OsConstants
+import android.util.TypedValue
+import androidx.annotation.AttrRes
+import androidx.preference.Preference
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
+import java.net.HttpURLConnection
+import java.net.InetAddress
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+
+fun Iterable.forEachTry(action: (T) -> Unit) {
+ var result: Exception? = null
+ for (element in this) try {
+ action(element)
+ } catch (e: Exception) {
+ if (result == null) result = e else result.addSuppressed(e)
+ }
+ if (result != null) {
+ result.printStackTrace()
+ throw result
+ }
+}
+
+val Throwable.readableMessage get() = localizedMessage ?: javaClass.name
+
+private val parseNumericAddress by lazy @SuppressLint("DiscouragedPrivateApi") {
+ InetAddress::class.java.getDeclaredMethod("parseNumericAddress", String::class.java).apply {
+ isAccessible = true
+ }
+}
+
+/**
+ * A slightly more performant variant of parseNumericAddress.
+ *
+ * Bug in Android 9.0 and lower: https://issuetracker.google.com/issues/123456213
+ */
+fun String?.parseNumericAddress(): InetAddress? =
+ Os.inet_pton(OsConstants.AF_INET, this) ?: Os.inet_pton(OsConstants.AF_INET6, this)?.let {
+ if (Build.VERSION.SDK_INT >= 29) it else parseNumericAddress.invoke(null,
+ this) as InetAddress
+ }
+
+fun MutableMap.computeIfAbsentCompat(key: K, value: () -> V) =
+ if (Build.VERSION.SDK_INT >= 24) computeIfAbsent(key) { value() } else this[key]
+ ?: value().also { put(key, it) }
+
+suspend fun HttpURLConnection.useCancellable(block: suspend HttpURLConnection.() -> T): T {
+ return suspendCancellableCoroutine { cont ->
+ cont.invokeOnCancellation {
+ if (Build.VERSION.SDK_INT >= 26) disconnect() else GlobalScope.launch(Dispatchers.IO) { disconnect() }
+ }
+ GlobalScope.launch(Dispatchers.IO) {
+ try {
+ cont.resume(block())
+ } catch (e: Throwable) {
+ cont.resumeWithException(e)
+ }
+ }
+ }
+}
+
+fun parsePort(str: String?, default: Int, min: Int = 1025): Int {
+ val value = str?.toIntOrNull() ?: default
+ return if (value < min || value > 65535) default else value
+}
+
+fun broadcastReceiver(callback: (Context, Intent) -> Unit): BroadcastReceiver =
+ object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) = callback(context, intent)
+ }
+
+fun ContentResolver.openBitmap(uri: Uri) =
+ if (Build.VERSION.SDK_INT >= 28) ImageDecoder.decodeBitmap(ImageDecoder.createSource(this, uri))
+ else BitmapFactory.decodeStream(openInputStream(uri))
+
+val PackageInfo.signaturesCompat
+ get() = if (Build.VERSION.SDK_INT >= 28) signingInfo.apkContentsSigners else @Suppress("DEPRECATION") signatures
+
+/**
+ * Based on: https://stackoverflow.com/a/26348729/2245107
+ */
+fun Resources.Theme.resolveResourceId(@AttrRes resId: Int): Int {
+ val typedValue = TypedValue()
+ if (!resolveAttribute(resId, typedValue, true)) throw Resources.NotFoundException()
+ return typedValue.resourceId
+}
+
+val Intent.datas
+ get() = listOfNotNull(data) + (clipData?.asIterable()?.mapNotNull { it.uri } ?: emptyList())
+
+fun printLog(t: Throwable) {
+ // Crashlytics.logException(t)
+ t.printStackTrace()
+}
+
+fun Preference.remove() = parent!!.removePreference(this)