diff --git a/core/schemas/com.github.shadowsocks.database.PrivateDatabase/28.json b/core/schemas/com.github.shadowsocks.database.PrivateDatabase/28.json new file mode 100644 index 0000000000..b1e6cb1b05 --- /dev/null +++ b/core/schemas/com.github.shadowsocks.database.PrivateDatabase/28.json @@ -0,0 +1,173 @@ +{ + "formatVersion": 1, + "database": { + "version": 28, + "identityHash": "b60ecca4d684ffe73173478bffd50a17", + "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, `metered` 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": "metered", + "columnName": "metered", + "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, \"b60ecca4d684ffe73173478bffd50a17\")" + ] + } +} \ No newline at end of file diff --git a/core/src/androidTest/java/com/github/shadowsocks/database/MigrationTest.kt b/core/src/androidTest/java/com/github/shadowsocks/database/MigrationTest.kt index 1b467d88b1..09658c9d06 100644 --- a/core/src/androidTest/java/com/github/shadowsocks/database/MigrationTest.kt +++ b/core/src/androidTest/java/com/github/shadowsocks/database/MigrationTest.kt @@ -46,4 +46,11 @@ class MigrationTest { db.close() privateDatabase.runMigrationsAndValidate(TEST_DB, 27, true, PrivateDatabase.Migration27) } + @Test + @Throws(IOException::class) + fun migrate28() { + val db = privateDatabase.createDatabase(TEST_DB, 27) + db.close() + privateDatabase.runMigrationsAndValidate(TEST_DB, 28, true, PrivateDatabase.Migration28) + } } diff --git a/core/src/main/java/com/github/shadowsocks/bg/VpnService.kt b/core/src/main/java/com/github/shadowsocks/bg/VpnService.kt index 40f0411a02..3e74d401aa 100644 --- a/core/src/main/java/com/github/shadowsocks/bg/VpnService.kt +++ b/core/src/main/java/com/github/shadowsocks/bg/VpnService.kt @@ -101,12 +101,15 @@ class VpnService : BaseVpnService(), LocalDnsService.Interface { private var conn: ParcelFileDescriptor? = null private var worker: ProtectWorker? = null private var active = false + private var metered = false private var underlyingNetwork: Network? = null set(value) { field = value if (active && Build.VERSION.SDK_INT >= 22) setUnderlyingNetworks(underlyingNetworks) } - private val underlyingNetworks get() = underlyingNetwork?.let { arrayOf(it) } + private val underlyingNetworks get() = + // clearing underlyingNetworks makes Android 9+ consider the network to be metered + if (Build.VERSION.SDK_INT >= 28 && metered) null else underlyingNetwork?.let { arrayOf(it) } override fun onBind(intent: Intent) = when (intent.action) { SERVICE_INTERFACE -> super.onBind(intent) @@ -190,6 +193,7 @@ class VpnService : BaseVpnService(), LocalDnsService.Interface { } } + metered = profile.metered active = true // possible race condition here? if (Build.VERSION.SDK_INT >= 22) builder.setUnderlyingNetworks(underlyingNetworks) diff --git a/core/src/main/java/com/github/shadowsocks/database/PrivateDatabase.kt b/core/src/main/java/com/github/shadowsocks/database/PrivateDatabase.kt index 607ce80be1..24621bf6e3 100644 --- a/core/src/main/java/com/github/shadowsocks/database/PrivateDatabase.kt +++ b/core/src/main/java/com/github/shadowsocks/database/PrivateDatabase.kt @@ -29,14 +29,15 @@ import com.github.shadowsocks.Core.app import com.github.shadowsocks.database.migration.RecreateSchemaMigration import com.github.shadowsocks.utils.Key -@Database(entities = [Profile::class, KeyValuePair::class], version = 27) +@Database(entities = [Profile::class, KeyValuePair::class], version = 28) abstract class PrivateDatabase : RoomDatabase() { companion object { private val instance by lazy { Room.databaseBuilder(app, PrivateDatabase::class.java, Key.DB_PROFILE) .addMigrations( Migration26, - Migration27 + Migration27, + Migration28 ) .fallbackToDestructiveMigration() .allowMainThreadQueries() @@ -61,4 +62,8 @@ abstract class PrivateDatabase : RoomDatabase() { override fun migrate(database: SupportSQLiteDatabase) = database.execSQL("ALTER TABLE `Profile` ADD COLUMN `udpFallback` INTEGER") } + object Migration28 : Migration(27, 28) { + override fun migrate(database: SupportSQLiteDatabase) = + database.execSQL("ALTER TABLE `Profile` ADD COLUMN `metered` INTEGER NOT NULL DEFAULT 0") + } } diff --git a/core/src/main/java/com/github/shadowsocks/database/Profile.kt b/core/src/main/java/com/github/shadowsocks/database/Profile.kt index e80dc53860..d605ce924a 100644 --- a/core/src/main/java/com/github/shadowsocks/database/Profile.kt +++ b/core/src/main/java/com/github/shadowsocks/database/Profile.kt @@ -20,6 +20,7 @@ package com.github.shadowsocks.database +import android.annotation.TargetApi import android.net.Uri import android.os.Parcelable import android.util.Base64 @@ -58,6 +59,8 @@ data class Profile( var bypass: Boolean = false, var udpdns: Boolean = false, var ipv6: Boolean = true, + @TargetApi(28) + var metered: Boolean = false, var individual: String = "", var tx: Long = 0, var rx: Long = 0, @@ -70,7 +73,7 @@ data class Profile( ) : Parcelable, Serializable { companion object { private const val TAG = "ShadowParser" - private const val serialVersionUID = 0L + private const val serialVersionUID = 1L private val pattern = """(?i)ss://[-a-zA-Z0-9+&@#/%?=.~*'()|!:,;\[\]]*[-a-zA-Z0-9+&@#/%=.~*'()|\[\]]""".toRegex() private val userInfoPattern = "^(.+?):(.*)$".toRegex() @@ -156,6 +159,7 @@ data class Profile( if (fallback) return@apply remoteDns = json.optString("remote_dns", remoteDns) ipv6 = json.optBoolean("ipv6", ipv6) + metered = json.optBoolean("metered", metered) json.optJSONObject("proxy_apps")?.also { proxyApps = it.optBoolean("enabled", proxyApps) bypass = it.optBoolean("bypass", bypass) @@ -232,6 +236,7 @@ data class Profile( fun copyFeatureSettingsTo(profile: Profile) { profile.route = route profile.ipv6 = ipv6 + profile.metered = metered profile.proxyApps = proxyApps profile.bypass = bypass profile.individual = individual @@ -269,6 +274,7 @@ data class Profile( put("route", route) put("remote_dns", remoteDns) put("ipv6", ipv6) + put("metered", metered) put("proxy_apps", JSONObject().apply { put("enabled", proxyApps) if (proxyApps) { @@ -295,6 +301,7 @@ data class Profile( DataStore.bypass = bypass DataStore.privateStore.putBoolean(Key.udpdns, udpdns) DataStore.privateStore.putBoolean(Key.ipv6, ipv6) + DataStore.privateStore.putBoolean(Key.metered, metered) DataStore.individual = individual DataStore.plugin = plugin ?: "" DataStore.udpFallback = udpFallback @@ -315,6 +322,7 @@ data class Profile( bypass = DataStore.bypass udpdns = DataStore.privateStore.getBoolean(Key.udpdns, false) ipv6 = DataStore.privateStore.getBoolean(Key.ipv6, false) + metered = DataStore.privateStore.getBoolean(Key.metered, false) individual = DataStore.individual plugin = DataStore.plugin udpFallback = DataStore.udpFallback diff --git a/core/src/main/java/com/github/shadowsocks/utils/Constants.kt b/core/src/main/java/com/github/shadowsocks/utils/Constants.kt index b1e6a60522..fb7f9f549e 100644 --- a/core/src/main/java/com/github/shadowsocks/utils/Constants.kt +++ b/core/src/main/java/com/github/shadowsocks/utils/Constants.kt @@ -50,6 +50,7 @@ object Key { const val bypass = "isBypassApps" const val udpdns = "isUdpDns" const val ipv6 = "isIpv6" + const val metered = "metered" const val host = "proxy" const val password = "sitekey" diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 1fa25802e3..050cbd2e84 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -36,6 +36,8 @@ IPv6 Route Redirect IPv6 traffic to remote + Metered Hint + Hint system to treat VPN as metered Route All Bypass LAN diff --git a/mobile/src/main/java/com/github/shadowsocks/ProfileConfigFragment.kt b/mobile/src/main/java/com/github/shadowsocks/ProfileConfigFragment.kt index e4eba5e2d8..6933fc8c72 100644 --- a/mobile/src/main/java/com/github/shadowsocks/ProfileConfigFragment.kt +++ b/mobile/src/main/java/com/github/shadowsocks/ProfileConfigFragment.kt @@ -24,6 +24,7 @@ import android.app.Activity import android.content.BroadcastReceiver import android.content.DialogInterface import android.content.Intent +import android.os.Build import android.os.Bundle import android.os.Parcelable import android.view.MenuItem @@ -34,10 +35,7 @@ import com.github.shadowsocks.database.Profile import com.github.shadowsocks.database.ProfileManager import com.github.shadowsocks.plugin.* import com.github.shadowsocks.preference.* -import com.github.shadowsocks.utils.Action -import com.github.shadowsocks.utils.DirectBoot -import com.github.shadowsocks.utils.Key -import com.github.shadowsocks.utils.readableMessage +import com.github.shadowsocks.utils.* import com.google.android.material.snackbar.Snackbar import kotlinx.android.parcel.Parcelize @@ -80,6 +78,7 @@ class ProfileConfigFragment : PreferenceFragmentCompat(), findPreference(Key.password)!!.summaryProvider = PasswordSummaryProvider val serviceMode = DataStore.serviceMode findPreference(Key.remoteDns)!!.isEnabled = serviceMode != Key.modeProxy + findPreference(Key.ipv6)!!.isEnabled = serviceMode == Key.modeVpn isProxyApps = findPreference(Key.proxyApps)!! isProxyApps.isEnabled = serviceMode == Key.modeVpn isProxyApps.setOnPreferenceClickListener { @@ -87,6 +86,9 @@ class ProfileConfigFragment : PreferenceFragmentCompat(), isProxyApps.isChecked = true false } + findPreference(Key.metered)!!.apply { + if (Build.VERSION.SDK_INT >= 28) isEnabled = serviceMode == Key.modeVpn else remove() + } findPreference(Key.udpdns)!!.isEnabled = serviceMode != Key.modeProxy plugin = findPreference(Key.plugin)!! pluginConfigure = findPreference(Key.pluginConfigure)!! diff --git a/mobile/src/main/res/drawable/ic_device_data_usage.xml b/mobile/src/main/res/drawable/ic_device_data_usage.xml new file mode 100644 index 0000000000..6431414dd0 --- /dev/null +++ b/mobile/src/main/res/drawable/ic_device_data_usage.xml @@ -0,0 +1,6 @@ + + + diff --git a/mobile/src/main/res/xml/pref_profile.xml b/mobile/src/main/res/xml/pref_profile.xml index 89149f50ab..a4ed0650a3 100644 --- a/mobile/src/main/res/xml/pref_profile.xml +++ b/mobile/src/main/res/xml/pref_profile.xml @@ -62,6 +62,11 @@ app:icon="@drawable/ic_action_dns" app:summary="@string/udp_dns_summary" app:title="@string/udp_dns"/> +