Commit 867f680b authored by Mygod's avatar Mygod

Implement UDP fallback

When a profile is selected as UDP fallback, only its server settings are respected. Tested with core.androidTest and locally with QUIC in YouTube app with plugin enabled.

Other notable changes:

* `IShadowsocksServiceCallback.trafficUpdated` now gets an object/data class for 4 stats. When profileId = 0, stats object represents the accumulated stats for all profiles.
* Refactor `TrafficMonitor`.
* Importing/exporting via JSON using a new field `udp_fallback`.
* PrivateDatabase is updated to version 27 to accommodate the new field.
parent f799649f
......@@ -40,6 +40,10 @@ android {
path 'src/main/jni/Android.mk'
}
}
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
}
}
task goBuild(type: Exec) {
......@@ -83,9 +87,10 @@ dependencies {
kapt "androidx.lifecycle:lifecycle-compiler:$lifecycleVersion"
kapt "androidx.room:room-compiler:$roomVersion"
testImplementation "androidx.arch.core:core-testing:$lifecycleVersion"
testImplementation "androidx.room:room-testing:$roomVersion"
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "android.arch.work:work-testing:$workVersion"
androidTestImplementation "androidx.room:room-testing:$roomVersion"
androidTestImplementation "androidx.test:runner:$androidTestVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidEspressoVersion"
androidTestImplementation "androidx.test.ext:junit-ktx:1.1.0"
}
{
"formatVersion": 1,
"database": {
"version": 27,
"identityHash": "8743c2e56bdbdabca7fcb89dff5434ba",
"entities": [
{
"tableName": "Profile",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`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, `route` TEXT NOT NULL, `remoteDns` TEXT NOT NULL, `proxyApps` INTEGER NOT NULL, `bypass` INTEGER NOT NULL, `udpdns` INTEGER NOT NULL, `ipv6` INTEGER NOT NULL, `individual` TEXT NOT NULL, `tx` INTEGER NOT NULL, `rx` INTEGER NOT NULL, `userOrder` INTEGER NOT NULL, `plugin` TEXT, `udpFallback` INTEGER)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "host",
"columnName": "host",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "remotePort",
"columnName": "remotePort",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "password",
"columnName": "password",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "method",
"columnName": "method",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "route",
"columnName": "route",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "remoteDns",
"columnName": "remoteDns",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "proxyApps",
"columnName": "proxyApps",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bypass",
"columnName": "bypass",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "udpdns",
"columnName": "udpdns",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "ipv6",
"columnName": "ipv6",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "individual",
"columnName": "individual",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tx",
"columnName": "tx",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "rx",
"columnName": "rx",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userOrder",
"columnName": "userOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "plugin",
"columnName": "plugin",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "udpFallback",
"columnName": "udpFallback",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "KeyValuePair",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `valueType` INTEGER NOT NULL, `value` BLOB NOT NULL, PRIMARY KEY(`key`))",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "valueType",
"columnName": "valueType",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "BLOB",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"key"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"8743c2e56bdbdabca7fcb89dff5434ba\")"
]
}
}
\ No newline at end of file
/*******************************************************************************
* *
* Copyright (C) 2017 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2017 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* Copyright (C) 2019 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2019 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* 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 *
......@@ -18,27 +18,32 @@
* *
*******************************************************************************/
package com.github.shadowsocks.bg
package com.github.shadowsocks.database
import android.net.LocalSocket
import com.github.shadowsocks.Core
import com.github.shadowsocks.utils.printLog
import java.io.File
import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
class TrafficMonitorThread : LocalSocketListener("TrafficMonitorThread") {
override val socketFile = File(Core.deviceStorage.noBackupFilesDir, "stat_path")
@RunWith(AndroidJUnit4::class)
class MigrationTest {
companion object {
private const val TEST_DB = "migration-test"
}
@get:Rule
val privateDatabase = MigrationTestHelper(InstrumentationRegistry.getInstrumentation(),
PrivateDatabase::class.java.canonicalName, FrameworkSQLiteOpenHelperFactory())
override fun accept(socket: LocalSocket) {
try {
val buffer = ByteArray(16)
if (socket.inputStream.read(buffer) != 16) throw IOException("Unexpected traffic stat length")
val stat = ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN)
TrafficMonitor.update(stat.getLong(0), stat.getLong(8))
} catch (e: IOException) {
printLog(e)
}
@Test
@Throws(IOException::class)
fun migrate27() {
val db = privateDatabase.createDatabase(TEST_DB, 26)
db.close()
privateDatabase.runMigrationsAndValidate(TEST_DB, 27, true, PrivateDatabase.Migration27)
}
}
package com.github.shadowsocks.aidl;
import com.github.shadowsocks.aidl.TrafficStats;
interface IShadowsocksServiceCallback {
oneway void stateChanged(int state, String profileName, String msg);
oneway void trafficUpdated(long profileId, long txRate, long rxRate, long txTotal, long rxTotal);
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);
}
package com.github.shadowsocks.aidl;
parcelable TrafficStats;
......@@ -69,8 +69,13 @@ object Core {
DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE_PER_USER
}
val currentProfile: Profile? get() =
if (DataStore.directBootAware) DirectBoot.getDeviceProfile() else ProfileManager.getProfile(DataStore.profileId)
val activeProfileIds get() = ProfileManager.getProfile(DataStore.profileId).let {
if (it == null) emptyList() else listOfNotNull(it.id, it.udpFallback)
}
val currentProfile: Pair<Profile, Profile?>? 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()
......
/*******************************************************************************
* *
* Copyright (C) 2019 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2019 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
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<TrafficStats> {
override fun createFromParcel(parcel: Parcel) = TrafficStats(parcel)
override fun newArray(size: Int): Array<TrafficStats?> = arrayOfNulls(size)
}
}
......@@ -25,36 +25,23 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.*
import android.util.Base64
import android.util.Log
import androidx.core.content.getSystemService
import androidx.core.os.bundleOf
import com.crashlytics.android.Crashlytics
import com.github.shadowsocks.Core
import com.github.shadowsocks.Core.app
import com.github.shadowsocks.acl.Acl
import com.github.shadowsocks.acl.AclSyncer
import com.github.shadowsocks.aidl.IShadowsocksService
import com.github.shadowsocks.aidl.IShadowsocksServiceCallback
import com.github.shadowsocks.aidl.TrafficStats
import com.github.shadowsocks.core.R
import com.github.shadowsocks.database.Profile
import com.github.shadowsocks.database.ProfileManager
import com.github.shadowsocks.plugin.PluginConfiguration
import com.github.shadowsocks.plugin.PluginManager
import com.github.shadowsocks.plugin.PluginOptions
import com.github.shadowsocks.preference.DataStore
import com.github.shadowsocks.utils.*
import com.google.firebase.analytics.FirebaseAnalytics
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.IOException
import java.net.InetAddress
import java.net.UnknownHostException
import java.security.MessageDigest
import java.util.*
import java.util.concurrent.TimeUnit
/**
* This object uses WeakMap to simulate the effects of multi-inheritance.
......@@ -70,15 +57,13 @@ object BaseService {
const val STOPPED = 4
const val CONFIG_FILE = "shadowsocks.conf"
const val CONFIG_FILE_UDP = "shadowsocks-udp.conf"
class Data internal constructor(private val service: Interface) {
@Volatile var profile: Profile? = null
@Volatile var state = STOPPED
@Volatile var plugin = PluginOptions()
@Volatile var pluginPath: String? = null
val processes = GuardedProcessPool()
var trafficMonitorThread: TrafficMonitorThread? = null
@Volatile var proxy: ProxyInstance? = null
@Volatile var udpFallback: ProxyInstance? = null
val callbacks = RemoteCallbackList<IShadowsocksServiceCallback>()
val bandwidthListeners = HashSet<IBinder>() // the binder is the real identifier
......@@ -94,7 +79,7 @@ object BaseService {
val binder = object : IShadowsocksService.Stub() {
override fun getState(): Int = this@Data.state
override fun getProfileName(): String = profile?.name ?: "Idle"
override fun getProfileName(): String = proxy?.profile?.name ?: "Idle"
override fun registerCallback(cb: IShadowsocksServiceCallback) {
callbacks.register(cb)
......@@ -103,18 +88,20 @@ object BaseService {
private fun registerTimeout() =
Core.handler.postAtTime(this::onTimeout, this, SystemClock.uptimeMillis() + 1000)
private fun onTimeout() {
val profile = profile
if (profile != null && state == CONNECTED && TrafficMonitor.updateRate() &&
bandwidthListeners.isNotEmpty()) {
val txRate = TrafficMonitor.txRate
val rxRate = TrafficMonitor.rxRate
val txTotal = TrafficMonitor.txTotal
val rxTotal = TrafficMonitor.rxTotal
val proxies = listOfNotNull(proxy, udpFallback)
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 } && state == CONNECTED && bandwidthListeners.isNotEmpty()) {
val sum = stats.fold(TrafficStats()) { a, b -> a + b.second }
val n = callbacks.beginBroadcast()
for (i in 0 until n) try {
val item = callbacks.getBroadcastItem(i)
if (bandwidthListeners.contains(item.asBinder()))
item.trafficUpdated(profile.id, txRate, rxRate, txTotal, rxTotal)
if (bandwidthListeners.contains(item.asBinder())) {
stats.forEach { (id, stats) -> item.trafficUpdated(id, stats) }
item.trafficUpdated(0, sum)
}
} catch (e: Exception) {
printLog(e)
}
......@@ -127,10 +114,24 @@ object BaseService {
val wasEmpty = bandwidthListeners.isEmpty()
if (bandwidthListeners.add(cb.asBinder())) {
if (wasEmpty) registerTimeout()
TrafficMonitor.updateRate()
if (state == CONNECTED) cb.trafficUpdated(profile!!.id,
TrafficMonitor.txRate, TrafficMonitor.rxRate,
TrafficMonitor.txTotal, TrafficMonitor.rxTotal)
if (state != CONNECTED) return
var sum = TrafficStats()
val proxy = proxy ?: return
proxy.trafficMonitor?.out.also { stats ->
cb.trafficUpdated(proxy.profile.id, if (stats == null) sum else {
sum += stats
stats
})
}
udpFallback?.also { udpFallback ->
udpFallback.trafficMonitor?.out.also { stats ->
cb.trafficUpdated(udpFallback.profile.id, if (stats == null) TrafficStats() else {
sum += stats
stats
})
}
}
cb.trafficUpdated(0, sum)
}
}
......@@ -146,63 +147,6 @@ object BaseService {
}
}
internal fun updateTrafficTotal(tx: Long, rx: Long) {
try {
// this.profile may have host, etc. modified and thus a re-fetch is necessary (possible race condition)
val profile = ProfileManager.getProfile((profile ?: return).id) ?: return
profile.tx += tx
profile.rx += rx
ProfileManager.updateProfile(profile)
Core.handler.post {
if (bandwidthListeners.isNotEmpty()) {
val n = callbacks.beginBroadcast()
for (i in 0 until n) {
try {
val item = callbacks.getBroadcastItem(i)
if (bandwidthListeners.contains(item.asBinder())) item.trafficPersisted(profile.id)
} catch (e: Exception) {
printLog(e)
}
}
callbacks.finishBroadcast()
}
}
} catch (e: IOException) {
if (!DataStore.directBootAware) throw e // we should only reach here because we're in direct boot
val profile = DirectBoot.getDeviceProfile()!!
profile.tx += tx
profile.rx += rx
profile.dirty = true
DirectBoot.update(profile)
DirectBoot.listenForUnlock()
}
}
internal var shadowsocksConfigFile: File? = null
internal fun buildShadowsocksConfig(): File {
val profile = profile!!
val config = profile.toJson(true)
val pluginPath = pluginPath
if (pluginPath != null) {
val pluginCmd = arrayListOf(pluginPath)
if (DataStore.tcpFastOpen) pluginCmd.add("--fast-open")
config
.put("plugin", Commandline.toString(service.buildAdditionalArguments(pluginCmd)))
.put("plugin_opts", plugin.toString())
}
// sensitive Shadowsocks config is stored in
return File((if (Build.VERSION.SDK_INT < 24 || app.getSystemService<UserManager>()?.isUserUnlocked != false)
app else Core.deviceStorage).noBackupFilesDir, CONFIG_FILE).apply {
shadowsocksConfigFile = this
writeText(config.toString())
}
}
val aclFile: File? get() {
val route = profile!!.route
return if (route == Acl.ALL) null else Acl.getFile(route)
}
fun changeState(s: Int, msg: String? = null) {
if (state == s && msg == null) return
if (callbacks.registeredCallbackCount > 0) Core.handler.post {
......@@ -222,15 +166,14 @@ object BaseService {
fun onBind(intent: Intent): IBinder? = if (intent.action == Action.SERVICE) data.binder else null
fun checkProfile(profile: Profile): Boolean =
if (profile.host.isEmpty() || profile.password.isEmpty()) {
stopRunner(true, (this as Context).getString(R.string.proxy_empty))
false
} else true
fun forceLoad() {
val profile = Core.currentProfile
val (profile, fallback) = Core.currentProfile
?: return stopRunner(true, (this as Context).getString(R.string.profile_empty))
if (!checkProfile(profile)) return
if (profile.host.isEmpty() || profile.password.isEmpty() ||
fallback != null && (fallback.host.isEmpty() || fallback.password.isEmpty())) {
stopRunner(true, (this as Context).getString(R.string.proxy_empty))
return
}
val s = data.state
when (s) {
STOPPED -> startRunner()
......@@ -245,27 +188,18 @@ object BaseService {
fun buildAdditionalArguments(cmd: ArrayList<String>): ArrayList<String> = cmd
fun startNativeProcesses() {
val data = data
val profile = data.profile!!
val cmd = buildAdditionalArguments(arrayListOf(
File((this as Context).applicationInfo.nativeLibraryDir, Executable.SS_LOCAL).absolutePath,
"-u",
"-b", DataStore.listenAddress,
"-l", DataStore.portProxy.toString(),
"-t", "600",
"-c", data.buildShadowsocksConfig().absolutePath))
val acl = data.aclFile
if (acl != null) {
cmd += "--acl"
cmd += acl.absolutePath
}
if (profile.udpdns) cmd += "-D"
if (DataStore.tcpFastOpen) cmd += "--fast-open"
data.processes.start(cmd)
val configRoot = (if (Build.VERSION.SDK_INT < 24 || app.getSystemService<UserManager>()
?.isUserUnlocked != false) app else Core.deviceStorage).noBackupFilesDir
val udpFallback = data.udpFallback
data.proxy!!.start(this,
File(Core.deviceStorage.noBackupFilesDir, "stat_main"),
File(configRoot, CONFIG_FILE),
if (udpFallback == null) "-u" else null)
check(udpFallback?.pluginPath == null)
udpFallback?.start(this,
File(Core.deviceStorage.noBackupFilesDir, "stat_udp"),
File(configRoot, CONFIG_FILE_UDP),
"-U")
}
fun createNotification(profileName: String): ServiceNotification
......@@ -294,26 +228,34 @@ object BaseService {
data.closeReceiverRegistered = false
}
data.shadowsocksConfigFile?.delete() // remove old config possibly in device storage
data.shadowsocksConfigFile = null
data.notification?.destroy()
data.notification = null
// Make sure update total traffic when stopping the runner
data.updateTrafficTotal(TrafficMonitor.txTotal, TrafficMonitor.rxTotal)
TrafficMonitor.reset()
data.trafficMonitorThread?.stopThread()
data.trafficMonitorThread = null
val ids = listOfNotNull(data.proxy, data.udpFallback).map {
it.close()
it.profile.id
}
data.proxy = null
if (ids.isNotEmpty()) Core.handler.post {
if (data.bandwidthListeners.isNotEmpty()) {
val n = data.callbacks.beginBroadcast()
for (i in 0 until n) {
try {
val item = data.callbacks.getBroadcastItem(i)
if (data.bandwidthListeners.contains(item.asBinder())) ids.forEach(item::trafficPersisted)
} catch (e: Exception) {
printLog(e)
}
}
data.callbacks.finishBroadcast()
}
}
// change the state
data.changeState(STOPPED, msg)
// stop the service if nothing has bound to it
if (stopService) stopSelf()
data.profile = null
}
val data: Data get() = instances[this]!!
......@@ -321,21 +263,19 @@ object BaseService {
fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val data = data
if (data.state != STOPPED) return Service.START_NOT_STICKY
val profile = Core.currentProfile
val profilePair = Core.currentProfile
this as Context
if (profile == null) {
if (profilePair == null) {
// gracefully shutdown: https://stackoverflow.com/q/47337857/2245107
data.notification = createNotification("")
stopRunner(true, getString(R.string.profile_empty))
return Service.START_NOT_STICKY
}
val (profile, fallback) = profilePair
profile.name = profile.formattedName // save name for later queries
data.profile = profile
TrafficMonitor.reset()
val thread = TrafficMonitorThread()
thread.start()
data.trafficMonitorThread = thread
val proxy = ProxyInstance(profile)
data.proxy = proxy
if (fallback != null) data.udpFallback = ProxyInstance(fallback, profile.route)
if (!data.closeReceiverRegistered) {
// register close receiver
......@@ -354,55 +294,15 @@ object BaseService {
thread("$tag-Connecting") {
try {
if (profile.host == "198.199.101.152") {
val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
val mdg = MessageDigest.getInstance("SHA-1")
mdg.update(Core.packageInfo.signaturesCompat.first().toByteArray())
val requestBody = FormBody.Builder()
.add("sig", String(Base64.encode(mdg.digest(), 0)))
.build()
val request = Request.Builder()
.url(RemoteConfig.proxyUrl)
.post(requestBody)
.build()
val proxies = client.newCall(request).execute()
.body()!!.string().split('|').toMutableList()
proxies.shuffle()
val proxy = proxies.first().split(':')
profile.host = proxy[0].trim()
profile.remotePort = proxy[1].trim().toInt()
profile.password = proxy[2].trim()
profile.method = proxy[3].trim()
}
if (profile.route == Acl.CUSTOM_RULES)
Acl.save(Acl.CUSTOM_RULES, Acl.customRules.flatten(10))
data.plugin = PluginConfiguration(profile.plugin ?: "").selectedOptions
data.pluginPath = PluginManager.init(data.plugin)
proxy.init()
data.udpFallback?.init()
// Clean up
killProcesses()
// it's hard to resolve DNS on a specific interface so we'll do it here
if (!profile.host.isNumericAddress()) {
thread("BaseService-resolve") {
// A WAR fix for Huawei devices that UnknownHostException cannot be caught correctly
try {
profile.host = InetAddress.getByName(profile.host).hostAddress ?: ""
} catch (_: UnknownHostException) { }
}.join(10 * 1000)
if (!profile.host.isNumericAddress()) throw UnknownHostException()
}
startNativeProcesses()
if (profile.route !in arrayOf(Acl.ALL, Acl.CUSTOM_RULES)) AclSyncer.schedule(profile.route)
proxy.scheduleUpdate()
data.udpFallback?.scheduleUpdate()
RemoteConfig.fetch()
data.changeState(CONNECTED)
......
......@@ -35,7 +35,7 @@ object LocalDnsService {
override fun startNativeProcesses() {
super.startNativeProcesses()
val data = data
val profile = data.profile!!
val profile = data.proxy!!.profile
fun makeDns(name: String, address: String, timeout: Int, edns: Boolean = true) = JSONObject().apply {
put("Name", name)
......
/*******************************************************************************
* *
* Copyright (C) 2019 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2019 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
package com.github.shadowsocks.bg
import android.content.Context
import android.util.Base64
import com.github.shadowsocks.Core
import com.github.shadowsocks.acl.Acl
import com.github.shadowsocks.acl.AclSyncer
import com.github.shadowsocks.database.Profile
import com.github.shadowsocks.database.ProfileManager
import com.github.shadowsocks.plugin.PluginConfiguration
import com.github.shadowsocks.plugin.PluginManager
import com.github.shadowsocks.preference.DataStore
import com.github.shadowsocks.utils.*
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.IOException
import java.net.InetAddress
import java.net.UnknownHostException
import java.security.MessageDigest
import java.util.concurrent.TimeUnit
/**
* This class sets up environment for ss-local.
*/
class ProxyInstance(val profile: Profile, private val route: String = profile.route): AutoCloseable {
var configFile: File? = null
var trafficMonitor: TrafficMonitor? = null
private val plugin = PluginConfiguration(profile.plugin ?: "").selectedOptions
val pluginPath by lazy { PluginManager.init(plugin) }
fun init() {
if (profile.host == "198.199.101.152") {
val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
val mdg = MessageDigest.getInstance("SHA-1")
mdg.update(Core.packageInfo.signaturesCompat.first().toByteArray())
val requestBody = FormBody.Builder()
.add("sig", String(Base64.encode(mdg.digest(), 0)))
.build()
val request = Request.Builder()
.url(RemoteConfig.proxyUrl)
.post(requestBody)
.build()
val proxies = client.newCall(request).execute()
.body()!!.string().split('|').toMutableList()
proxies.shuffle()
val proxy = proxies.first().split(':')
profile.host = proxy[0].trim()
profile.remotePort = proxy[1].trim().toInt()
profile.password = proxy[2].trim()
profile.method = proxy[3].trim()
}
if (route == Acl.CUSTOM_RULES) Acl.save(Acl.CUSTOM_RULES, Acl.customRules.flatten(10))
// it's hard to resolve DNS on a specific interface so we'll do it here
if (!profile.host.isNumericAddress()) {
thread("ProxyInstance-resolve") {
// A WAR fix for Huawei devices that UnknownHostException cannot be caught correctly
try {
profile.host = InetAddress.getByName(profile.host).hostAddress ?: ""
} catch (_: UnknownHostException) { }
}.join(10 * 1000)
if (!profile.host.isNumericAddress()) 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()
if (pluginPath != null) {
val pluginCmd = arrayListOf(pluginPath!!)
if (DataStore.tcpFastOpen) pluginCmd.add("--fast-open")
config
.put("plugin", Commandline.toString(service.buildAdditionalArguments(pluginCmd)))
.put("plugin_opts", plugin.toString())
}
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)
if (route != Acl.ALL) {
cmd += "--acl"
cmd += Acl.getFile(route).absolutePath
}
// for UDP profile, it's only going to operate in UDP relay mode-only so this flag has no effect
if (profile.udpdns) cmd += "-D"
if (DataStore.tcpFastOpen) cmd += "--fast-open"
service.data.processes.start(cmd)
}
fun scheduleUpdate() {
if (route !in arrayOf(Acl.ALL, Acl.CUSTOM_RULES)) AclSyncer.schedule(route)
}
override fun close() {
trafficMonitor?.apply {
close()
// Make sure update total traffic when stopping the runner
try {
// profile may have host, etc. modified and thus a re-fetch is necessary (possible race condition)
val profile = ProfileManager.getProfile(profile.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 == profile.id }
profile.tx += current.txTotal
profile.rx += current.rxTotal
profile.dirty = true
DirectBoot.update(profile)
DirectBoot.listenForUnlock()
}
}
trafficMonitor = null
configFile?.delete() // remove old config possibly in device storage
configFile = null
}
}
......@@ -35,10 +35,10 @@ import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import com.github.shadowsocks.Core
import com.github.shadowsocks.aidl.IShadowsocksServiceCallback
import com.github.shadowsocks.aidl.TrafficStats
import com.github.shadowsocks.core.R
import com.github.shadowsocks.utils.Action
import com.github.shadowsocks.utils.broadcastReceiver
import java.util.*
/**
* Android < 8 VPN: always invisible because of VPN notification/icon
......@@ -53,13 +53,15 @@ class ServiceNotification(private val service: BaseService.Interface, profileNam
private val callback: IShadowsocksServiceCallback by lazy {
object : IShadowsocksServiceCallback.Stub() {
override fun stateChanged(state: Int, profileName: String?, msg: String?) { } // ignore
override fun trafficUpdated(profileId: Long, txRate: Long, rxRate: Long, txTotal: Long, rxTotal: Long) {
override fun trafficUpdated(profileId: Long, stats: TrafficStats) {
if (profileId != 0L) return
service as Context
val txr = service.getString(R.string.speed, Formatter.formatFileSize(service, txRate))
val rxr = service.getString(R.string.speed, Formatter.formatFileSize(service, rxRate))
val txr = service.getString(R.string.speed, Formatter.formatFileSize(service, stats.txRate))
val rxr = service.getString(R.string.speed, Formatter.formatFileSize(service, stats.rxRate))
builder.setContentText("$txr↑\t$rxr↓")
style.bigText(service.getString(R.string.stat_summary, txr, rxr,
Formatter.formatFileSize(service, txTotal), Formatter.formatFileSize(service, rxTotal)))
Formatter.formatFileSize(service, stats.txTotal),
Formatter.formatFileSize(service, stats.rxTotal)))
show()
}
override fun trafficPersisted(profileId: Long) { }
......
......@@ -20,68 +20,72 @@
package com.github.shadowsocks.bg
import android.net.LocalSocket
import android.os.SystemClock
import com.github.shadowsocks.aidl.TrafficStats
import com.github.shadowsocks.utils.printLog
import java.io.File
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
object TrafficMonitor {
// Bytes per second
var txRate = 0L
var rxRate = 0L
class TrafficMonitor(statFile: File) : AutoCloseable {
private val thread = object : LocalSocketListener("TrafficMonitor") {
override val socketFile = statFile
// Bytes for the current session
var txTotal = 0L
var rxTotal = 0L
override fun accept(socket: LocalSocket) {
try {
val buffer = ByteArray(16)
if (socket.inputStream.read(buffer) != 16) throw IOException("Unexpected traffic stat length")
val stat = ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN)
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
}
} catch (e: IOException) {
printLog(e)
}
}
}.apply { start() }
// Bytes for the last query
private var txLast = 0L
private var rxLast = 0L
val current = TrafficStats()
var out = TrafficStats()
private var timestampLast = 0L
@Volatile
private var dirty = true
private var dirty = false
fun updateRate(): Boolean {
fun requestUpdate(): Pair<TrafficStats, Boolean> {
val now = SystemClock.elapsedRealtime()
val delta = now - timestampLast
timestampLast = now
var updated = false
if (delta != 0L)
if (delta != 0L) {
if (dirty) {
txRate = (txTotal - txLast) * 1000 / delta
rxRate = (rxTotal - rxLast) * 1000 / delta
txLast = txTotal
rxLast = rxTotal
out = current.copy().apply {
txRate = (txTotal - out.txTotal) * 1000 / delta
rxRate = (rxTotal - out.rxTotal) * 1000 / delta
}
dirty = false
updated = true
} else {
if (txRate != 0L) {
txRate = 0
if (out.txRate != 0L) {
out.txRate = 0
updated = true
}
if (rxRate != 0L) {
rxRate = 0
if (out.rxRate != 0L) {
out.rxRate = 0
updated = true
}
}
return updated
}
fun update(tx: Long, rx: Long) {
if (txTotal != tx) {
txTotal = tx
dirty = true
}
if (rxTotal != rx) {
rxTotal = rx
dirty = true
}
return Pair(out, updated)
}
fun reset() {
txRate = 0
rxRate = 0
txTotal = 0
rxTotal = 0
txLast = 0
rxLast = 0
dirty = true
}
override fun close() = thread.stopThread()
}
......@@ -40,13 +40,14 @@ class TransproxyService : Service(), LocalDnsService.Interface {
super<LocalDnsService.Interface>.onStartCommand(intent, flags, startId)
private fun startDNSTunnel() {
val proxy = data.proxy!!
val cmd = arrayListOf(File(applicationInfo.nativeLibraryDir, Executable.SS_TUNNEL).absolutePath,
"-t", "10",
"-b", DataStore.listenAddress,
"-u",
"-l", DataStore.portLocalDns.toString(), // ss-tunnel listens on the same port as overture
"-L", data.profile!!.remoteDns.split(",").first().trim() + ":53",
"-c", data.shadowsocksConfigFile!!.absolutePath) // config is already built by BaseService.Interface
"-l", DataStore.portLocalDns.toString(), // ss-tunnel listens on the same port as overture
"-L", proxy.profile.remoteDns.split(",").first().trim() + ":53",
"-c", proxy.configFile!!.absolutePath) // config is already built by BaseService.Interface
if (DataStore.tcpFastOpen) cmd += "--fast-open"
data.processes.start(cmd)
}
......@@ -74,6 +75,6 @@ redsocks {
override fun startNativeProcesses() {
startRedsocksDaemon()
super.startNativeProcesses()
if (data.profile!!.udpdns) startDNSTunnel()
if (data.proxy!!.profile.udpdns) startDNSTunnel()
}
}
......@@ -186,7 +186,7 @@ class VpnService : BaseVpnService(), LocalDnsService.Interface {
}
private fun startVpn(): Int {
val profile = data.profile!!
val profile = data.proxy!!.profile
val builder = Builder()
.setConfigureIntent(Core.configureIntent(this))
.setSession(profile.formattedName)
......
......@@ -23,18 +23,20 @@ package com.github.shadowsocks.database
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.github.shadowsocks.Core.app
import com.github.shadowsocks.database.migration.RecreateSchemaMigration
import com.github.shadowsocks.utils.Key
@Database(entities = [Profile::class, KeyValuePair::class], version = 26)
@Database(entities = [Profile::class, KeyValuePair::class], version = 27)
abstract class PrivateDatabase : RoomDatabase() {
companion object {
private val instance by lazy {
Room.databaseBuilder(app, PrivateDatabase::class.java, Key.DB_PROFILE)
.addMigrations(
Migration26
Migration26,
Migration27
)
.fallbackToDestructiveMigration()
.allowMainThreadQueries()
......@@ -47,7 +49,7 @@ abstract class PrivateDatabase : RoomDatabase() {
abstract fun profileDao(): Profile.Dao
abstract fun keyValuePairDao(): KeyValuePair.Dao
private object Migration26 : RecreateSchemaMigration(25, 26, "Profile",
object Migration26 : RecreateSchemaMigration(25, 26, "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, `route` TEXT NOT NULL, `remoteDns` TEXT NOT NULL, `proxyApps` INTEGER NOT NULL, `bypass` INTEGER NOT NULL, `udpdns` INTEGER NOT NULL, `ipv6` INTEGER NOT NULL, `individual` TEXT NOT NULL, `tx` INTEGER NOT NULL, `rx` INTEGER NOT NULL, `userOrder` INTEGER NOT NULL, `plugin` TEXT)",
"`id`, `name`, `host`, `remotePort`, `password`, `method`, `route`, `remoteDns`, `proxyApps`, `bypass`, `udpdns`, `ipv6`, `individual`, `tx`, `rx`, `userOrder`, `plugin`") {
override fun migrate(database: SupportSQLiteDatabase) {
......@@ -55,4 +57,8 @@ abstract class PrivateDatabase : RoomDatabase() {
PublicDatabase.Migration3.migrate(database)
}
}
object Migration27 : Migration(26, 27) {
override fun migrate(database: SupportSQLiteDatabase) =
database.execSQL("ALTER TABLE `Profile` ADD COLUMN `udpFallback` INTEGER")
}
}
......@@ -102,16 +102,18 @@ class Profile : Serializable {
}.filterNotNull()
private class JsonParser(private val feature: Profile? = null) : ArrayList<Profile>() {
private fun tryAdd(json: JSONObject) {
private val fallbackMap = mutableMapOf<Profile, Profile>()
private fun tryParse(json: JSONObject, fallback: Boolean = false): Profile? {
val host = json.optString("server")
if (host.isNullOrEmpty()) return
if (host.isNullOrEmpty()) return null
val remotePort = json.optInt("server_port")
if (remotePort <= 0) return
if (remotePort <= 0) return null
val password = json.optString("password")
if (password.isNullOrEmpty()) return
if (password.isNullOrEmpty()) return null
val method = json.optString("method")
if (method.isNullOrEmpty()) return
add(Profile().also {
if (method.isNullOrEmpty()) return null
return Profile().also {
it.host = host
it.remotePort = remotePort
it.password = password
......@@ -124,6 +126,7 @@ class Profile : Serializable {
}
name = json.optString("remarks")
route = json.optString("route", route)
if (fallback) return@apply
remoteDns = json.optString("remote_dns", remoteDns)
ipv6 = json.optBoolean("ipv6", ipv6)
json.optJSONObject("proxy_apps")?.also {
......@@ -132,22 +135,41 @@ class Profile : Serializable {
individual = it.optJSONArray("android_list")?.asIterable()?.joinToString("\n") ?: individual
}
udpdns = json.optBoolean("udpdns", udpdns)
})
json.optJSONObject("udp_fallback")?.let { tryParse(it, true) }?.also { fallbackMap[this] = it }
}
}
fun process(json: Any) {
when (json) {
is JSONObject -> {
tryAdd(json)
for (key in json.keys()) process(json.get(key))
val profile = tryParse(json)
if (profile != null) add(profile) else for (key in json.keys()) process(json.get(key))
}
is JSONArray -> json.asIterable().forEach(this::process)
// ignore other types
}
}
fun finalize(create: (Profile) -> Unit) {
val profiles = ProfileManager.getAllProfiles() ?: emptyList()
for ((profile, fallback) in fallbackMap) {
val match = profiles.firstOrNull {
fallback.host == it.host && fallback.remotePort == it.remotePort &&
fallback.password == it.password && fallback.method == it.method &&
it.plugin.isNullOrEmpty()
}
profile.udpFallback = if (match == null) {
create(fallback)
fallback.id
} else match.id
ProfileManager.updateProfile(profile)
}
}
}
fun parseJson(json: String, feature: Profile? = null, create: (Profile) -> Unit) = JsonParser(feature).run {
process(JSONTokener(json).nextValue())
for (profile in this) create(profile)
finalize(create)
}
fun parseJson(json: String, feature: Profile? = null): List<Profile> =
JsonParser(feature).apply { process(JSONTokener(json).nextValue()) }
}
@androidx.room.Dao
......@@ -195,6 +217,7 @@ class Profile : Serializable {
var rx: Long = 0
var userOrder: Long = 0
var plugin: String? = null
var udpFallback: Long? = null
@Ignore // not persisted in db, only used by direct boot
var dirty: Boolean = false
......@@ -226,12 +249,12 @@ class Profile : Serializable {
}
override fun toString() = toUri().toString()
fun toJson(compat: Boolean = false) = JSONObject().apply {
fun toJson(profiles: Map<Long, Profile>? = null): JSONObject = JSONObject().apply {
put("server", host)
put("server_port", remotePort)
put("password", password)
put("method", method)
if (compat) return@apply
if (profiles == null) return@apply
PluginConfiguration(plugin ?: "").selectedOptions.also {
if (it.id.isNotEmpty()) {
put("plugin", it.id)
......@@ -251,9 +274,12 @@ class Profile : Serializable {
}
})
put("udpdns", udpdns)
val fallback = profiles[udpFallback]
if (fallback != null && fallback.plugin.isNullOrEmpty()) fallback.toJson().also { put("udp_fallback", it) }
}
fun serialize() {
DataStore.editingId = id
DataStore.privateStore.putString(Key.name, name)
DataStore.privateStore.putString(Key.host, host)
DataStore.privateStore.putString(Key.remotePort, remotePort.toString())
......@@ -267,9 +293,12 @@ class Profile : Serializable {
DataStore.privateStore.putBoolean(Key.ipv6, ipv6)
DataStore.individual = individual
DataStore.plugin = plugin ?: ""
DataStore.udpFallback = udpFallback
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) ?: ""
host = DataStore.privateStore.getString(Key.host) ?: ""
......@@ -284,5 +313,6 @@ class Profile : Serializable {
ipv6 = DataStore.privateStore.getBoolean(Key.ipv6, false)
individual = DataStore.individual
plugin = DataStore.plugin
udpFallback = DataStore.udpFallback
}
}
......@@ -21,6 +21,7 @@
package com.github.shadowsocks.database
import android.database.sqlite.SQLiteCantOpenDatabaseException
import com.github.shadowsocks.Core
import com.github.shadowsocks.preference.DataStore
import com.github.shadowsocks.utils.DirectBoot
import com.github.shadowsocks.utils.printLog
......@@ -63,11 +64,14 @@ object ProfileManager {
null
}
@Throws(IOException::class)
fun expand(profile: Profile): Pair<Profile, Profile?> = Pair(profile, profile.udpFallback?.let { getProfile(it) })
@Throws(SQLException::class)
fun delProfile(id: Long) {
check(PrivateDatabase.profileDao.delete(id) == 1)
listener?.onRemove(id)
if (id == DataStore.profileId && DataStore.directBootAware) DirectBoot.clean()
if (id in Core.activeProfileIds && DataStore.directBootAware) DirectBoot.clean()
}
@Throws(SQLException::class)
......
......@@ -105,6 +105,9 @@ object DataStore : OnPreferenceDataStoreChangeListener {
if (publicStore.getString(Key.portTransproxy) == null) portTransproxy = portTransproxy
}
var editingId: Long?
get() = privateStore.getLong(Key.id)
set(value) = privateStore.putLong(Key.id, value)
var proxyApps: Boolean
get() = privateStore.getBoolean(Key.proxyApps) ?: false
set(value) = privateStore.putBoolean(Key.proxyApps, value)
......@@ -117,6 +120,9 @@ object DataStore : OnPreferenceDataStoreChangeListener {
var plugin: String
get() = privateStore.getString(Key.plugin) ?: ""
set(value) = privateStore.putString(Key.plugin, value)
var udpFallback: Long?
get() = privateStore.getLong(Key.udpFallback)
set(value) = privateStore.putLong(Key.udpFallback, value)
var dirty: Boolean
get() = privateStore.getBoolean(Key.dirty) ?: false
set(value) = privateStore.putBoolean(Key.dirty, value)
......
......@@ -59,6 +59,7 @@ object Key {
const val plugin = "plugin"
const val pluginConfigure = "plugin.configure"
const val udpFallback = "udpFallback"
const val dirty = "profileDirty"
......
......@@ -21,24 +21,28 @@ object DirectBoot : BroadcastReceiver() {
private val file = File(Core.deviceStorage.noBackupFilesDir, "directBootProfile")
private var registered = false
fun getDeviceProfile(): Profile? = try {
ObjectInputStream(file.inputStream()).use { it.readObject() as Profile }
fun getDeviceProfile(): Pair<Profile, Profile?>? = try {
ObjectInputStream(file.inputStream()).use { it.readObject() as? Pair<Profile, Profile?> }
} 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(profile) }
if (profile == null) clean()
else ObjectOutputStream(file.outputStream()).use { it.writeObject(ProfileManager.expand(profile)) }
fun flushTrafficStats() {
val profile = getDeviceProfile()
if (profile?.dirty == true) ProfileManager.updateProfile(profile)
getDeviceProfile()?.also { (profile, fallback) ->
if (profile.dirty) ProfileManager.updateProfile(profile)
if (fallback?.dirty == true) ProfileManager.updateProfile(fallback)
}
update()
}
......
......@@ -80,7 +80,7 @@ class HttpsTest : ViewModel() {
status.value = Status.Testing
val id = testCount // it would change by other code
thread("ConnectionTest") {
val url = URL("https", when (Core.currentProfile!!.route) {
val url = URL("https", when (Core.currentProfile!!.first.route) {
Acl.CHINALIST -> "www.qualcomm.cn"
else -> "www.google.com"
}, "/generate_204")
......
Subproject commit 91222766e793a6d1516ac066984b63069594e0bf
Subproject commit 7fc05dcd9ddb95db3b557fb9f4dc6c647f2c67c3
......@@ -59,6 +59,7 @@
<string name="tcp_fastopen_failure">Toggle failed</string>
<string name="udp_dns">DNS Forwarding</string>
<string name="udp_dns_summary">Forward all DNS requests to remote</string>
<string name="udp_fallback">UDP Fallback</string>
<!-- notification category -->
<string name="service_vpn">VPN Service</string>
......
......@@ -58,6 +58,13 @@
android:excludeFromRecents="true"
android:launchMode="singleTask"/>
<activity
android:name=".UdpFallbackProfileActivity"
android:label="@string/udp_fallback"
android:parentActivityName=".ProfileConfigActivity"
android:excludeFromRecents="true"
android:launchMode="singleTask"/>
<activity
android:name=".ScannerActivity"
android:label="@string/add_profile_methods_scan_qr_code"
......
......@@ -46,6 +46,7 @@ import com.crashlytics.android.Crashlytics
import com.github.shadowsocks.acl.CustomRulesFragment
import com.github.shadowsocks.aidl.IShadowsocksService
import com.github.shadowsocks.aidl.IShadowsocksServiceCallback
import com.github.shadowsocks.aidl.TrafficStats
import com.github.shadowsocks.bg.BaseService
import com.github.shadowsocks.bg.Executable
import com.github.shadowsocks.database.Profile
......@@ -96,12 +97,14 @@ class MainActivity : AppCompatActivity(), ShadowsocksConnection.Interface, OnPre
override fun stateChanged(state: Int, profileName: String?, msg: String?) {
Core.handler.post { changeState(state, msg, true) }
}
override fun trafficUpdated(profileId: Long, txRate: Long, rxRate: Long, txTotal: Long, rxTotal: Long) {
override fun trafficUpdated(profileId: Long, stats: TrafficStats) {
Core.handler.post {
stats.updateTraffic(txRate, rxRate, txTotal, rxTotal)
val child = supportFragmentManager.findFragmentById(R.id.fragment_holder) as ToolbarFragment?
if (state != BaseService.STOPPING)
child?.onTrafficUpdated(profileId, txRate, rxRate, txTotal, rxTotal)
if (profileId == 0L) this@MainActivity.stats.updateTraffic(
stats.txRate, stats.rxRate, stats.txTotal, stats.rxTotal)
if (state != BaseService.STOPPING) {
(supportFragmentManager.findFragmentById(R.id.fragment_holder) as? ToolbarFragment)
?.onTrafficUpdated(profileId, stats)
}
}
}
override fun trafficPersisted(profileId: Long) {
......@@ -195,7 +198,7 @@ class MainActivity : AppCompatActivity(), ShadowsocksConnection.Interface, OnPre
else -> null
}
if (sharedStr.isNullOrEmpty()) return
val profiles = Profile.findAllUrls(sharedStr, Core.currentProfile).toList()
val profiles = Profile.findAllUrls(sharedStr, Core.currentProfile?.first).toList()
if (profiles.isEmpty()) {
snackbar().setText(R.string.profile_invalid_input).show()
return
......
......@@ -63,6 +63,7 @@ class ProfileConfigFragment : PreferenceFragmentCompat(),
private lateinit var pluginConfigure: EditTextPreference
private lateinit var pluginConfiguration: PluginConfiguration
private lateinit var receiver: BroadcastReceiver
private lateinit var udpFallback: Preference
override fun onCreatePreferencesFix(savedInstanceState: Bundle?, rootKey: String?) {
preferenceManager.preferenceDataStore = DataStore.privateStore
......@@ -101,6 +102,7 @@ class ProfileConfigFragment : PreferenceFragmentCompat(),
pluginConfigure.onPreferenceChangeListener = this
initPlugins()
receiver = Core.listenForPackageChanges(false) { initPlugins() }
udpFallback = findPreference(Key.udpFallback)
DataStore.privateStore.registerChangeListener(this)
}
......@@ -128,13 +130,16 @@ class ProfileConfigFragment : PreferenceFragmentCompat(),
profile.deserialize()
ProfileManager.updateProfile(profile)
ProfilesFragment.instance?.profilesAdapter?.deepRefreshId(profileId)
if (DataStore.profileId == profileId && DataStore.directBootAware) DirectBoot.update()
if (profileId in Core.activeProfileIds && DataStore.directBootAware) DirectBoot.update()
requireActivity().finish()
}
override fun onResume() {
super.onResume()
isProxyApps.isChecked = DataStore.proxyApps // fetch proxyApps updated by AppManager
val fallbackProfile = DataStore.udpFallback?.let { ProfileManager.getProfile(it) }
if (fallbackProfile == null) udpFallback.setSummary(R.string.plugin_disabled)
else udpFallback.summary = fallbackProfile.formattedName
}
override fun onPreferenceChange(preference: Preference?, newValue: Any?): Boolean = try {
......
......@@ -39,6 +39,7 @@ import androidx.core.content.getSystemService
import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
import androidx.recyclerview.widget.*
import com.github.shadowsocks.aidl.TrafficStats
import com.github.shadowsocks.bg.BaseService
import com.github.shadowsocks.database.Profile
import com.github.shadowsocks.database.ProfileManager
......@@ -74,7 +75,7 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
else -> false
}
private fun isProfileEditable(id: Long) = when ((activity as MainActivity).state) {
BaseService.CONNECTED -> id != DataStore.profileId
BaseService.CONNECTED -> id !in Core.activeProfileIds
BaseService.STOPPED -> true
else -> false
}
......@@ -148,7 +149,7 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
edit.alpha = if (editable) 1F else .5F
var tx = item.tx
var rx = item.rx
if (item.id == bandwidthProfile) {
statsCache[item.id]?.apply {
tx += txTotal
rx += rxTotal
}
......@@ -300,9 +301,7 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
val profilesAdapter by lazy { ProfilesAdapter() }
private lateinit var undoManager: UndoSnackbarManager<Profile>
private var bandwidthProfile = 0L
private var txTotal: Long = 0L
private var rxTotal: Long = 0L
private val statsCache = mutableMapOf<Long, TrafficStats>()
private val clipboard by lazy { requireContext().getSystemService<ClipboardManager>()!! }
......@@ -366,8 +365,10 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
}
R.id.action_import_clipboard -> {
try {
val profiles = Profile.findAllUrls(clipboard.primaryClip!!.getItemAt(0).text, Core.currentProfile)
.toList()
val profiles = Profile.findAllUrls(
clipboard.primaryClip!!.getItemAt(0).text,
Core.currentProfile?.first
).toList()
if (profiles.isNotEmpty()) {
profiles.forEach { ProfileManager.createProfile(it) }
(activity as MainActivity).snackbar().setText(R.string.action_import_msg).show()
......@@ -389,7 +390,7 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
}
R.id.action_manual_settings -> {
startConfig(ProfileManager.createProfile(
Profile().also { Core.currentProfile?.copyFeatureSettingsTo(it) }))
Profile().also { Core.currentProfile?.first?.copyFeatureSettingsTo(it) }))
true
}
R.id.action_export_clipboard -> {
......@@ -424,12 +425,12 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
if (resultCode != Activity.RESULT_OK) super.onActivityResult(requestCode, resultCode, data)
else when (requestCode) {
REQUEST_IMPORT_PROFILES -> {
val feature = Core.currentProfile
val feature = Core.currentProfile?.first
var success = false
val activity = activity as MainActivity
for (uri in data!!.datas) try {
Profile.parseJson(activity.contentResolver.openInputStream(uri)!!.bufferedReader().readText(),
feature).forEach {
feature) {
ProfileManager.createProfile(it)
success = true
}
......@@ -442,8 +443,9 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
REQUEST_EXPORT_PROFILES -> {
val profiles = ProfileManager.getAllProfiles()
if (profiles != null) try {
val lookup = profiles.associateBy { it.id }
requireContext().contentResolver.openOutputStream(data?.data!!)!!.bufferedWriter().use {
it.write(JSONArray(profiles.map { it.toJson() }.toTypedArray()).toString(2))
it.write(JSONArray(profiles.map { it.toJson(lookup) }.toTypedArray()).toString(2))
}
} catch (e: Exception) {
printLog(e)
......@@ -454,24 +456,14 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
}
}
override fun onTrafficUpdated(profileId: Long, txRate: Long, rxRate: Long, txTotal: Long, rxTotal: Long) {
if (profileId != -1L) { // ignore resets from MainActivity
if (bandwidthProfile != profileId) {
onTrafficPersisted(bandwidthProfile)
bandwidthProfile = profileId
}
this.txTotal = txTotal
this.rxTotal = rxTotal
override fun onTrafficUpdated(profileId: Long, stats: TrafficStats) {
if (profileId != 0L) { // ignore aggregate stats
statsCache[profileId] = stats
profilesAdapter.refreshId(profileId)
}
}
fun onTrafficPersisted(profileId: Long) {
txTotal = 0
rxTotal = 0
if (bandwidthProfile != profileId) {
onTrafficPersisted(bandwidthProfile)
bandwidthProfile = profileId
}
statsCache.remove(profileId)
profilesAdapter.deepRefreshId(profileId)
}
......
......@@ -105,7 +105,7 @@ class ScannerActivity : AppCompatActivity(), BarcodeRetriever {
}
override fun onRetrieved(barcode: Barcode) = runOnUiThread {
Profile.findAllUrls(barcode.rawValue, Core.currentProfile).forEach { ProfileManager.createProfile(it) }
Profile.findAllUrls(barcode.rawValue, Core.currentProfile?.first).forEach { ProfileManager.createProfile(it) }
onSupportNavigateUp()
}
override fun onRetrievedMultiple(closetToClick: Barcode?, barcode: MutableList<BarcodeGraphic>?) = check(false)
......@@ -136,7 +136,7 @@ class ScannerActivity : AppCompatActivity(), BarcodeRetriever {
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when (requestCode) {
REQUEST_IMPORT, REQUEST_IMPORT_OR_FINISH -> if (resultCode == Activity.RESULT_OK) {
val feature = Core.currentProfile
val feature = Core.currentProfile?.first
var success = false
for (uri in data!!.datas) try {
detector.detect(Frame.Builder().setBitmap(contentResolver.openBitmap(uri)).build())
......
......@@ -25,6 +25,7 @@ import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.core.view.GravityCompat
import androidx.fragment.app.Fragment
import com.github.shadowsocks.aidl.TrafficStats
/**
* @author Mygod
......@@ -39,7 +40,7 @@ open class ToolbarFragment : Fragment() {
toolbar.setNavigationOnClickListener { (activity as MainActivity).drawer.openDrawer(GravityCompat.START) }
}
open fun onTrafficUpdated(profileId: Long, txRate: Long, rxRate: Long, txTotal: Long, rxTotal: Long) { }
open fun onTrafficUpdated(profileId: Long, stats: TrafficStats) { }
open fun onBackPressed(): Boolean = false
}
/*******************************************************************************
* *
* Copyright (C) 2019 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2019 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
package com.github.shadowsocks
import android.content.res.Resources
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.CheckedTextView
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.github.shadowsocks.database.Profile
import com.github.shadowsocks.database.ProfileManager
import com.github.shadowsocks.preference.DataStore
import com.github.shadowsocks.utils.resolveResourceId
class UdpFallbackProfileActivity : AppCompatActivity() {
inner class ProfileViewHolder(view: View) : RecyclerView.ViewHolder(view), View.OnClickListener {
private var item: Profile? = null
private val text = itemView.findViewById<CheckedTextView>(android.R.id.text1)
init {
view.setBackgroundResource(theme.resolveResourceId(android.R.attr.selectableItemBackground))
itemView.setOnClickListener(this)
}
fun bind(item: Profile?) {
this.item = item
if (item == null) text.setText(R.string.plugin_disabled) else text.text = item.formattedName
text.isChecked = udpFallback == item?.id
}
override fun onClick(v: View?) {
DataStore.udpFallback = item?.id
DataStore.dirty = true
finish()
}
}
inner class ProfilesAdapter : RecyclerView.Adapter<ProfileViewHolder>() {
internal val profiles = (ProfileManager.getAllProfiles()?.toMutableList() ?: mutableListOf())
.filter { it.id != editingId && it.plugin.isNullOrEmpty() }
override fun onBindViewHolder(holder: ProfileViewHolder, position: Int) =
holder.bind(if (position == 0) null else profiles[position - 1])
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProfileViewHolder = ProfileViewHolder(
LayoutInflater.from(parent.context).inflate(Resources.getSystem()
.getIdentifier("select_dialog_singlechoice_material", "layout", "android"), parent, false))
override fun getItemCount(): Int = 1 + profiles.size
}
private var editingId = DataStore.editingId
private var udpFallback = DataStore.udpFallback
private val profilesAdapter = ProfilesAdapter()
override fun onCreate(savedInstanceState: Bundle?) {
if (editingId == null) {
finish()
return
}
super.onCreate(savedInstanceState)
setContentView(R.layout.layout_udp_fallback)
val toolbar = findViewById<Toolbar>(R.id.toolbar)
toolbar.setTitle(R.string.udp_fallback)
toolbar.setNavigationIcon(R.drawable.ic_navigation_close)
toolbar.setNavigationOnClickListener { finish() }
val profilesList = findViewById<RecyclerView>(R.id.list)
val lm = LinearLayoutManager(this, RecyclerView.VERTICAL, false)
profilesList.layoutManager = lm
profilesList.itemAnimator = DefaultItemAnimator()
profilesList.adapter = profilesAdapter
if (DataStore.udpFallback != null)
lm.scrollToPosition(profilesAdapter.profiles.indexOfFirst { it.id == DataStore.udpFallback } + 1)
}
}
......@@ -44,7 +44,6 @@ import com.github.shadowsocks.ToolbarFragment
import com.github.shadowsocks.bg.BaseService
import com.github.shadowsocks.utils.Subnet
import com.github.shadowsocks.utils.asIterable
import com.github.shadowsocks.utils.printLog
import com.github.shadowsocks.utils.resolveResourceId
import com.github.shadowsocks.widget.UndoSnackbarManager
import com.google.android.material.textfield.TextInputLayout
......@@ -339,7 +338,7 @@ class CustomRulesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener,
}
private val isEnabled get() = when ((activity as MainActivity).state) {
BaseService.CONNECTED -> Core.currentProfile?.route != Acl.CUSTOM_RULES
BaseService.CONNECTED -> Core.currentProfile?.first?.route != Acl.CUSTOM_RULES
BaseService.STOPPED -> true
else -> false
}
......
......@@ -31,6 +31,7 @@ import com.github.shadowsocks.R
import com.github.shadowsocks.ShadowsocksConnection
import com.github.shadowsocks.aidl.IShadowsocksService
import com.github.shadowsocks.aidl.IShadowsocksServiceCallback
import com.github.shadowsocks.aidl.TrafficStats
import com.github.shadowsocks.preference.DataStore
@RequiresApi(24)
......@@ -65,7 +66,7 @@ class TileService : BaseTileService(), ShadowsocksConnection.Interface {
tile.label = label ?: getString(R.string.app_name)
tile.updateTile()
}
override fun trafficUpdated(profileId: Long, txRate: Long, rxRate: Long, txTotal: Long, rxTotal: Long) { }
override fun trafficUpdated(profileId: Long, stats: TrafficStats) { }
override fun trafficPersisted(profileId: Long) { }
}
}
......
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<include layout="@layout/toolbar_light_dark" />
<androidx.recyclerview.widget.RecyclerView android:id="@+id/list"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:itemview="@android:layout/select_dialog_singlechoice_material"
android:scrollbars="vertical"/>
</LinearLayout>
......@@ -86,6 +86,13 @@
android:persistent="false"
app:pref_summaryHasText="%s"
android:title="@string/plugin_configure"/>
<Preference
android:key="udpFallback"
android:title="@string/udp_fallback"
android:summary="@string/plugin_disabled">
<intent android:targetPackage="com.github.shadowsocks"
android:targetClass="com.github.shadowsocks.UdpFallbackProfileActivity"/>
</Preference>
</PreferenceCategory>
......
......@@ -45,6 +45,7 @@ import com.github.shadowsocks.Core
import com.github.shadowsocks.ShadowsocksConnection
import com.github.shadowsocks.aidl.IShadowsocksService
import com.github.shadowsocks.aidl.IShadowsocksServiceCallback
import com.github.shadowsocks.aidl.TrafficStats
import com.github.shadowsocks.bg.BaseService
import com.github.shadowsocks.bg.Executable
import com.github.shadowsocks.database.Profile
......@@ -93,11 +94,12 @@ class MainPreferenceFragment : LeanbackPreferenceFragment(), ShadowsocksConnecti
override fun stateChanged(state: Int, profileName: String?, msg: String?) {
Core.handler.post { changeState(state, msg) }
}
override fun trafficUpdated(profileId: Long, txRate: Long, rxRate: Long, txTotal: Long, rxTotal: Long) {
stats.summary = getString(R.string.stat_summary,
getString(R.string.speed, Formatter.formatFileSize(activity, txRate)),
getString(R.string.speed, Formatter.formatFileSize(activity, rxRate)),
Formatter.formatFileSize(activity, txTotal), Formatter.formatFileSize(activity, rxTotal))
override fun trafficUpdated(profileId: Long, stats: TrafficStats) {
if (profileId == 0L) this@MainPreferenceFragment.stats.summary = getString(R.string.stat_summary,
getString(R.string.speed, Formatter.formatFileSize(activity, stats.txRate)),
getString(R.string.speed, Formatter.formatFileSize(activity, stats.rxRate)),
Formatter.formatFileSize(activity, stats.txTotal),
Formatter.formatFileSize(activity, stats.rxTotal))
}
override fun trafficPersisted(profileId: Long) { }
}
......@@ -115,7 +117,7 @@ class MainPreferenceFragment : LeanbackPreferenceFragment(), ShadowsocksConnecti
stats.isVisible = state == BaseService.CONNECTED
val owner = activity as FragmentActivity // TODO: change to this when refactored to androidx
if (state != BaseService.CONNECTED) {
serviceCallback.trafficUpdated(0, 0, 0, 0, 0)
serviceCallback.trafficUpdated(0, TrafficStats())
tester.status.removeObservers(owner)
if (state != BaseService.IDLE) tester.invalidate()
} else tester.status.observe(owner, Observer {
......@@ -300,7 +302,7 @@ class MainPreferenceFragment : LeanbackPreferenceFragment(), ShadowsocksConnecti
ProfileManager.clear()
for (uri in data!!.datas) try {
Profile.parseJson(activity.contentResolver.openInputStream(uri)!!.bufferedReader().readText(),
feature).forEach {
feature) {
// 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
......@@ -318,8 +320,9 @@ class MainPreferenceFragment : LeanbackPreferenceFragment(), ShadowsocksConnecti
if (resultCode != Activity.RESULT_OK) return
val profiles = ProfileManager.getAllProfiles()
if (profiles != null) try {
val lookup = profiles.associateBy { it.id }
activity.contentResolver.openOutputStream(data?.data!!)!!.bufferedWriter().use {
it.write(JSONArray(profiles.map { it.toJson() }.toTypedArray()).toString(2))
it.write(JSONArray(profiles.map { it.toJson(lookup) }.toTypedArray()).toString(2))
}
} catch (e: Exception) {
printLog(e)
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment