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 { ...@@ -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)
}
}
...@@ -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