Unverified Commit fede0331 authored by Max Lv's avatar Max Lv Committed by GitHub

Merge pull request #2070 from Mygod/udp-fallback

Implement UDP fallback
parents cfe5a45c 867f680b
...@@ -40,6 +40,10 @@ android { ...@@ -40,6 +40,10 @@ android {
path 'src/main/jni/Android.mk' path 'src/main/jni/Android.mk'
} }
} }
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
}
} }
task goBuild(type: Exec) { task goBuild(type: Exec) {
...@@ -83,9 +87,10 @@ dependencies { ...@@ -83,9 +87,10 @@ dependencies {
kapt "androidx.lifecycle:lifecycle-compiler:$lifecycleVersion" kapt "androidx.lifecycle:lifecycle-compiler:$lifecycleVersion"
kapt "androidx.room:room-compiler:$roomVersion" kapt "androidx.room:room-compiler:$roomVersion"
testImplementation "androidx.arch.core:core-testing:$lifecycleVersion" testImplementation "androidx.arch.core:core-testing:$lifecycleVersion"
testImplementation "androidx.room:room-testing:$roomVersion"
testImplementation "junit:junit:$junitVersion" testImplementation "junit:junit:$junitVersion"
androidTestImplementation "android.arch.work:work-testing:$workVersion" androidTestImplementation "android.arch.work:work-testing:$workVersion"
androidTestImplementation "androidx.room:room-testing:$roomVersion"
androidTestImplementation "androidx.test:runner:$androidTestVersion" androidTestImplementation "androidx.test:runner:$androidTestVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidEspressoVersion" 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) 2019 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2017 by Mygod Studio <contact-shadowsocks-android@mygod.be> * * Copyright (C) 2019 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* * * *
* This program is free software: you can redistribute it and/or modify * * 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 * * it under the terms of the GNU General Public License as published by *
...@@ -18,27 +18,32 @@ ...@@ -18,27 +18,32 @@
* * * *
*******************************************************************************/ *******************************************************************************/
package com.github.shadowsocks.bg package com.github.shadowsocks.database
import android.net.LocalSocket import androidx.room.testing.MigrationTestHelper
import com.github.shadowsocks.Core import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import com.github.shadowsocks.utils.printLog import androidx.test.ext.junit.runners.AndroidJUnit4
import java.io.File 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.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
class TrafficMonitorThread : LocalSocketListener("TrafficMonitorThread") { @RunWith(AndroidJUnit4::class)
override val socketFile = File(Core.deviceStorage.noBackupFilesDir, "stat_path") class MigrationTest {
companion object {
override fun accept(socket: LocalSocket) { private const val TEST_DB = "migration-test"
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)
} }
@get:Rule
val privateDatabase = MigrationTestHelper(InstrumentationRegistry.getInstrumentation(),
PrivateDatabase::class.java.canonicalName, FrameworkSQLiteOpenHelperFactory())
@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; package com.github.shadowsocks.aidl;
import com.github.shadowsocks.aidl.TrafficStats;
interface IShadowsocksServiceCallback { interface IShadowsocksServiceCallback {
oneway void stateChanged(int state, String profileName, String msg); 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 // Traffic data has persisted to database, listener should refetch their data from database
oneway void trafficPersisted(long profileId); oneway void trafficPersisted(long profileId);
} }
package com.github.shadowsocks.aidl;
parcelable TrafficStats;
...@@ -69,8 +69,13 @@ object Core { ...@@ -69,8 +69,13 @@ object Core {
DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE_PER_USER DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE_PER_USER
} }
val currentProfile: Profile? get() = val activeProfileIds get() = ProfileManager.getProfile(DataStore.profileId).let {
if (DataStore.directBootAware) DirectBoot.getDeviceProfile() else ProfileManager.getProfile(DataStore.profileId) 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 { fun switchProfile(id: Long): Profile {
val result = ProfileManager.getProfile(id) ?: ProfileManager.createProfile() 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 ...@@ -25,36 +25,23 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.os.* import android.os.*
import android.util.Base64
import android.util.Log import android.util.Log
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import com.crashlytics.android.Crashlytics import com.crashlytics.android.Crashlytics
import com.github.shadowsocks.Core import com.github.shadowsocks.Core
import com.github.shadowsocks.Core.app 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.IShadowsocksService
import com.github.shadowsocks.aidl.IShadowsocksServiceCallback import com.github.shadowsocks.aidl.IShadowsocksServiceCallback
import com.github.shadowsocks.aidl.TrafficStats
import com.github.shadowsocks.core.R 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.PluginManager
import com.github.shadowsocks.plugin.PluginOptions
import com.github.shadowsocks.preference.DataStore import com.github.shadowsocks.preference.DataStore
import com.github.shadowsocks.utils.* import com.github.shadowsocks.utils.*
import com.google.firebase.analytics.FirebaseAnalytics import com.google.firebase.analytics.FirebaseAnalytics
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File import java.io.File
import java.io.IOException
import java.net.InetAddress
import java.net.UnknownHostException import java.net.UnknownHostException
import java.security.MessageDigest
import java.util.* import java.util.*
import java.util.concurrent.TimeUnit
/** /**
* This object uses WeakMap to simulate the effects of multi-inheritance. * This object uses WeakMap to simulate the effects of multi-inheritance.
...@@ -70,15 +57,13 @@ object BaseService { ...@@ -70,15 +57,13 @@ object BaseService {
const val STOPPED = 4 const val STOPPED = 4
const val CONFIG_FILE = "shadowsocks.conf" const val CONFIG_FILE = "shadowsocks.conf"
const val CONFIG_FILE_UDP = "shadowsocks-udp.conf"
class Data internal constructor(private val service: Interface) { class Data internal constructor(private val service: Interface) {
@Volatile var profile: Profile? = null
@Volatile var state = STOPPED @Volatile var state = STOPPED
@Volatile var plugin = PluginOptions()
@Volatile var pluginPath: String? = null
val processes = GuardedProcessPool() val processes = GuardedProcessPool()
@Volatile var proxy: ProxyInstance? = null
var trafficMonitorThread: TrafficMonitorThread? = null @Volatile var udpFallback: ProxyInstance? = null
val callbacks = RemoteCallbackList<IShadowsocksServiceCallback>() val callbacks = RemoteCallbackList<IShadowsocksServiceCallback>()
val bandwidthListeners = HashSet<IBinder>() // the binder is the real identifier val bandwidthListeners = HashSet<IBinder>() // the binder is the real identifier
...@@ -94,7 +79,7 @@ object BaseService { ...@@ -94,7 +79,7 @@ object BaseService {
val binder = object : IShadowsocksService.Stub() { val binder = object : IShadowsocksService.Stub() {
override fun getState(): Int = this@Data.state 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) { override fun registerCallback(cb: IShadowsocksServiceCallback) {
callbacks.register(cb) callbacks.register(cb)
...@@ -103,18 +88,20 @@ object BaseService { ...@@ -103,18 +88,20 @@ object BaseService {
private fun registerTimeout() = private fun registerTimeout() =
Core.handler.postAtTime(this::onTimeout, this, SystemClock.uptimeMillis() + 1000) Core.handler.postAtTime(this::onTimeout, this, SystemClock.uptimeMillis() + 1000)
private fun onTimeout() { private fun onTimeout() {
val profile = profile val proxies = listOfNotNull(proxy, udpFallback)
if (profile != null && state == CONNECTED && TrafficMonitor.updateRate() && val stats = proxies
bandwidthListeners.isNotEmpty()) { .map { Pair(it.profile.id, it.trafficMonitor?.requestUpdate()) }
val txRate = TrafficMonitor.txRate .filter { it.second != null }
val rxRate = TrafficMonitor.rxRate .map { Triple(it.first, it.second!!.first, it.second!!.second) }
val txTotal = TrafficMonitor.txTotal if (stats.any { it.third } && state == CONNECTED && bandwidthListeners.isNotEmpty()) {
val rxTotal = TrafficMonitor.rxTotal val sum = stats.fold(TrafficStats()) { a, b -> a + b.second }
val n = callbacks.beginBroadcast() val n = callbacks.beginBroadcast()
for (i in 0 until n) try { for (i in 0 until n) try {
val item = callbacks.getBroadcastItem(i) val item = callbacks.getBroadcastItem(i)
if (bandwidthListeners.contains(item.asBinder())) if (bandwidthListeners.contains(item.asBinder())) {
item.trafficUpdated(profile.id, txRate, rxRate, txTotal, rxTotal) stats.forEach { (id, stats) -> item.trafficUpdated(id, stats) }
item.trafficUpdated(0, sum)
}
} catch (e: Exception) { } catch (e: Exception) {
printLog(e) printLog(e)
} }
...@@ -127,10 +114,24 @@ object BaseService { ...@@ -127,10 +114,24 @@ object BaseService {
val wasEmpty = bandwidthListeners.isEmpty() val wasEmpty = bandwidthListeners.isEmpty()
if (bandwidthListeners.add(cb.asBinder())) { if (bandwidthListeners.add(cb.asBinder())) {
if (wasEmpty) registerTimeout() if (wasEmpty) registerTimeout()
TrafficMonitor.updateRate() if (state != CONNECTED) return
if (state == CONNECTED) cb.trafficUpdated(profile!!.id, var sum = TrafficStats()
TrafficMonitor.txRate, TrafficMonitor.rxRate, val proxy = proxy ?: return
TrafficMonitor.txTotal, TrafficMonitor.rxTotal) 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 { ...@@ -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) { fun changeState(s: Int, msg: String? = null) {
if (state == s && msg == null) return if (state == s && msg == null) return
if (callbacks.registeredCallbackCount > 0) Core.handler.post { if (callbacks.registeredCallbackCount > 0) Core.handler.post {
...@@ -222,15 +166,14 @@ object BaseService { ...@@ -222,15 +166,14 @@ object BaseService {
fun onBind(intent: Intent): IBinder? = if (intent.action == Action.SERVICE) data.binder else null 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() { fun forceLoad() {
val profile = Core.currentProfile val (profile, fallback) = Core.currentProfile
?: return stopRunner(true, (this as Context).getString(R.string.profile_empty)) ?: 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 val s = data.state
when (s) { when (s) {
STOPPED -> startRunner() STOPPED -> startRunner()
...@@ -245,27 +188,18 @@ object BaseService { ...@@ -245,27 +188,18 @@ object BaseService {
fun buildAdditionalArguments(cmd: ArrayList<String>): ArrayList<String> = cmd fun buildAdditionalArguments(cmd: ArrayList<String>): ArrayList<String> = cmd
fun startNativeProcesses() { fun startNativeProcesses() {
val data = data val configRoot = (if (Build.VERSION.SDK_INT < 24 || app.getSystemService<UserManager>()
val profile = data.profile!! ?.isUserUnlocked != false) app else Core.deviceStorage).noBackupFilesDir
val cmd = buildAdditionalArguments(arrayListOf( val udpFallback = data.udpFallback
File((this as Context).applicationInfo.nativeLibraryDir, Executable.SS_LOCAL).absolutePath, data.proxy!!.start(this,
"-u", File(Core.deviceStorage.noBackupFilesDir, "stat_main"),
"-b", DataStore.listenAddress, File(configRoot, CONFIG_FILE),
"-l", DataStore.portProxy.toString(), if (udpFallback == null) "-u" else null)
"-t", "600", check(udpFallback?.pluginPath == null)
"-c", data.buildShadowsocksConfig().absolutePath)) udpFallback?.start(this,
File(Core.deviceStorage.noBackupFilesDir, "stat_udp"),
val acl = data.aclFile File(configRoot, CONFIG_FILE_UDP),
if (acl != null) { "-U")
cmd += "--acl"
cmd += acl.absolutePath
}
if (profile.udpdns) cmd += "-D"
if (DataStore.tcpFastOpen) cmd += "--fast-open"
data.processes.start(cmd)
} }
fun createNotification(profileName: String): ServiceNotification fun createNotification(profileName: String): ServiceNotification
...@@ -294,26 +228,34 @@ object BaseService { ...@@ -294,26 +228,34 @@ object BaseService {
data.closeReceiverRegistered = false data.closeReceiverRegistered = false
} }
data.shadowsocksConfigFile?.delete() // remove old config possibly in device storage
data.shadowsocksConfigFile = null
data.notification?.destroy() data.notification?.destroy()
data.notification = null data.notification = null
// Make sure update total traffic when stopping the runner val ids = listOfNotNull(data.proxy, data.udpFallback).map {
data.updateTrafficTotal(TrafficMonitor.txTotal, TrafficMonitor.rxTotal) it.close()
it.profile.id
TrafficMonitor.reset() }
data.trafficMonitorThread?.stopThread() data.proxy = null
data.trafficMonitorThread = 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 // change the state
data.changeState(STOPPED, msg) data.changeState(STOPPED, msg)
// stop the service if nothing has bound to it // stop the service if nothing has bound to it
if (stopService) stopSelf() if (stopService) stopSelf()
data.profile = null
} }
val data: Data get() = instances[this]!! val data: Data get() = instances[this]!!
...@@ -321,21 +263,19 @@ object BaseService { ...@@ -321,21 +263,19 @@ object BaseService {
fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val data = data val data = data
if (data.state != STOPPED) return Service.START_NOT_STICKY if (data.state != STOPPED) return Service.START_NOT_STICKY
val profile = Core.currentProfile val profilePair = Core.currentProfile
this as Context this as Context
if (profile == null) { if (profilePair == null) {
// gracefully shutdown: https://stackoverflow.com/q/47337857/2245107 // gracefully shutdown: https://stackoverflow.com/q/47337857/2245107
data.notification = createNotification("") data.notification = createNotification("")
stopRunner(true, getString(R.string.profile_empty)) stopRunner(true, getString(R.string.profile_empty))
return Service.START_NOT_STICKY return Service.START_NOT_STICKY
} }
val (profile, fallback) = profilePair
profile.name = profile.formattedName // save name for later queries profile.name = profile.formattedName // save name for later queries
data.profile = profile val proxy = ProxyInstance(profile)
data.proxy = proxy
TrafficMonitor.reset() if (fallback != null) data.udpFallback = ProxyInstance(fallback, profile.route)
val thread = TrafficMonitorThread()
thread.start()
data.trafficMonitorThread = thread
if (!data.closeReceiverRegistered) { if (!data.closeReceiverRegistered) {
// register close receiver // register close receiver
...@@ -354,55 +294,15 @@ object BaseService { ...@@ -354,55 +294,15 @@ object BaseService {
thread("$tag-Connecting") { thread("$tag-Connecting") {
try { try {
if (profile.host == "198.199.101.152") { proxy.init()
val client = OkHttpClient.Builder() data.udpFallback?.init()
.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)
// Clean up // Clean up
killProcesses() 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() startNativeProcesses()
if (profile.route !in arrayOf(Acl.ALL, Acl.CUSTOM_RULES)) AclSyncer.schedule(profile.route) proxy.scheduleUpdate()
data.udpFallback?.scheduleUpdate()
RemoteConfig.fetch() RemoteConfig.fetch()
data.changeState(CONNECTED) data.changeState(CONNECTED)
......
...@@ -35,7 +35,7 @@ object LocalDnsService { ...@@ -35,7 +35,7 @@ object LocalDnsService {
override fun startNativeProcesses() { override fun startNativeProcesses() {
super.startNativeProcesses() super.startNativeProcesses()
val data = data 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 { fun makeDns(name: String, address: String, timeout: Int, edns: Boolean = true) = JSONObject().apply {
put("Name", name) 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 ...@@ -35,10 +35,10 @@ import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import com.github.shadowsocks.Core import com.github.shadowsocks.Core
import com.github.shadowsocks.aidl.IShadowsocksServiceCallback import com.github.shadowsocks.aidl.IShadowsocksServiceCallback
import com.github.shadowsocks.aidl.TrafficStats
import com.github.shadowsocks.core.R import com.github.shadowsocks.core.R
import com.github.shadowsocks.utils.Action import com.github.shadowsocks.utils.Action
import com.github.shadowsocks.utils.broadcastReceiver import com.github.shadowsocks.utils.broadcastReceiver
import java.util.*
/** /**
* Android < 8 VPN: always invisible because of VPN notification/icon * Android < 8 VPN: always invisible because of VPN notification/icon
...@@ -53,13 +53,15 @@ class ServiceNotification(private val service: BaseService.Interface, profileNam ...@@ -53,13 +53,15 @@ class ServiceNotification(private val service: BaseService.Interface, profileNam
private val callback: IShadowsocksServiceCallback by lazy { private val callback: IShadowsocksServiceCallback by lazy {
object : IShadowsocksServiceCallback.Stub() { object : IShadowsocksServiceCallback.Stub() {
override fun stateChanged(state: Int, profileName: String?, msg: String?) { } // ignore 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 service as Context
val txr = service.getString(R.string.speed, Formatter.formatFileSize(service, txRate)) val txr = service.getString(R.string.speed, Formatter.formatFileSize(service, stats.txRate))
val rxr = service.getString(R.string.speed, Formatter.formatFileSize(service, rxRate)) val rxr = service.getString(R.string.speed, Formatter.formatFileSize(service, stats.rxRate))
builder.setContentText("$txr↑\t$rxr↓") builder.setContentText("$txr↑\t$rxr↓")
style.bigText(service.getString(R.string.stat_summary, txr, 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() show()
} }
override fun trafficPersisted(profileId: Long) { } override fun trafficPersisted(profileId: Long) { }
......
...@@ -20,68 +20,72 @@ ...@@ -20,68 +20,72 @@
package com.github.shadowsocks.bg package com.github.shadowsocks.bg
import android.net.LocalSocket
import android.os.SystemClock 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 { class TrafficMonitor(statFile: File) : AutoCloseable {
// Bytes per second private val thread = object : LocalSocketListener("TrafficMonitor") {
var txRate = 0L override val socketFile = statFile
var rxRate = 0L
// Bytes for the current session override fun accept(socket: LocalSocket) {
var txTotal = 0L try {
var rxTotal = 0L 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 val current = TrafficStats()
private var txLast = 0L var out = TrafficStats()
private var rxLast = 0L
private var timestampLast = 0L private var timestampLast = 0L
@Volatile @Volatile
private var dirty = true private var dirty = false
fun updateRate(): Boolean { fun requestUpdate(): Pair<TrafficStats, Boolean> {
val now = SystemClock.elapsedRealtime() val now = SystemClock.elapsedRealtime()
val delta = now - timestampLast val delta = now - timestampLast
timestampLast = now timestampLast = now
var updated = false var updated = false
if (delta != 0L) if (delta != 0L) {
if (dirty) { if (dirty) {
txRate = (txTotal - txLast) * 1000 / delta out = current.copy().apply {
rxRate = (rxTotal - rxLast) * 1000 / delta txRate = (txTotal - out.txTotal) * 1000 / delta
txLast = txTotal rxRate = (rxTotal - out.rxTotal) * 1000 / delta
rxLast = rxTotal }
dirty = false dirty = false
updated = true updated = true
} else { } else {
if (txRate != 0L) { if (out.txRate != 0L) {
txRate = 0 out.txRate = 0
updated = true updated = true
} }
if (rxRate != 0L) { if (out.rxRate != 0L) {
rxRate = 0 out.rxRate = 0
updated = true 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() { override fun close() = thread.stopThread()
txRate = 0
rxRate = 0
txTotal = 0
rxTotal = 0
txLast = 0
rxLast = 0
dirty = true
}
} }
...@@ -40,13 +40,14 @@ class TransproxyService : Service(), LocalDnsService.Interface { ...@@ -40,13 +40,14 @@ class TransproxyService : Service(), LocalDnsService.Interface {
super<LocalDnsService.Interface>.onStartCommand(intent, flags, startId) super<LocalDnsService.Interface>.onStartCommand(intent, flags, startId)
private fun startDNSTunnel() { private fun startDNSTunnel() {
val proxy = data.proxy!!
val cmd = arrayListOf(File(applicationInfo.nativeLibraryDir, Executable.SS_TUNNEL).absolutePath, val cmd = arrayListOf(File(applicationInfo.nativeLibraryDir, Executable.SS_TUNNEL).absolutePath,
"-t", "10", "-t", "10",
"-b", DataStore.listenAddress, "-b", DataStore.listenAddress,
"-u", "-u",
"-l", DataStore.portLocalDns.toString(), // ss-tunnel listens on the same port as overture "-l", DataStore.portLocalDns.toString(), // ss-tunnel listens on the same port as overture
"-L", data.profile!!.remoteDns.split(",").first().trim() + ":53", "-L", proxy.profile.remoteDns.split(",").first().trim() + ":53",
"-c", data.shadowsocksConfigFile!!.absolutePath) // config is already built by BaseService.Interface "-c", proxy.configFile!!.absolutePath) // config is already built by BaseService.Interface
if (DataStore.tcpFastOpen) cmd += "--fast-open" if (DataStore.tcpFastOpen) cmd += "--fast-open"
data.processes.start(cmd) data.processes.start(cmd)
} }
...@@ -74,6 +75,6 @@ redsocks { ...@@ -74,6 +75,6 @@ redsocks {
override fun startNativeProcesses() { override fun startNativeProcesses() {
startRedsocksDaemon() startRedsocksDaemon()
super.startNativeProcesses() super.startNativeProcesses()
if (data.profile!!.udpdns) startDNSTunnel() if (data.proxy!!.profile.udpdns) startDNSTunnel()
} }
} }
...@@ -186,7 +186,7 @@ class VpnService : BaseVpnService(), LocalDnsService.Interface { ...@@ -186,7 +186,7 @@ class VpnService : BaseVpnService(), LocalDnsService.Interface {
} }
private fun startVpn(): Int { private fun startVpn(): Int {
val profile = data.profile!! val profile = data.proxy!!.profile
val builder = Builder() val builder = Builder()
.setConfigureIntent(Core.configureIntent(this)) .setConfigureIntent(Core.configureIntent(this))
.setSession(profile.formattedName) .setSession(profile.formattedName)
......
...@@ -23,18 +23,20 @@ package com.github.shadowsocks.database ...@@ -23,18 +23,20 @@ package com.github.shadowsocks.database
import androidx.room.Database import androidx.room.Database
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
import com.github.shadowsocks.Core.app import com.github.shadowsocks.Core.app
import com.github.shadowsocks.database.migration.RecreateSchemaMigration import com.github.shadowsocks.database.migration.RecreateSchemaMigration
import com.github.shadowsocks.utils.Key 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() { abstract class PrivateDatabase : RoomDatabase() {
companion object { companion object {
private val instance by lazy { private val instance by lazy {
Room.databaseBuilder(app, PrivateDatabase::class.java, Key.DB_PROFILE) Room.databaseBuilder(app, PrivateDatabase::class.java, Key.DB_PROFILE)
.addMigrations( .addMigrations(
Migration26 Migration26,
Migration27
) )
.fallbackToDestructiveMigration() .fallbackToDestructiveMigration()
.allowMainThreadQueries() .allowMainThreadQueries()
...@@ -47,7 +49,7 @@ abstract class PrivateDatabase : RoomDatabase() { ...@@ -47,7 +49,7 @@ abstract class PrivateDatabase : RoomDatabase() {
abstract fun profileDao(): Profile.Dao abstract fun profileDao(): Profile.Dao
abstract fun keyValuePairDao(): KeyValuePair.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` 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`") { "`id`, `name`, `host`, `remotePort`, `password`, `method`, `route`, `remoteDns`, `proxyApps`, `bypass`, `udpdns`, `ipv6`, `individual`, `tx`, `rx`, `userOrder`, `plugin`") {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(database: SupportSQLiteDatabase) {
...@@ -55,4 +57,8 @@ abstract class PrivateDatabase : RoomDatabase() { ...@@ -55,4 +57,8 @@ abstract class PrivateDatabase : RoomDatabase() {
PublicDatabase.Migration3.migrate(database) 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 { ...@@ -102,16 +102,18 @@ class Profile : Serializable {
}.filterNotNull() }.filterNotNull()
private class JsonParser(private val feature: Profile? = null) : ArrayList<Profile>() { 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") val host = json.optString("server")
if (host.isNullOrEmpty()) return if (host.isNullOrEmpty()) return null
val remotePort = json.optInt("server_port") val remotePort = json.optInt("server_port")
if (remotePort <= 0) return if (remotePort <= 0) return null
val password = json.optString("password") val password = json.optString("password")
if (password.isNullOrEmpty()) return if (password.isNullOrEmpty()) return null
val method = json.optString("method") val method = json.optString("method")
if (method.isNullOrEmpty()) return if (method.isNullOrEmpty()) return null
add(Profile().also { return Profile().also {
it.host = host it.host = host
it.remotePort = remotePort it.remotePort = remotePort
it.password = password it.password = password
...@@ -124,6 +126,7 @@ class Profile : Serializable { ...@@ -124,6 +126,7 @@ class Profile : Serializable {
} }
name = json.optString("remarks") name = json.optString("remarks")
route = json.optString("route", route) route = json.optString("route", route)
if (fallback) return@apply
remoteDns = json.optString("remote_dns", remoteDns) remoteDns = json.optString("remote_dns", remoteDns)
ipv6 = json.optBoolean("ipv6", ipv6) ipv6 = json.optBoolean("ipv6", ipv6)
json.optJSONObject("proxy_apps")?.also { json.optJSONObject("proxy_apps")?.also {
...@@ -132,22 +135,41 @@ class Profile : Serializable { ...@@ -132,22 +135,41 @@ class Profile : Serializable {
individual = it.optJSONArray("android_list")?.asIterable()?.joinToString("\n") ?: individual individual = it.optJSONArray("android_list")?.asIterable()?.joinToString("\n") ?: individual
} }
udpdns = json.optBoolean("udpdns", udpdns) udpdns = json.optBoolean("udpdns", udpdns)
}) json.optJSONObject("udp_fallback")?.let { tryParse(it, true) }?.also { fallbackMap[this] = it }
}
} }
fun process(json: Any) { fun process(json: Any) {
when (json) { when (json) {
is JSONObject -> { is JSONObject -> {
tryAdd(json) val profile = tryParse(json)
for (key in json.keys()) process(json.get(key)) if (profile != null) add(profile) else for (key in json.keys()) process(json.get(key))
} }
is JSONArray -> json.asIterable().forEach(this::process) is JSONArray -> json.asIterable().forEach(this::process)
// ignore other types // 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 @androidx.room.Dao
...@@ -195,6 +217,7 @@ class Profile : Serializable { ...@@ -195,6 +217,7 @@ class Profile : Serializable {
var rx: Long = 0 var rx: Long = 0
var userOrder: Long = 0 var userOrder: Long = 0
var plugin: String? = null var plugin: String? = null
var udpFallback: Long? = null
@Ignore // not persisted in db, only used by direct boot @Ignore // not persisted in db, only used by direct boot
var dirty: Boolean = false var dirty: Boolean = false
...@@ -226,12 +249,12 @@ class Profile : Serializable { ...@@ -226,12 +249,12 @@ class Profile : Serializable {
} }
override fun toString() = toUri().toString() 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", host)
put("server_port", remotePort) put("server_port", remotePort)
put("password", password) put("password", password)
put("method", method) put("method", method)
if (compat) return@apply if (profiles == null) return@apply
PluginConfiguration(plugin ?: "").selectedOptions.also { PluginConfiguration(plugin ?: "").selectedOptions.also {
if (it.id.isNotEmpty()) { if (it.id.isNotEmpty()) {
put("plugin", it.id) put("plugin", it.id)
...@@ -251,9 +274,12 @@ class Profile : Serializable { ...@@ -251,9 +274,12 @@ class Profile : Serializable {
} }
}) })
put("udpdns", udpdns) put("udpdns", udpdns)
val fallback = profiles[udpFallback]
if (fallback != null && fallback.plugin.isNullOrEmpty()) fallback.toJson().also { put("udp_fallback", it) }
} }
fun serialize() { fun serialize() {
DataStore.editingId = id
DataStore.privateStore.putString(Key.name, name) DataStore.privateStore.putString(Key.name, name)
DataStore.privateStore.putString(Key.host, host) DataStore.privateStore.putString(Key.host, host)
DataStore.privateStore.putString(Key.remotePort, remotePort.toString()) DataStore.privateStore.putString(Key.remotePort, remotePort.toString())
...@@ -267,9 +293,12 @@ class Profile : Serializable { ...@@ -267,9 +293,12 @@ class Profile : Serializable {
DataStore.privateStore.putBoolean(Key.ipv6, ipv6) DataStore.privateStore.putBoolean(Key.ipv6, ipv6)
DataStore.individual = individual DataStore.individual = individual
DataStore.plugin = plugin ?: "" DataStore.plugin = plugin ?: ""
DataStore.udpFallback = udpFallback
DataStore.privateStore.remove(Key.dirty) DataStore.privateStore.remove(Key.dirty)
} }
fun deserialize() { 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 // 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) ?: "" name = DataStore.privateStore.getString(Key.name) ?: ""
host = DataStore.privateStore.getString(Key.host) ?: "" host = DataStore.privateStore.getString(Key.host) ?: ""
...@@ -284,5 +313,6 @@ class Profile : Serializable { ...@@ -284,5 +313,6 @@ class Profile : Serializable {
ipv6 = DataStore.privateStore.getBoolean(Key.ipv6, false) ipv6 = DataStore.privateStore.getBoolean(Key.ipv6, false)
individual = DataStore.individual individual = DataStore.individual
plugin = DataStore.plugin plugin = DataStore.plugin
udpFallback = DataStore.udpFallback
} }
} }
...@@ -21,6 +21,7 @@ ...@@ -21,6 +21,7 @@
package com.github.shadowsocks.database package com.github.shadowsocks.database
import android.database.sqlite.SQLiteCantOpenDatabaseException import android.database.sqlite.SQLiteCantOpenDatabaseException
import com.github.shadowsocks.Core
import com.github.shadowsocks.preference.DataStore import com.github.shadowsocks.preference.DataStore
import com.github.shadowsocks.utils.DirectBoot import com.github.shadowsocks.utils.DirectBoot
import com.github.shadowsocks.utils.printLog import com.github.shadowsocks.utils.printLog
...@@ -63,11 +64,14 @@ object ProfileManager { ...@@ -63,11 +64,14 @@ object ProfileManager {
null null
} }
@Throws(IOException::class)
fun expand(profile: Profile): Pair<Profile, Profile?> = Pair(profile, profile.udpFallback?.let { getProfile(it) })
@Throws(SQLException::class) @Throws(SQLException::class)
fun delProfile(id: Long) { fun delProfile(id: Long) {
check(PrivateDatabase.profileDao.delete(id) == 1) check(PrivateDatabase.profileDao.delete(id) == 1)
listener?.onRemove(id) listener?.onRemove(id)
if (id == DataStore.profileId && DataStore.directBootAware) DirectBoot.clean() if (id in Core.activeProfileIds && DataStore.directBootAware) DirectBoot.clean()
} }
@Throws(SQLException::class) @Throws(SQLException::class)
......
...@@ -105,6 +105,9 @@ object DataStore : OnPreferenceDataStoreChangeListener { ...@@ -105,6 +105,9 @@ object DataStore : OnPreferenceDataStoreChangeListener {
if (publicStore.getString(Key.portTransproxy) == null) portTransproxy = portTransproxy 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 var proxyApps: Boolean
get() = privateStore.getBoolean(Key.proxyApps) ?: false get() = privateStore.getBoolean(Key.proxyApps) ?: false
set(value) = privateStore.putBoolean(Key.proxyApps, value) set(value) = privateStore.putBoolean(Key.proxyApps, value)
...@@ -117,6 +120,9 @@ object DataStore : OnPreferenceDataStoreChangeListener { ...@@ -117,6 +120,9 @@ object DataStore : OnPreferenceDataStoreChangeListener {
var plugin: String var plugin: String
get() = privateStore.getString(Key.plugin) ?: "" get() = privateStore.getString(Key.plugin) ?: ""
set(value) = privateStore.putString(Key.plugin, value) 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 var dirty: Boolean
get() = privateStore.getBoolean(Key.dirty) ?: false get() = privateStore.getBoolean(Key.dirty) ?: false
set(value) = privateStore.putBoolean(Key.dirty, value) set(value) = privateStore.putBoolean(Key.dirty, value)
......
...@@ -59,6 +59,7 @@ object Key { ...@@ -59,6 +59,7 @@ object Key {
const val plugin = "plugin" const val plugin = "plugin"
const val pluginConfigure = "plugin.configure" const val pluginConfigure = "plugin.configure"
const val udpFallback = "udpFallback"
const val dirty = "profileDirty" const val dirty = "profileDirty"
......
...@@ -21,24 +21,28 @@ object DirectBoot : BroadcastReceiver() { ...@@ -21,24 +21,28 @@ object DirectBoot : BroadcastReceiver() {
private val file = File(Core.deviceStorage.noBackupFilesDir, "directBootProfile") private val file = File(Core.deviceStorage.noBackupFilesDir, "directBootProfile")
private var registered = false private var registered = false
fun getDeviceProfile(): Profile? = try { fun getDeviceProfile(): Pair<Profile, Profile?>? = try {
ObjectInputStream(file.inputStream()).use { it.readObject() as Profile } ObjectInputStream(file.inputStream()).use { it.readObject() as? Pair<Profile, Profile?> }
} catch (_: IOException) { null } } catch (_: IOException) { null }
fun clean() { fun clean() {
file.delete() file.delete()
File(Core.deviceStorage.noBackupFilesDir, BaseService.CONFIG_FILE).delete() File(Core.deviceStorage.noBackupFilesDir, BaseService.CONFIG_FILE).delete()
File(Core.deviceStorage.noBackupFilesDir, BaseService.CONFIG_FILE_UDP).delete()
} }
/** /**
* app.currentProfile will call this. * app.currentProfile will call this.
*/ */
fun update(profile: Profile? = ProfileManager.getProfile(DataStore.profileId)) = 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() { fun flushTrafficStats() {
val profile = getDeviceProfile() getDeviceProfile()?.also { (profile, fallback) ->
if (profile?.dirty == true) ProfileManager.updateProfile(profile) if (profile.dirty) ProfileManager.updateProfile(profile)
if (fallback?.dirty == true) ProfileManager.updateProfile(fallback)
}
update() update()
} }
......
...@@ -80,7 +80,7 @@ class HttpsTest : ViewModel() { ...@@ -80,7 +80,7 @@ class HttpsTest : ViewModel() {
status.value = Status.Testing status.value = Status.Testing
val id = testCount // it would change by other code val id = testCount // it would change by other code
thread("ConnectionTest") { thread("ConnectionTest") {
val url = URL("https", when (Core.currentProfile!!.route) { val url = URL("https", when (Core.currentProfile!!.first.route) {
Acl.CHINALIST -> "www.qualcomm.cn" Acl.CHINALIST -> "www.qualcomm.cn"
else -> "www.google.com" else -> "www.google.com"
}, "/generate_204") }, "/generate_204")
......
Subproject commit 91222766e793a6d1516ac066984b63069594e0bf Subproject commit 7fc05dcd9ddb95db3b557fb9f4dc6c647f2c67c3
...@@ -59,6 +59,7 @@ ...@@ -59,6 +59,7 @@
<string name="tcp_fastopen_failure">Toggle failed</string> <string name="tcp_fastopen_failure">Toggle failed</string>
<string name="udp_dns">DNS Forwarding</string> <string name="udp_dns">DNS Forwarding</string>
<string name="udp_dns_summary">Forward all DNS requests to remote</string> <string name="udp_dns_summary">Forward all DNS requests to remote</string>
<string name="udp_fallback">UDP Fallback</string>
<!-- notification category --> <!-- notification category -->
<string name="service_vpn">VPN Service</string> <string name="service_vpn">VPN Service</string>
......
...@@ -58,6 +58,13 @@ ...@@ -58,6 +58,13 @@
android:excludeFromRecents="true" android:excludeFromRecents="true"
android:launchMode="singleTask"/> android:launchMode="singleTask"/>
<activity
android:name=".UdpFallbackProfileActivity"
android:label="@string/udp_fallback"
android:parentActivityName=".ProfileConfigActivity"
android:excludeFromRecents="true"
android:launchMode="singleTask"/>
<activity <activity
android:name=".ScannerActivity" android:name=".ScannerActivity"
android:label="@string/add_profile_methods_scan_qr_code" android:label="@string/add_profile_methods_scan_qr_code"
......
...@@ -46,6 +46,7 @@ import com.crashlytics.android.Crashlytics ...@@ -46,6 +46,7 @@ import com.crashlytics.android.Crashlytics
import com.github.shadowsocks.acl.CustomRulesFragment import com.github.shadowsocks.acl.CustomRulesFragment
import com.github.shadowsocks.aidl.IShadowsocksService import com.github.shadowsocks.aidl.IShadowsocksService
import com.github.shadowsocks.aidl.IShadowsocksServiceCallback import com.github.shadowsocks.aidl.IShadowsocksServiceCallback
import com.github.shadowsocks.aidl.TrafficStats
import com.github.shadowsocks.bg.BaseService import com.github.shadowsocks.bg.BaseService
import com.github.shadowsocks.bg.Executable import com.github.shadowsocks.bg.Executable
import com.github.shadowsocks.database.Profile import com.github.shadowsocks.database.Profile
...@@ -96,12 +97,14 @@ class MainActivity : AppCompatActivity(), ShadowsocksConnection.Interface, OnPre ...@@ -96,12 +97,14 @@ class MainActivity : AppCompatActivity(), ShadowsocksConnection.Interface, OnPre
override fun stateChanged(state: Int, profileName: String?, msg: String?) { override fun stateChanged(state: Int, profileName: String?, msg: String?) {
Core.handler.post { changeState(state, msg, true) } 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 { Core.handler.post {
stats.updateTraffic(txRate, rxRate, txTotal, rxTotal) if (profileId == 0L) this@MainActivity.stats.updateTraffic(
val child = supportFragmentManager.findFragmentById(R.id.fragment_holder) as ToolbarFragment? stats.txRate, stats.rxRate, stats.txTotal, stats.rxTotal)
if (state != BaseService.STOPPING) if (state != BaseService.STOPPING) {
child?.onTrafficUpdated(profileId, txRate, rxRate, txTotal, rxTotal) (supportFragmentManager.findFragmentById(R.id.fragment_holder) as? ToolbarFragment)
?.onTrafficUpdated(profileId, stats)
}
} }
} }
override fun trafficPersisted(profileId: Long) { override fun trafficPersisted(profileId: Long) {
...@@ -195,7 +198,7 @@ class MainActivity : AppCompatActivity(), ShadowsocksConnection.Interface, OnPre ...@@ -195,7 +198,7 @@ class MainActivity : AppCompatActivity(), ShadowsocksConnection.Interface, OnPre
else -> null else -> null
} }
if (sharedStr.isNullOrEmpty()) return if (sharedStr.isNullOrEmpty()) return
val profiles = Profile.findAllUrls(sharedStr, Core.currentProfile).toList() val profiles = Profile.findAllUrls(sharedStr, Core.currentProfile?.first).toList()
if (profiles.isEmpty()) { if (profiles.isEmpty()) {
snackbar().setText(R.string.profile_invalid_input).show() snackbar().setText(R.string.profile_invalid_input).show()
return return
......
...@@ -63,6 +63,7 @@ class ProfileConfigFragment : PreferenceFragmentCompat(), ...@@ -63,6 +63,7 @@ class ProfileConfigFragment : PreferenceFragmentCompat(),
private lateinit var pluginConfigure: EditTextPreference private lateinit var pluginConfigure: EditTextPreference
private lateinit var pluginConfiguration: PluginConfiguration private lateinit var pluginConfiguration: PluginConfiguration
private lateinit var receiver: BroadcastReceiver private lateinit var receiver: BroadcastReceiver
private lateinit var udpFallback: Preference
override fun onCreatePreferencesFix(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferencesFix(savedInstanceState: Bundle?, rootKey: String?) {
preferenceManager.preferenceDataStore = DataStore.privateStore preferenceManager.preferenceDataStore = DataStore.privateStore
...@@ -101,6 +102,7 @@ class ProfileConfigFragment : PreferenceFragmentCompat(), ...@@ -101,6 +102,7 @@ class ProfileConfigFragment : PreferenceFragmentCompat(),
pluginConfigure.onPreferenceChangeListener = this pluginConfigure.onPreferenceChangeListener = this
initPlugins() initPlugins()
receiver = Core.listenForPackageChanges(false) { initPlugins() } receiver = Core.listenForPackageChanges(false) { initPlugins() }
udpFallback = findPreference(Key.udpFallback)
DataStore.privateStore.registerChangeListener(this) DataStore.privateStore.registerChangeListener(this)
} }
...@@ -128,13 +130,16 @@ class ProfileConfigFragment : PreferenceFragmentCompat(), ...@@ -128,13 +130,16 @@ class ProfileConfigFragment : PreferenceFragmentCompat(),
profile.deserialize() profile.deserialize()
ProfileManager.updateProfile(profile) ProfileManager.updateProfile(profile)
ProfilesFragment.instance?.profilesAdapter?.deepRefreshId(profileId) ProfilesFragment.instance?.profilesAdapter?.deepRefreshId(profileId)
if (DataStore.profileId == profileId && DataStore.directBootAware) DirectBoot.update() if (profileId in Core.activeProfileIds && DataStore.directBootAware) DirectBoot.update()
requireActivity().finish() requireActivity().finish()
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
isProxyApps.isChecked = DataStore.proxyApps // fetch proxyApps updated by AppManager 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 { override fun onPreferenceChange(preference: Preference?, newValue: Any?): Boolean = try {
......
...@@ -39,6 +39,7 @@ import androidx.core.content.getSystemService ...@@ -39,6 +39,7 @@ import androidx.core.content.getSystemService
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.recyclerview.widget.* import androidx.recyclerview.widget.*
import com.github.shadowsocks.aidl.TrafficStats
import com.github.shadowsocks.bg.BaseService import com.github.shadowsocks.bg.BaseService
import com.github.shadowsocks.database.Profile import com.github.shadowsocks.database.Profile
import com.github.shadowsocks.database.ProfileManager import com.github.shadowsocks.database.ProfileManager
...@@ -74,7 +75,7 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener { ...@@ -74,7 +75,7 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
else -> false else -> false
} }
private fun isProfileEditable(id: Long) = when ((activity as MainActivity).state) { private fun isProfileEditable(id: Long) = when ((activity as MainActivity).state) {
BaseService.CONNECTED -> id != DataStore.profileId BaseService.CONNECTED -> id !in Core.activeProfileIds
BaseService.STOPPED -> true BaseService.STOPPED -> true
else -> false else -> false
} }
...@@ -148,7 +149,7 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener { ...@@ -148,7 +149,7 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
edit.alpha = if (editable) 1F else .5F edit.alpha = if (editable) 1F else .5F
var tx = item.tx var tx = item.tx
var rx = item.rx var rx = item.rx
if (item.id == bandwidthProfile) { statsCache[item.id]?.apply {
tx += txTotal tx += txTotal
rx += rxTotal rx += rxTotal
} }
...@@ -300,9 +301,7 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener { ...@@ -300,9 +301,7 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
val profilesAdapter by lazy { ProfilesAdapter() } val profilesAdapter by lazy { ProfilesAdapter() }
private lateinit var undoManager: UndoSnackbarManager<Profile> private lateinit var undoManager: UndoSnackbarManager<Profile>
private var bandwidthProfile = 0L private val statsCache = mutableMapOf<Long, TrafficStats>()
private var txTotal: Long = 0L
private var rxTotal: Long = 0L
private val clipboard by lazy { requireContext().getSystemService<ClipboardManager>()!! } private val clipboard by lazy { requireContext().getSystemService<ClipboardManager>()!! }
...@@ -366,8 +365,10 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener { ...@@ -366,8 +365,10 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
} }
R.id.action_import_clipboard -> { R.id.action_import_clipboard -> {
try { try {
val profiles = Profile.findAllUrls(clipboard.primaryClip!!.getItemAt(0).text, Core.currentProfile) val profiles = Profile.findAllUrls(
.toList() clipboard.primaryClip!!.getItemAt(0).text,
Core.currentProfile?.first
).toList()
if (profiles.isNotEmpty()) { if (profiles.isNotEmpty()) {
profiles.forEach { ProfileManager.createProfile(it) } profiles.forEach { ProfileManager.createProfile(it) }
(activity as MainActivity).snackbar().setText(R.string.action_import_msg).show() (activity as MainActivity).snackbar().setText(R.string.action_import_msg).show()
...@@ -389,7 +390,7 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener { ...@@ -389,7 +390,7 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
} }
R.id.action_manual_settings -> { R.id.action_manual_settings -> {
startConfig(ProfileManager.createProfile( startConfig(ProfileManager.createProfile(
Profile().also { Core.currentProfile?.copyFeatureSettingsTo(it) })) Profile().also { Core.currentProfile?.first?.copyFeatureSettingsTo(it) }))
true true
} }
R.id.action_export_clipboard -> { R.id.action_export_clipboard -> {
...@@ -424,12 +425,12 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener { ...@@ -424,12 +425,12 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
if (resultCode != Activity.RESULT_OK) super.onActivityResult(requestCode, resultCode, data) if (resultCode != Activity.RESULT_OK) super.onActivityResult(requestCode, resultCode, data)
else when (requestCode) { else when (requestCode) {
REQUEST_IMPORT_PROFILES -> { REQUEST_IMPORT_PROFILES -> {
val feature = Core.currentProfile val feature = Core.currentProfile?.first
var success = false var success = false
val activity = activity as MainActivity val activity = activity as MainActivity
for (uri in data!!.datas) try { for (uri in data!!.datas) try {
Profile.parseJson(activity.contentResolver.openInputStream(uri)!!.bufferedReader().readText(), Profile.parseJson(activity.contentResolver.openInputStream(uri)!!.bufferedReader().readText(),
feature).forEach { feature) {
ProfileManager.createProfile(it) ProfileManager.createProfile(it)
success = true success = true
} }
...@@ -442,8 +443,9 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener { ...@@ -442,8 +443,9 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
REQUEST_EXPORT_PROFILES -> { REQUEST_EXPORT_PROFILES -> {
val profiles = ProfileManager.getAllProfiles() val profiles = ProfileManager.getAllProfiles()
if (profiles != null) try { if (profiles != null) try {
val lookup = profiles.associateBy { it.id }
requireContext().contentResolver.openOutputStream(data?.data!!)!!.bufferedWriter().use { 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) { } catch (e: Exception) {
printLog(e) printLog(e)
...@@ -454,24 +456,14 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener { ...@@ -454,24 +456,14 @@ class ProfilesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener {
} }
} }
override fun onTrafficUpdated(profileId: Long, txRate: Long, rxRate: Long, txTotal: Long, rxTotal: Long) { override fun onTrafficUpdated(profileId: Long, stats: TrafficStats) {
if (profileId != -1L) { // ignore resets from MainActivity if (profileId != 0L) { // ignore aggregate stats
if (bandwidthProfile != profileId) { statsCache[profileId] = stats
onTrafficPersisted(bandwidthProfile)
bandwidthProfile = profileId
}
this.txTotal = txTotal
this.rxTotal = rxTotal
profilesAdapter.refreshId(profileId) profilesAdapter.refreshId(profileId)
} }
} }
fun onTrafficPersisted(profileId: Long) { fun onTrafficPersisted(profileId: Long) {
txTotal = 0 statsCache.remove(profileId)
rxTotal = 0
if (bandwidthProfile != profileId) {
onTrafficPersisted(bandwidthProfile)
bandwidthProfile = profileId
}
profilesAdapter.deepRefreshId(profileId) profilesAdapter.deepRefreshId(profileId)
} }
......
...@@ -105,7 +105,7 @@ class ScannerActivity : AppCompatActivity(), BarcodeRetriever { ...@@ -105,7 +105,7 @@ class ScannerActivity : AppCompatActivity(), BarcodeRetriever {
} }
override fun onRetrieved(barcode: Barcode) = runOnUiThread { 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() onSupportNavigateUp()
} }
override fun onRetrievedMultiple(closetToClick: Barcode?, barcode: MutableList<BarcodeGraphic>?) = check(false) override fun onRetrievedMultiple(closetToClick: Barcode?, barcode: MutableList<BarcodeGraphic>?) = check(false)
...@@ -136,7 +136,7 @@ class ScannerActivity : AppCompatActivity(), BarcodeRetriever { ...@@ -136,7 +136,7 @@ class ScannerActivity : AppCompatActivity(), BarcodeRetriever {
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when (requestCode) { when (requestCode) {
REQUEST_IMPORT, REQUEST_IMPORT_OR_FINISH -> if (resultCode == Activity.RESULT_OK) { REQUEST_IMPORT, REQUEST_IMPORT_OR_FINISH -> if (resultCode == Activity.RESULT_OK) {
val feature = Core.currentProfile val feature = Core.currentProfile?.first
var success = false var success = false
for (uri in data!!.datas) try { for (uri in data!!.datas) try {
detector.detect(Frame.Builder().setBitmap(contentResolver.openBitmap(uri)).build()) detector.detect(Frame.Builder().setBitmap(contentResolver.openBitmap(uri)).build())
......
...@@ -25,6 +25,7 @@ import android.view.View ...@@ -25,6 +25,7 @@ import android.view.View
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.core.view.GravityCompat import androidx.core.view.GravityCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.github.shadowsocks.aidl.TrafficStats
/** /**
* @author Mygod * @author Mygod
...@@ -39,7 +40,7 @@ open class ToolbarFragment : Fragment() { ...@@ -39,7 +40,7 @@ open class ToolbarFragment : Fragment() {
toolbar.setNavigationOnClickListener { (activity as MainActivity).drawer.openDrawer(GravityCompat.START) } 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 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 ...@@ -44,7 +44,6 @@ import com.github.shadowsocks.ToolbarFragment
import com.github.shadowsocks.bg.BaseService import com.github.shadowsocks.bg.BaseService
import com.github.shadowsocks.utils.Subnet import com.github.shadowsocks.utils.Subnet
import com.github.shadowsocks.utils.asIterable import com.github.shadowsocks.utils.asIterable
import com.github.shadowsocks.utils.printLog
import com.github.shadowsocks.utils.resolveResourceId import com.github.shadowsocks.utils.resolveResourceId
import com.github.shadowsocks.widget.UndoSnackbarManager import com.github.shadowsocks.widget.UndoSnackbarManager
import com.google.android.material.textfield.TextInputLayout import com.google.android.material.textfield.TextInputLayout
...@@ -339,7 +338,7 @@ class CustomRulesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener, ...@@ -339,7 +338,7 @@ class CustomRulesFragment : ToolbarFragment(), Toolbar.OnMenuItemClickListener,
} }
private val isEnabled get() = when ((activity as MainActivity).state) { 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 BaseService.STOPPED -> true
else -> false else -> false
} }
......
...@@ -31,6 +31,7 @@ import com.github.shadowsocks.R ...@@ -31,6 +31,7 @@ import com.github.shadowsocks.R
import com.github.shadowsocks.ShadowsocksConnection import com.github.shadowsocks.ShadowsocksConnection
import com.github.shadowsocks.aidl.IShadowsocksService import com.github.shadowsocks.aidl.IShadowsocksService
import com.github.shadowsocks.aidl.IShadowsocksServiceCallback import com.github.shadowsocks.aidl.IShadowsocksServiceCallback
import com.github.shadowsocks.aidl.TrafficStats
import com.github.shadowsocks.preference.DataStore import com.github.shadowsocks.preference.DataStore
@RequiresApi(24) @RequiresApi(24)
...@@ -65,7 +66,7 @@ class TileService : BaseTileService(), ShadowsocksConnection.Interface { ...@@ -65,7 +66,7 @@ class TileService : BaseTileService(), ShadowsocksConnection.Interface {
tile.label = label ?: getString(R.string.app_name) tile.label = label ?: getString(R.string.app_name)
tile.updateTile() 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) { } 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 @@ ...@@ -86,6 +86,13 @@
android:persistent="false" android:persistent="false"
app:pref_summaryHasText="%s" app:pref_summaryHasText="%s"
android:title="@string/plugin_configure"/> 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> </PreferenceCategory>
......
...@@ -45,6 +45,7 @@ import com.github.shadowsocks.Core ...@@ -45,6 +45,7 @@ import com.github.shadowsocks.Core
import com.github.shadowsocks.ShadowsocksConnection import com.github.shadowsocks.ShadowsocksConnection
import com.github.shadowsocks.aidl.IShadowsocksService import com.github.shadowsocks.aidl.IShadowsocksService
import com.github.shadowsocks.aidl.IShadowsocksServiceCallback import com.github.shadowsocks.aidl.IShadowsocksServiceCallback
import com.github.shadowsocks.aidl.TrafficStats
import com.github.shadowsocks.bg.BaseService import com.github.shadowsocks.bg.BaseService
import com.github.shadowsocks.bg.Executable import com.github.shadowsocks.bg.Executable
import com.github.shadowsocks.database.Profile import com.github.shadowsocks.database.Profile
...@@ -93,11 +94,12 @@ class MainPreferenceFragment : LeanbackPreferenceFragment(), ShadowsocksConnecti ...@@ -93,11 +94,12 @@ class MainPreferenceFragment : LeanbackPreferenceFragment(), ShadowsocksConnecti
override fun stateChanged(state: Int, profileName: String?, msg: String?) { override fun stateChanged(state: Int, profileName: String?, msg: String?) {
Core.handler.post { changeState(state, msg) } Core.handler.post { changeState(state, msg) }
} }
override fun trafficUpdated(profileId: Long, txRate: Long, rxRate: Long, txTotal: Long, rxTotal: Long) { override fun trafficUpdated(profileId: Long, stats: TrafficStats) {
stats.summary = getString(R.string.stat_summary, if (profileId == 0L) this@MainPreferenceFragment.stats.summary = getString(R.string.stat_summary,
getString(R.string.speed, Formatter.formatFileSize(activity, txRate)), getString(R.string.speed, Formatter.formatFileSize(activity, stats.txRate)),
getString(R.string.speed, Formatter.formatFileSize(activity, rxRate)), getString(R.string.speed, Formatter.formatFileSize(activity, stats.rxRate)),
Formatter.formatFileSize(activity, txTotal), Formatter.formatFileSize(activity, rxTotal)) Formatter.formatFileSize(activity, stats.txTotal),
Formatter.formatFileSize(activity, stats.rxTotal))
} }
override fun trafficPersisted(profileId: Long) { } override fun trafficPersisted(profileId: Long) { }
} }
...@@ -115,7 +117,7 @@ class MainPreferenceFragment : LeanbackPreferenceFragment(), ShadowsocksConnecti ...@@ -115,7 +117,7 @@ class MainPreferenceFragment : LeanbackPreferenceFragment(), ShadowsocksConnecti
stats.isVisible = state == BaseService.CONNECTED stats.isVisible = state == BaseService.CONNECTED
val owner = activity as FragmentActivity // TODO: change to this when refactored to androidx val owner = activity as FragmentActivity // TODO: change to this when refactored to androidx
if (state != BaseService.CONNECTED) { if (state != BaseService.CONNECTED) {
serviceCallback.trafficUpdated(0, 0, 0, 0, 0) serviceCallback.trafficUpdated(0, TrafficStats())
tester.status.removeObservers(owner) tester.status.removeObservers(owner)
if (state != BaseService.IDLE) tester.invalidate() if (state != BaseService.IDLE) tester.invalidate()
} else tester.status.observe(owner, Observer { } else tester.status.observe(owner, Observer {
...@@ -300,7 +302,7 @@ class MainPreferenceFragment : LeanbackPreferenceFragment(), ShadowsocksConnecti ...@@ -300,7 +302,7 @@ class MainPreferenceFragment : LeanbackPreferenceFragment(), ShadowsocksConnecti
ProfileManager.clear() ProfileManager.clear()
for (uri in data!!.datas) try { for (uri in data!!.datas) try {
Profile.parseJson(activity.contentResolver.openInputStream(uri)!!.bufferedReader().readText(), 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 // if two profiles has the same address, treat them as the same profile and copy stats over
profiles?.get(it.formattedAddress)?.apply { profiles?.get(it.formattedAddress)?.apply {
it.tx = tx it.tx = tx
...@@ -318,8 +320,9 @@ class MainPreferenceFragment : LeanbackPreferenceFragment(), ShadowsocksConnecti ...@@ -318,8 +320,9 @@ class MainPreferenceFragment : LeanbackPreferenceFragment(), ShadowsocksConnecti
if (resultCode != Activity.RESULT_OK) return if (resultCode != Activity.RESULT_OK) return
val profiles = ProfileManager.getAllProfiles() val profiles = ProfileManager.getAllProfiles()
if (profiles != null) try { if (profiles != null) try {
val lookup = profiles.associateBy { it.id }
activity.contentResolver.openOutputStream(data?.data!!)!!.bufferedWriter().use { 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) { } catch (e: Exception) {
printLog(e) 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