VPN Hotspot 2.0: Client+ (#39)

Fix #13, #38. I don't have a lot of confidence that this would work very well for every device.

Also here's an SQL command that hopefully somebody could make into the app for me: `SELECT TrafficRecord.mac, SUM(TrafficRecord.sentPackets), SUM(TrafficRecord.sentBytes), SUM(TrafficRecord.receivedPackets), SUM(TrafficRecord.receivedBytes) FROM TrafficRecord LEFT JOIN TrafficRecord AS Next ON TrafficRecord.id = Next.previousId WHERE Next.id IS NULL GROUP BY TrafficRecord.mac;`
This commit is contained in:
Mygod
2018-10-02 21:12:19 +08:00
committed by GitHub
parent 16d1eda0d4
commit 38f95a382e
35 changed files with 946 additions and 98 deletions

View File

@@ -77,6 +77,7 @@ API light grey list:
* [`Landroid/net/wifi/p2p/WifiP2pManager;->requestPersistentGroupInfo(Landroid/net/wifi/p2p/WifiP2pManager$Channel;Landroid/net/wifi/p2p/WifiP2pManager$PersistentGroupInfoListener;)V`](https://android.googlesource.com/platform/prebuilts/runtime/+/94fec32/appcompat/hiddenapi-light-greylist.txt#4412) * [`Landroid/net/wifi/p2p/WifiP2pManager;->requestPersistentGroupInfo(Landroid/net/wifi/p2p/WifiP2pManager$Channel;Landroid/net/wifi/p2p/WifiP2pManager$PersistentGroupInfoListener;)V`](https://android.googlesource.com/platform/prebuilts/runtime/+/94fec32/appcompat/hiddenapi-light-greylist.txt#4412)
* [`Landroid/net/wifi/p2p/WifiP2pManager;->setWifiP2pChannels(Landroid/net/wifi/p2p/WifiP2pManager$Channel;IILandroid/net/wifi/p2p/WifiP2pManager$ActionListener;)V`](https://android.googlesource.com/platform/prebuilts/runtime/+/94fec32/appcompat/hiddenapi-light-greylist.txt#4416) * [`Landroid/net/wifi/p2p/WifiP2pManager;->setWifiP2pChannels(Landroid/net/wifi/p2p/WifiP2pManager$Channel;IILandroid/net/wifi/p2p/WifiP2pManager$ActionListener;)V`](https://android.googlesource.com/platform/prebuilts/runtime/+/94fec32/appcompat/hiddenapi-light-greylist.txt#4416)
* [`Landroid/net/wifi/p2p/WifiP2pManager;->startWps(Landroid/net/wifi/p2p/WifiP2pManager$Channel;Landroid/net/wifi/WpsInfo;Landroid/net/wifi/p2p/WifiP2pManager$ActionListener;)V`](https://android.googlesource.com/platform/prebuilts/runtime/+/94fec32/appcompat/hiddenapi-light-greylist.txt#4417) * [`Landroid/net/wifi/p2p/WifiP2pManager;->startWps(Landroid/net/wifi/p2p/WifiP2pManager$Channel;Landroid/net/wifi/WpsInfo;Landroid/net/wifi/p2p/WifiP2pManager$ActionListener;)V`](https://android.googlesource.com/platform/prebuilts/runtime/+/94fec32/appcompat/hiddenapi-light-greylist.txt#4417)
* [`Ljava/net/InetAddress;->parseNumericAddress(Ljava/lang/String;)Ljava/net/InetAddress;`](https://android.googlesource.com/platform/prebuilts/runtime/+/94fec32/appcompat/hiddenapi-light-greylist.txt#9800)
Unlisted private API: Unlisted private API:

View File

@@ -4,8 +4,9 @@ apply plugin: 'com.github.ben-manes.versions'
buildscript { buildscript {
ext { ext {
kotlinVersion = '1.2.70' kotlinVersion = '1.2.71'
androidxVersion = '1.0.0-rc02' androidxVersion = '1.0.0'
roomVersion = '2.0.0'
} }
repositories { repositories {
google() google()
@@ -15,10 +16,10 @@ buildscript {
} }
} }
dependencies { dependencies {
classpath "com.android.tools.build:gradle:3.2.0-rc03" classpath "com.android.tools.build:gradle:3.2.0"
classpath 'com.github.ben-manes:gradle-versions-plugin:0.20.0' classpath 'com.github.ben-manes:gradle-versions-plugin:0.20.0'
classpath 'com.google.gms:google-services:4.1.0' classpath 'com.google.gms:google-services:4.1.0'
classpath 'io.fabric.tools:gradle:1.25.4' classpath 'io.fabric.tools:gradle:1.26.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"
} }
} }

View File

@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.1-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.2-all.zip

View File

@@ -4,7 +4,7 @@ apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt' apply plugin: 'kotlin-kapt'
android { android {
buildToolsVersion "28.0.2" buildToolsVersion "28.0.3"
compileSdkVersion 28 compileSdkVersion 28
defaultConfig { defaultConfig {
applicationId "be.mygod.vpnhotspot" applicationId "be.mygod.vpnhotspot"
@@ -14,6 +14,11 @@ android {
versionCode 36 versionCode 36
versionName "1.4.2" versionName "1.4.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
javaCompileOptions {
annotationProcessorOptions {
arguments = ["room.schemaLocation": "$projectDir/schemas".toString()]
}
}
} }
buildTypes { buildTypes {
debug { debug {
@@ -40,19 +45,22 @@ android {
} }
dependencies { dependencies {
kapt "androidx.room:room-compiler:$roomVersion"
implementation fileTree(dir: 'libs', include: ['*.jar']) implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "androidx.browser:browser:$androidxVersion" implementation "androidx.browser:browser:$androidxVersion"
implementation 'androidx.constraintlayout:constraintlayout:2.0.0-alpha2' implementation 'androidx.constraintlayout:constraintlayout:2.0.0-alpha2'
implementation "androidx.core:core-ktx:$androidxVersion" implementation "androidx.core:core-ktx:$androidxVersion"
implementation "androidx.preference:preference:$androidxVersion" implementation "androidx.preference:preference:$androidxVersion"
implementation "androidx.room:room-runtime:$roomVersion"
implementation 'com.android.billingclient:billing:1.1' implementation 'com.android.billingclient:billing:1.1'
implementation 'com.crashlytics.sdk.android:crashlytics:2.9.5' implementation 'com.crashlytics.sdk.android:crashlytics:2.9.5'
implementation 'com.github.luongvo:BadgeView:1.1.5' implementation 'com.github.luongvo:BadgeView:1.1.5'
implementation 'com.github.topjohnwu:libsu:2.0.2' implementation 'com.github.topjohnwu:libsu:2.0.2'
implementation "com.google.android.material:material:$androidxVersion" implementation "com.google.android.material:material:$androidxVersion"
implementation 'com.linkedin.dexmaker:dexmaker-mockito:2.19.1' implementation 'com.linkedin.dexmaker:dexmaker-mockito:2.19.1'
implementation 'com.takisoft.preferencex:preferencex:1.0.0-alpha2' implementation 'com.takisoft.preferencex:preferencex:1.0.0'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion"
testImplementation "androidx.room:room-testing:$roomVersion"
testImplementation 'junit:junit:4.12' testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test:runner:1.1.0-alpha4' androidTestImplementation 'androidx.test:runner:1.1.0-alpha4'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.0-alpha4' androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.0-alpha4'

View File

@@ -0,0 +1,146 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "4687ed591fd62dcfddd2b6eb990ba1a0",
"entities": [
{
"tableName": "ClientRecord",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mac` INTEGER NOT NULL, `nickname` BLOB NOT NULL, `blocked` INTEGER NOT NULL, PRIMARY KEY(`mac`))",
"fields": [
{
"fieldPath": "mac",
"columnName": "mac",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "nickname",
"columnName": "nickname",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "blocked",
"columnName": "blocked",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"mac"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "TrafficRecord",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `timestamp` INTEGER NOT NULL, `mac` INTEGER NOT NULL, `ip` BLOB NOT NULL, `upstream` TEXT, `downstream` TEXT NOT NULL, `sentPackets` INTEGER NOT NULL, `sentBytes` INTEGER NOT NULL, `receivedPackets` INTEGER NOT NULL, `receivedBytes` INTEGER NOT NULL, `previousId` INTEGER, FOREIGN KEY(`previousId`) REFERENCES `TrafficRecord`(`id`) ON UPDATE RESTRICT ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "mac",
"columnName": "mac",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "ip",
"columnName": "ip",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "upstream",
"columnName": "upstream",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "downstream",
"columnName": "downstream",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sentPackets",
"columnName": "sentPackets",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sentBytes",
"columnName": "sentBytes",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "receivedPackets",
"columnName": "receivedPackets",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "receivedBytes",
"columnName": "receivedBytes",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "previousId",
"columnName": "previousId",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_TrafficRecord_previousId",
"unique": true,
"columnNames": [
"previousId"
],
"createSql": "CREATE UNIQUE INDEX `index_TrafficRecord_previousId` ON `${TABLE_NAME}` (`previousId`)"
}
],
"foreignKeys": [
{
"table": "TrafficRecord",
"onDelete": "CASCADE",
"onUpdate": "RESTRICT",
"columns": [
"previousId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"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, \"4687ed591fd62dcfddd2b6eb990ba1a0\")"
]
}
}

View File

@@ -10,6 +10,7 @@ import android.os.Build
import android.os.Handler import android.os.Handler
import android.preference.PreferenceManager import android.preference.PreferenceManager
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import be.mygod.vpnhotspot.room.AppDatabase
import be.mygod.vpnhotspot.util.DeviceStorageApp import be.mygod.vpnhotspot.util.DeviceStorageApp
import be.mygod.vpnhotspot.util.Event0 import be.mygod.vpnhotspot.util.Event0
import be.mygod.vpnhotspot.util.RootSession import be.mygod.vpnhotspot.util.RootSession
@@ -30,6 +31,7 @@ class App : Application() {
if (Build.VERSION.SDK_INT >= 24) { if (Build.VERSION.SDK_INT >= 24) {
deviceStorage = DeviceStorageApp(this) deviceStorage = DeviceStorageApp(this)
deviceStorage.moveSharedPreferencesFrom(this, PreferenceManager.getDefaultSharedPreferencesName(this)) deviceStorage.moveSharedPreferencesFrom(this, PreferenceManager.getDefaultSharedPreferencesName(this))
deviceStorage.moveDatabaseFrom(this, AppDatabase.DB_NAME)
} else deviceStorage = this } else deviceStorage = this
Fabric.with(deviceStorage, Crashlytics()) Fabric.with(deviceStorage, Crashlytics())
ServiceNotification.updateNotificationChannels() ServiceNotification.updateNotificationChannels()

View File

@@ -33,7 +33,7 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService() {
private var routingManager: LocalOnlyInterfaceManager? = null private var routingManager: LocalOnlyInterfaceManager? = null
private var receiverRegistered = false private var receiverRegistered = false
private val receiver = broadcastReceiver { _, intent -> private val receiver = broadcastReceiver { _, intent ->
val ifaces = TetheringManager.getLocalOnlyTetheredIfaces(intent.extras) val ifaces = TetheringManager.getLocalOnlyTetheredIfaces(intent.extras ?: return@broadcastReceiver)
debugLog(TAG, "onTetherStateChangedLocked: $ifaces") debugLog(TAG, "onTetherStateChangedLocked: $ifaces")
check(ifaces.size <= 1) check(ifaces.size <= 1)
val iface = ifaces.singleOrNull() val iface = ifaces.singleOrNull()
@@ -45,7 +45,7 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService() {
} else { } else {
val routingManager = routingManager val routingManager = routingManager
if (routingManager == null) { if (routingManager == null) {
this.routingManager = LocalOnlyInterfaceManager(iface) this.routingManager = LocalOnlyInterfaceManager(this, iface)
IpNeighbourMonitor.registerCallback(this) IpNeighbourMonitor.registerCallback(this)
} else check(iface == routingManager.downstream) } else check(iface == routingManager.downstream)
} }

View File

@@ -1,5 +1,6 @@
package be.mygod.vpnhotspot package be.mygod.vpnhotspot
import android.content.Context
import be.mygod.vpnhotspot.App.Companion.app import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.net.Routing import be.mygod.vpnhotspot.net.Routing
import be.mygod.vpnhotspot.net.UpstreamMonitor import be.mygod.vpnhotspot.net.UpstreamMonitor
@@ -8,7 +9,7 @@ import com.crashlytics.android.Crashlytics
import java.net.InetAddress import java.net.InetAddress
import java.net.InterfaceAddress import java.net.InterfaceAddress
class LocalOnlyInterfaceManager(val downstream: String) : UpstreamMonitor.Callback { class LocalOnlyInterfaceManager(private val owner: Context, val downstream: String) : UpstreamMonitor.Callback {
private var routing: Routing? = null private var routing: Routing? = null
private var dns = emptyList<InetAddress>() private var dns = emptyList<InetAddress>()
@@ -32,7 +33,7 @@ class LocalOnlyInterfaceManager(val downstream: String) : UpstreamMonitor.Callba
private fun clean() { private fun clean() {
val routing = routing ?: return val routing = routing ?: return
routing.started = false routing.stop()
initRouting(routing.upstream, routing.hostAddress, dns) initRouting(routing.upstream, routing.hostAddress, dns)
} }
@@ -40,22 +41,19 @@ class LocalOnlyInterfaceManager(val downstream: String) : UpstreamMonitor.Callba
dns: List<InetAddress> = this.dns) { dns: List<InetAddress> = this.dns) {
this.dns = dns this.dns = dns
try { try {
this.routing = Routing(upstream, downstream, owner).apply { routing = Routing(this.owner, upstream, downstream, owner, app.strict).apply {
try { try {
val strict = app.strict
if (strict && upstream == null) return@apply // in this case, nothing to be done
if (app.dhcpWorkaround) dhcpWorkaround() if (app.dhcpWorkaround) dhcpWorkaround()
ipForward() // local only interfaces need to enable ip_forward ipForward() // local only interfaces need to enable ip_forward
rule() rule()
forward(strict) forward()
if (app.masquerade) masquerade(strict) if (app.masquerade) masquerade()
dnsRedirect(dns) dnsRedirect(dns)
commit()
} catch (e: Exception) { } catch (e: Exception) {
revert() revert()
throw e throw e
} finally { } // otw nothing needs to be done
commit()
}
} }
} catch (e: Exception) { } catch (e: Exception) {
SmartSnackbar.make(e.localizedMessage).show() SmartSnackbar.make(e.localizedMessage).show()

View File

@@ -238,7 +238,7 @@ class RepeaterService : Service(), WifiP2pManager.ChannelListener, SharedPrefere
private fun doStart(group: WifiP2pGroup) { private fun doStart(group: WifiP2pGroup) {
this.group = group this.group = group
check(routingManager == null) check(routingManager == null)
routingManager = LocalOnlyInterfaceManager(group.`interface`!!) routingManager = LocalOnlyInterfaceManager(this, group.`interface`!!)
status = Status.ACTIVE status = Status.ACTIVE
showNotification(group) showNotification(group)
} }

View File

@@ -31,8 +31,9 @@ class TetheringService : IpNeighbourMonitoringService(), UpstreamMonitor.Callbac
private var dns: List<InetAddress> = emptyList() private var dns: List<InetAddress> = emptyList()
private var receiverRegistered = false private var receiverRegistered = false
private val receiver = broadcastReceiver { _, intent -> private val receiver = broadcastReceiver { _, intent ->
val extras = intent.extras ?: return@broadcastReceiver
synchronized(routings) { synchronized(routings) {
for (iface in routings.keys - TetheringManager.getTetheredIfaces(intent.extras!!)) for (iface in routings.keys - TetheringManager.getTetheredIfaces(extras))
routings.remove(iface)?.revert() routings.remove(iface)?.revert()
updateRoutingsLocked() updateRoutingsLocked()
} }
@@ -65,7 +66,7 @@ class TetheringService : IpNeighbourMonitoringService(), UpstreamMonitor.Callbac
val (downstream, value) = iterator.next() val (downstream, value) = iterator.next()
if (value != null) if (value.upstream == upstream) continue else value.revert() if (value != null) if (value.upstream == upstream) continue else value.revert()
try { try {
routings[downstream] = Routing(upstream, downstream).apply { routings[downstream] = Routing(this, upstream, downstream).apply {
try { try {
if (app.dhcpWorkaround) dhcpWorkaround() if (app.dhcpWorkaround) dhcpWorkaround()
// system tethering already has working forwarding rules // system tethering already has working forwarding rules
@@ -73,15 +74,13 @@ class TetheringService : IpNeighbourMonitoringService(), UpstreamMonitor.Callbac
rule() rule()
// here we always enforce strict mode as fallback is handled by system which we disable // here we always enforce strict mode as fallback is handled by system which we disable
forward() forward()
if (app.strict) overrideSystemRules()
if (app.masquerade) masquerade() if (app.masquerade) masquerade()
if (upstream != null) dnsRedirect(dns) if (upstream != null) dnsRedirect(dns)
if (disableIpv6) disableIpv6() if (disableIpv6) disableIpv6()
commit()
} catch (e: Exception) { } catch (e: Exception) {
revert() revert()
throw e throw e
} finally {
commit()
} }
} }
} catch (e: Exception) { } catch (e: Exception) {

View File

@@ -1,13 +1,26 @@
package be.mygod.vpnhotspot.client package be.mygod.vpnhotspot.client
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.format.Formatter
import android.text.style.StrikethroughSpan
import androidx.databinding.BaseObservable
import androidx.databinding.Bindable
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import be.mygod.vpnhotspot.App.Companion.app import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.R import be.mygod.vpnhotspot.R
import be.mygod.vpnhotspot.net.InetAddressComparator
import be.mygod.vpnhotspot.net.IpNeighbour import be.mygod.vpnhotspot.net.IpNeighbour
import be.mygod.vpnhotspot.net.TetherType import be.mygod.vpnhotspot.net.TetherType
import java.util.* import be.mygod.vpnhotspot.room.AppDatabase
import be.mygod.vpnhotspot.room.lookup
import be.mygod.vpnhotspot.room.macToLong
import be.mygod.vpnhotspot.util.onEmpty
import java.net.InetAddress
import java.util.Objects
import java.util.TreeMap
abstract class Client { abstract class Client : BaseObservable() {
companion object DiffCallback : DiffUtil.ItemCallback<Client>() { companion object DiffCallback : DiffUtil.ItemCallback<Client>() {
override fun areItemsTheSame(oldItem: Client, newItem: Client) = override fun areItemsTheSame(oldItem: Client, newItem: Client) =
oldItem.iface == newItem.iface && oldItem.mac == newItem.mac oldItem.iface == newItem.iface && oldItem.mac == newItem.mac
@@ -16,17 +29,31 @@ abstract class Client {
abstract val iface: String abstract val iface: String
abstract val mac: String abstract val mac: String
val ip = TreeMap<String, IpNeighbour.State>() private val macIface get() = "$mac%$iface"
val ip = TreeMap<InetAddress, IpNeighbour.State>(InetAddressComparator)
val record by lazy { AppDatabase.instance.clientRecordDao.lookup(mac.macToLong()) }
var sendRate = -1L
var receiveRate = -1L
open val icon get() = TetherType.ofInterface(iface).icon open val icon get() = TetherType.ofInterface(iface).icon
val title get() = "$mac%$iface" val title: CharSequence get() {
val description get() = ip.entries.joinToString("\n") { (ip, state) -> val result = SpannableStringBuilder(record.nickname.onEmpty(macIface))
app.getString(when (state) { if (record.blocked) result.setSpan(StrikethroughSpan(), 0, result.length, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
IpNeighbour.State.INCOMPLETE -> R.string.connected_state_incomplete return result
IpNeighbour.State.VALID -> R.string.connected_state_valid }
IpNeighbour.State.FAILED -> R.string.connected_state_failed val description: String @Bindable get() {
else -> throw IllegalStateException("Invalid IpNeighbour.State: $state") val result = StringBuilder(if (record.nickname.isEmpty()) "" else "$macIface\n")
}, ip) ip.entries.forEach { (ip, state) ->
result.appendln(app.getString(when (state) {
IpNeighbour.State.INCOMPLETE -> R.string.connected_state_incomplete
IpNeighbour.State.VALID -> R.string.connected_state_valid
IpNeighbour.State.FAILED -> R.string.connected_state_failed
else -> throw IllegalStateException("Invalid IpNeighbour.State: $state")
}, ip.hostAddress))
}
if (sendRate >= 0 && receiveRate >= 0) result.appendln(
"${Formatter.formatFileSize(app, sendRate)}/s\t\t${Formatter.formatFileSize(app, receiveRate)}/s")
return result.toString().trimEnd()
} }
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
@@ -38,8 +65,9 @@ abstract class Client {
if (iface != other.iface) return false if (iface != other.iface) return false
if (mac != other.mac) return false if (mac != other.mac) return false
if (ip != other.ip) return false if (ip != other.ip) return false
if (record != other.record) return false
return true return true
} }
override fun hashCode() = Objects.hash(iface, mac, ip) override fun hashCode() = Objects.hash(iface, mac, ip, record)
} }

View File

@@ -21,7 +21,7 @@ class ClientMonitorService : Service(), ServiceConnection, IpNeighbourMonitor.Ca
private var tetheredInterfaces = emptySet<String>() private var tetheredInterfaces = emptySet<String>()
private val receiver = broadcastReceiver { _, intent -> private val receiver = broadcastReceiver { _, intent ->
val extras = intent.extras!! val extras = intent.extras ?: return@broadcastReceiver
tetheredInterfaces = TetheringManager.getTetheredIfaces(extras).toSet() + tetheredInterfaces = TetheringManager.getTetheredIfaces(extras).toSet() +
TetheringManager.getLocalOnlyTetheredIfaces(extras) TetheringManager.getLocalOnlyTetheredIfaces(extras)
populateClients() populateClients()

View File

@@ -1,47 +1,187 @@
package be.mygod.vpnhotspot.client package be.mygod.vpnhotspot.client
import android.content.ComponentName import android.content.ComponentName
import android.content.DialogInterface
import android.content.ServiceConnection import android.content.ServiceConnection
import android.os.Bundle import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import android.text.format.DateUtils
import android.text.format.Formatter
import android.util.LongSparseArray
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.EditText
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.PopupMenu
import androidx.core.os.bundleOf
import androidx.databinding.DataBindingUtil import androidx.databinding.DataBindingUtil
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import be.mygod.vpnhotspot.AlertDialogFragment
import be.mygod.vpnhotspot.BR
import be.mygod.vpnhotspot.R import be.mygod.vpnhotspot.R
import be.mygod.vpnhotspot.databinding.FragmentRepeaterBinding import be.mygod.vpnhotspot.databinding.FragmentClientsBinding
import be.mygod.vpnhotspot.databinding.ListitemClientBinding import be.mygod.vpnhotspot.databinding.ListitemClientBinding
import be.mygod.vpnhotspot.net.IpNeighbourMonitor import be.mygod.vpnhotspot.net.IpNeighbourMonitor
import be.mygod.vpnhotspot.net.TrafficRecorder
import be.mygod.vpnhotspot.room.*
import be.mygod.vpnhotspot.util.ServiceForegroundConnector import be.mygod.vpnhotspot.util.ServiceForegroundConnector
import be.mygod.vpnhotspot.util.toPluralInt
import java.text.NumberFormat
class ClientsFragment : Fragment(), ServiceConnection { class ClientsFragment : Fragment(), ServiceConnection {
private class ClientViewHolder(val binding: ListitemClientBinding) : RecyclerView.ViewHolder(binding.root) class NicknameDialogFragment : AlertDialogFragment() {
companion object {
const val KEY_MAC = "mac"
const val KEY_NICKNAME = "nickname"
}
private val mac by lazy { arguments!!.getString(KEY_MAC)!! }
override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) {
setView(R.layout.dialog_nickname)
setTitle(getString(R.string.clients_nickname_title, mac))
setPositiveButton(android.R.string.ok, listener)
setNegativeButton(android.R.string.cancel, null)
}
override fun onCreateDialog(savedInstanceState: Bundle?) = super.onCreateDialog(savedInstanceState).apply {
create()
findViewById<EditText>(android.R.id.edit)!!.setText(arguments!!.getCharSequence(KEY_NICKNAME))
}
override fun onClick(di: DialogInterface?, which: Int) {
AppDatabase.instance.clientRecordDao.lookup(mac.macToLong()).apply {
nickname = dialog.findViewById<EditText>(android.R.id.edit).text
AppDatabase.instance.clientRecordDao.update(this)
}
IpNeighbourMonitor.instance?.flush()
}
}
class StatsDialogFragment : AlertDialogFragment() {
companion object {
const val KEY_TITLE = "title"
const val KEY_STATS = "stats"
}
private val title by lazy { arguments!!.getCharSequence(KEY_TITLE)!! }
private val stats by lazy { arguments!!.getParcelable<ClientStats>(KEY_STATS)!! }
override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) {
setTitle(getString(R.string.clients_stats_title, title))
val context = context
val resources = resources
val format = NumberFormat.getIntegerInstance(resources.configuration.locale)
setMessage("%s\n%s\n%s".format(
resources.getQuantityString(R.plurals.clients_stats_message_1, stats.count.toPluralInt(),
format.format(stats.count), DateUtils.formatDateTime(context, stats.timestamp,
DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_YEAR or DateUtils.FORMAT_SHOW_DATE)),
resources.getQuantityString(R.plurals.clients_stats_message_2, stats.sentPackets.toPluralInt(),
format.format(stats.sentPackets), Formatter.formatFileSize(context, stats.sentBytes)),
resources.getQuantityString(R.plurals.clients_stats_message_3, stats.sentPackets.toPluralInt(),
format.format(stats.receivedPackets),
Formatter.formatFileSize(context, stats.receivedBytes))))
setPositiveButton(android.R.string.ok, null)
}
}
private inner class ClientViewHolder(val binding: ListitemClientBinding) : RecyclerView.ViewHolder(binding.root),
View.OnClickListener, PopupMenu.OnMenuItemClickListener {
init {
binding.root.setOnClickListener(this)
}
override fun onClick(v: View) {
PopupMenu(binding.root.context, binding.root).apply {
menuInflater.inflate(R.menu.popup_client, menu)
menu.removeItem(if (binding.client!!.record.blocked) R.id.block else R.id.unblock)
setOnMenuItemClickListener(this@ClientViewHolder)
show()
}
}
override fun onMenuItemClick(item: MenuItem?): Boolean {
return when (item?.itemId) {
R.id.nickname -> {
val client = binding.client ?: return false
NicknameDialogFragment().apply {
arguments = bundleOf(Pair(NicknameDialogFragment.KEY_MAC, client.mac),
Pair(NicknameDialogFragment.KEY_NICKNAME, client.record.nickname))
}.show(fragmentManager, "NicknameDialogFragment")
true
}
R.id.block, R.id.unblock -> {
val client = binding.client ?: return false
client.record.apply {
AppDatabase.instance.clientRecordDao.update(ClientRecord(mac, nickname, !blocked))
}
IpNeighbourMonitor.instance?.flush()
true
}
R.id.stats -> {
val client = binding.client ?: return false
StatsDialogFragment().apply {
arguments = bundleOf(Pair(StatsDialogFragment.KEY_TITLE, client.title),
Pair(StatsDialogFragment.KEY_STATS,
AppDatabase.instance.trafficRecordDao.queryStats(client.mac.macToLong())))
}.show(fragmentManager, "StatsDialogFragment")
true
}
else -> false
}
}
}
private inner class ClientAdapter : ListAdapter<Client, ClientViewHolder>(Client) { private inner class ClientAdapter : ListAdapter<Client, ClientViewHolder>(Client) {
private var clientsLookup: LongSparseArray<Client>? = null
override fun submitList(list: MutableList<Client>?) { override fun submitList(list: MutableList<Client>?) {
super.submitList(list) super.submitList(list)
clientsLookup = if (list == null) null else LongSparseArray<Client>(list.size).apply {
list.forEach { put(it.mac.macToLong(), it) }
}
binding.swipeRefresher.isRefreshing = false binding.swipeRefresher.isRefreshing = false
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
ClientViewHolder(ListitemClientBinding.inflate(LayoutInflater.from(parent.context))) ClientViewHolder(ListitemClientBinding.inflate(LayoutInflater.from(parent.context), parent, false))
override fun onBindViewHolder(holder: ClientViewHolder, position: Int) { override fun onBindViewHolder(holder: ClientViewHolder, position: Int) {
holder.binding.client = getItem(position) holder.binding.client = getItem(position)
holder.binding.executePendingBindings() holder.binding.executePendingBindings()
} }
fun updateTraffic(newRecords: Collection<TrafficRecord>, oldRecords: LongSparseArray<TrafficRecord>) {
val clientsLookup = clientsLookup ?: return
for (newRecord in newRecords) {
val oldRecord = oldRecords[newRecord.previousId ?: continue] ?: continue
val client = clientsLookup[newRecord.mac]
val elapsed = newRecord.timestamp - oldRecord.timestamp
if (elapsed == 0L) {
check(newRecord.sentPackets == oldRecord.sentPackets)
check(newRecord.sentBytes == oldRecord.sentBytes)
check(newRecord.receivedPackets == oldRecord.receivedPackets)
check(newRecord.receivedBytes == oldRecord.receivedBytes)
} else {
client.sendRate = (newRecord.sentBytes - oldRecord.sentBytes) * 1000 / elapsed
client.receiveRate = (newRecord.receivedBytes - oldRecord.receivedBytes) * 1000 / elapsed
client.notifyPropertyChanged(BR.description)
}
}
}
} }
private lateinit var binding: FragmentRepeaterBinding private lateinit var binding: FragmentClientsBinding
private val adapter = ClientAdapter() private val adapter = ClientAdapter()
private var clients: ClientMonitorService.Binder? = null private var clients: ClientMonitorService.Binder? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_repeater, container, false) binding = DataBindingUtil.inflate(inflater, R.layout.fragment_clients, container, false)
binding.clients.layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false) binding.clients.layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false)
binding.clients.itemAnimator = DefaultItemAnimator() binding.clients.itemAnimator = DefaultItemAnimator()
binding.clients.adapter = adapter binding.clients.adapter = adapter
@@ -63,4 +203,16 @@ class ClientsFragment : Fragment(), ServiceConnection {
clients.clientsChanged -= this clients.clientsChanged -= this
this.clients = null this.clients = null
} }
override fun onStart() {
super.onStart()
// we just put these two thing together as this is the only place we need to use this event for now
TrafficRecorder.foregroundListeners[this] = adapter::updateTraffic
TrafficRecorder.rescheduleUpdate() // next schedule time might be 1 min, force reschedule to <= 1s
}
override fun onStop() {
TrafficRecorder.foregroundListeners -= this
super.onStop()
}
} }

View File

@@ -90,7 +90,7 @@ class TetheringFragment : Fragment(), ServiceConnection {
var tetheringBinder: TetheringService.Binder? = null var tetheringBinder: TetheringService.Binder? = null
val adapter = ManagerAdapter() val adapter = ManagerAdapter()
private val receiver = broadcastReceiver { _, intent -> private val receiver = broadcastReceiver { _, intent ->
val extras = intent.extras!! val extras = intent.extras ?: return@broadcastReceiver
adapter.update(TetheringManager.getTetheredIfaces(extras), adapter.update(TetheringManager.getTetheredIfaces(extras),
TetheringManager.getLocalOnlyTetheredIfaces(extras), TetheringManager.getLocalOnlyTetheredIfaces(extras),
extras.getStringArrayList(TetheringManager.EXTRA_ERRORED_TETHER)!!) extras.getStringArrayList(TetheringManager.EXTRA_ERRORED_TETHER)!!)

View File

@@ -0,0 +1,13 @@
package be.mygod.vpnhotspot.net
import java.net.InetAddress
object InetAddressComparator : Comparator<InetAddress> {
override fun compare(o1: InetAddress?, o2: InetAddress?): Int {
if (o1 == null && o2 == null) return 0
val a1 = o1?.address
val a2 = o2?.address
val r = (a1?.size ?: 0).compareTo(a2?.size ?: 0)
return if (r == 0) a1!!.zip(a2!!).map { (l, r) -> l - r }.find { it != 0 } ?: 0 else r
}
}

View File

@@ -1,11 +1,13 @@
package be.mygod.vpnhotspot.net package be.mygod.vpnhotspot.net
import android.util.Log import android.util.Log
import be.mygod.vpnhotspot.util.parseNumericAddressNoThrow
import com.crashlytics.android.Crashlytics import com.crashlytics.android.Crashlytics
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.net.InetAddress
data class IpNeighbour(val ip: String, val dev: String, val lladdr: String, val state: State) { data class IpNeighbour(val ip: InetAddress, val dev: String, val lladdr: String, val state: State) {
enum class State { enum class State {
INCOMPLETE, VALID, FAILED, DELETING INCOMPLETE, VALID, FAILED, DELETING
} }
@@ -29,12 +31,13 @@ data class IpNeighbour(val ip: String, val dev: String, val lladdr: String, val
if (line.isNotEmpty()) Crashlytics.log(Log.WARN, TAG, line) if (line.isNotEmpty()) Crashlytics.log(Log.WARN, TAG, line)
return null return null
} }
val ip = match.groupValues[2] val ip = parseNumericAddressNoThrow(match.groupValues[2]) ?: return null
val dev = match.groupValues[4] val dev = match.groupValues[4]
var lladdr = checkLladdrNotLoopback(match.groupValues[6]) var lladdr = checkLladdrNotLoopback(match.groupValues[6])
// use ARP as fallback // use ARP as fallback
if (dev.isNotEmpty() && lladdr.isEmpty()) lladdr = checkLladdrNotLoopback(arp() if (dev.isNotEmpty() && lladdr.isEmpty()) lladdr = checkLladdrNotLoopback(arp()
.filter { it[ARP_IP_ADDRESS] == ip && it[ARP_DEVICE] == dev } .asSequence()
.filter { parseNumericAddressNoThrow(it[ARP_IP_ADDRESS]) == ip && it[ARP_DEVICE] == dev }
.map { it[ARP_HW_ADDRESS] } .map { it[ARP_HW_ADDRESS] }
.singleOrNull() ?: "") .singleOrNull() ?: "")
val state = if (match.groupValues[1].isNotEmpty() || lladdr.isEmpty()) State.DELETING else val state = if (match.groupValues[1].isNotEmpty() || lladdr.isEmpty()) State.DELETING else
@@ -64,9 +67,11 @@ data class IpNeighbour(val ip: String, val dev: String, val lladdr: String, val
private fun arp(): List<List<String>> { private fun arp(): List<List<String>> {
if (System.nanoTime() - arpCacheTime >= ARP_CACHE_EXPIRE) try { if (System.nanoTime() - arpCacheTime >= ARP_CACHE_EXPIRE) try {
arpCache = File("/proc/net/arp").bufferedReader().readLines() arpCache = File("/proc/net/arp").bufferedReader().readLines()
.asSequence()
.map { it.split(spaces) } .map { it.split(spaces) }
.drop(1) .drop(1)
.filter { it.size >= 6 && mac.matcher(it[ARP_HW_ADDRESS]).matches() } .filter { it.size >= 6 && mac.matcher(it[ARP_HW_ADDRESS]).matches() }
.toList()
} catch (e: IOException) { } catch (e: IOException) {
e.printStackTrace() e.printStackTrace()
Crashlytics.logException(e) Crashlytics.logException(e)

View File

@@ -2,6 +2,7 @@ package be.mygod.vpnhotspot.net
import be.mygod.vpnhotspot.App.Companion.app import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.util.debugLog import be.mygod.vpnhotspot.util.debugLog
import java.net.InetAddress
class IpNeighbourMonitor private constructor() : IpMonitor() { class IpNeighbourMonitor private constructor() : IpMonitor() {
companion object { companion object {
@@ -31,7 +32,7 @@ class IpNeighbourMonitor private constructor() : IpMonitor() {
} }
private var updatePosted = false private var updatePosted = false
val neighbours = HashMap<String, IpNeighbour>() private val neighbours = HashMap<InetAddress, IpNeighbour>()
override val monitoredObject: String get() = "neigh" override val monitoredObject: String get() = "neigh"

View File

@@ -1,10 +1,21 @@
package be.mygod.vpnhotspot.net package be.mygod.vpnhotspot.net
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Build import android.os.Build
import android.os.IBinder
import be.mygod.vpnhotspot.App.Companion.app import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.R import be.mygod.vpnhotspot.R
import be.mygod.vpnhotspot.client.Client
import be.mygod.vpnhotspot.client.ClientMonitorService
import be.mygod.vpnhotspot.util.RootSession import be.mygod.vpnhotspot.util.RootSession
import be.mygod.vpnhotspot.util.computeIfAbsentCompat
import be.mygod.vpnhotspot.util.debugLog import be.mygod.vpnhotspot.util.debugLog
import be.mygod.vpnhotspot.util.stopAndUnbind
import be.mygod.vpnhotspot.widget.SmartSnackbar
import com.crashlytics.android.Crashlytics
import java.net.* import java.net.*
/** /**
@@ -12,7 +23,8 @@ import java.net.*
* *
* Once revert is called, this object no longer serves any purpose. * Once revert is called, this object no longer serves any purpose.
*/ */
class Routing(val upstream: String?, private val downstream: String, ownerAddress: InterfaceAddress? = null) { class Routing(private val owner: Context, val upstream: String?, private val downstream: String,
ownerAddress: InterfaceAddress? = null, private val strict: Boolean = true) : ServiceConnection {
companion object { companion object {
/** /**
* -w <seconds> is not supported on 7.1-. * -w <seconds> is not supported on 7.1-.
@@ -22,16 +34,19 @@ class Routing(val upstream: String?, private val downstream: String, ownerAddres
*/ */
private val IPTABLES = if (Build.VERSION.SDK_INT >= 26) "iptables -w 1" else "iptables -w" private val IPTABLES = if (Build.VERSION.SDK_INT >= 26) "iptables -w 1" else "iptables -w"
fun clean() = RootSession.use { fun clean() {
it.submit("$IPTABLES -t nat -F PREROUTING") TrafficRecorder.clean()
it.submit("while $IPTABLES -D FORWARD -j vpnhotspot_fwd; do done") RootSession.use {
it.submit("$IPTABLES -F vpnhotspot_fwd") it.submit("$IPTABLES -t nat -F PREROUTING")
it.submit("$IPTABLES -X vpnhotspot_fwd") it.submit("while $IPTABLES -D FORWARD -j vpnhotspot_fwd; do done")
it.submit("while $IPTABLES -t nat -D POSTROUTING -j vpnhotspot_masquerade; do done") it.submit("$IPTABLES -F vpnhotspot_fwd")
it.submit("$IPTABLES -t nat -F vpnhotspot_masquerade") it.submit("$IPTABLES -X vpnhotspot_fwd")
it.submit("$IPTABLES -t nat -X vpnhotspot_masquerade") it.submit("while $IPTABLES -t nat -D POSTROUTING -j vpnhotspot_masquerade; do done")
it.submit("while ip rule del priority 17900; do done") it.submit("$IPTABLES -t nat -F vpnhotspot_masquerade")
it.submit("while ip rule del iif lo uidrange 0-0 lookup local_network priority 11000; do done") it.submit("$IPTABLES -t nat -X vpnhotspot_masquerade")
it.submit("while ip rule del priority 17900; do done")
it.submit("while ip rule del iif lo uidrange 0-0 lookup local_network priority 11000; do done")
}
} }
fun RootSession.Transaction.iptablesAdd(content: String, table: String = "filter") = fun RootSession.Transaction.iptablesAdd(content: String, table: String = "filter") =
@@ -47,7 +62,8 @@ class Routing(val upstream: String?, private val downstream: String, ownerAddres
val hostAddress = ownerAddress ?: NetworkInterface.getByName(downstream)?.interfaceAddresses?.asSequence() val hostAddress = ownerAddress ?: NetworkInterface.getByName(downstream)?.interfaceAddresses?.asSequence()
?.singleOrNull { it.address is Inet4Address } ?: throw InterfaceNotFoundException() ?.singleOrNull { it.address is Inet4Address } ?: throw InterfaceNotFoundException()
private val transaction = RootSession.beginTransaction() private val transaction = RootSession.beginTransaction()
var started = false private val subroutes = HashMap<InetAddress, Subroute>()
private var clients: ClientMonitorService.Binder? = null
fun ipForward() = transaction.exec("echo 1 >/proc/sys/net/ipv4/ip_forward") fun ipForward() = transaction.exec("echo 1 >/proc/sys/net/ipv4/ip_forward")
@@ -68,25 +84,14 @@ class Routing(val upstream: String?, private val downstream: String, ownerAddres
} }
} }
fun forward(strict: Boolean = true) { fun forward() {
transaction.execQuiet("$IPTABLES -N vpnhotspot_fwd") transaction.execQuiet("$IPTABLES -N vpnhotspot_fwd")
transaction.iptablesInsert("FORWARD -j vpnhotspot_fwd") transaction.iptablesInsert("FORWARD -j vpnhotspot_fwd")
if (strict) { transaction.iptablesAdd("vpnhotspot_fwd -i $downstream ! -o $downstream -j DROP") // ensure blocking works
if (upstream != null) { // the real forwarding filters will be added in Subroute when clients are connected
transaction.iptablesAdd("vpnhotspot_fwd -i $upstream -o $downstream -m state --state ESTABLISHED,RELATED -j ACCEPT")
transaction.iptablesAdd("vpnhotspot_fwd -i $downstream -o $upstream -j ACCEPT")
} // else nothing needs to be done
} else {
// for not strict mode, allow downstream packets to be redirected to anywhere
// because we don't wanna keep track of default network changes
transaction.iptablesAdd("vpnhotspot_fwd -o $downstream -m state --state ESTABLISHED,RELATED -j ACCEPT")
transaction.iptablesAdd("vpnhotspot_fwd -i $downstream -j ACCEPT")
}
} }
fun overrideSystemRules() = transaction.iptablesAdd("vpnhotspot_fwd -i $downstream -j DROP") fun masquerade() {
fun masquerade(strict: Boolean = true) {
val hostSubnet = "${hostAddress.address.hostAddress}/${hostAddress.networkPrefixLength}" val hostSubnet = "${hostAddress.address.hostAddress}/${hostAddress.networkPrefixLength}"
transaction.execQuiet("$IPTABLES -t nat -N vpnhotspot_masquerade") transaction.execQuiet("$IPTABLES -t nat -N vpnhotspot_masquerade")
transaction.iptablesInsert("POSTROUTING -j vpnhotspot_masquerade", "nat") transaction.iptablesInsert("POSTROUTING -j vpnhotspot_masquerade", "nat")
@@ -119,6 +124,77 @@ class Routing(val upstream: String?, private val downstream: String, ownerAddres
fun dhcpWorkaround() = transaction.exec("ip rule add iif lo uidrange 0-0 lookup local_network priority 11000", fun dhcpWorkaround() = transaction.exec("ip rule add iif lo uidrange 0-0 lookup local_network priority 11000",
"ip rule del iif lo uidrange 0-0 lookup local_network priority 11000") "ip rule del iif lo uidrange 0-0 lookup local_network priority 11000")
fun commit() = transaction.commit() fun commit() {
fun revert() = transaction.revert() transaction.commit()
owner.bindService(Intent(owner, ClientMonitorService::class.java), this, Context.BIND_AUTO_CREATE)
}
fun revert() {
stop()
synchronized(subroutes) { subroutes.forEach { (_, subroute) -> subroute.close() } }
transaction.revert()
}
/**
* Only unregister client listener. This should only be used when a clean has just performed.
*/
fun stop() = owner.stopAndUnbind(this)
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
clients = service as ClientMonitorService.Binder
service.clientsChanged[this] = {
synchronized(subroutes) {
val toRemove = HashSet(subroutes.keys)
for (client in it) if (!client.record.blocked) updateForClient(client, toRemove)
for (address in toRemove) subroutes.remove(address)!!.close()
}
}
}
override fun onServiceDisconnected(name: ComponentName?) {
val clients = clients ?: return
clients.clientsChanged -= this
this.clients = null
}
private fun updateForClient(client: Client, toRemove: HashSet<InetAddress>? = null) {
for ((ip, _) in client.ip) if (ip is Inet4Address) {
toRemove?.remove(ip)
try {
subroutes.computeIfAbsentCompat(ip) { Subroute(ip, client) }
} catch (e: Exception) {
Crashlytics.logException(e)
e.printStackTrace()
SmartSnackbar.make(e.localizedMessage).show()
}
}
}
private inner class Subroute(private val ip: Inet4Address, client: Client) : AutoCloseable {
private val transaction = RootSession.beginTransaction().apply {
try {
val address by lazy { ip.hostAddress }
if (!strict) {
// otw allow downstream packets to be redirected to anywhere
// because we don't wanna keep track of default network changes
iptablesInsert("vpnhotspot_fwd -i $downstream -s $address -j ACCEPT")
iptablesInsert("vpnhotspot_fwd -o $downstream -d $address -m state --state ESTABLISHED,RELATED -j ACCEPT")
} else if (upstream != null) {
iptablesInsert("vpnhotspot_fwd -i $downstream -s $address -o $upstream -j ACCEPT")
iptablesInsert("vpnhotspot_fwd -i $upstream -o $downstream -d $address -m state --state ESTABLISHED,RELATED -j ACCEPT")
} // else nothing needs to be done
commit()
} catch (e: Exception) {
revert()
throw e
}
}
init {
TrafficRecorder.register(ip, if (strict) upstream else null, downstream, client.mac)
}
override fun close() {
TrafficRecorder.unregister(ip, downstream)
transaction.revert()
}
}
} }

View File

@@ -0,0 +1,123 @@
package be.mygod.vpnhotspot.net
import android.os.SystemClock
import android.util.Log
import android.util.LongSparseArray
import be.mygod.vpnhotspot.room.AppDatabase
import be.mygod.vpnhotspot.room.TrafficRecord
import be.mygod.vpnhotspot.room.insert
import be.mygod.vpnhotspot.room.macToLong
import be.mygod.vpnhotspot.util.Event2
import be.mygod.vpnhotspot.util.RootSession
import be.mygod.vpnhotspot.util.parseNumericAddress
import com.crashlytics.android.Crashlytics
import java.net.InetAddress
import java.util.concurrent.TimeUnit
object TrafficRecorder {
private const val TAG = "TrafficRecorder"
private const val ANYWHERE = "0.0.0.0/0"
private var scheduled = false
private val records = HashMap<Pair<InetAddress, String>, TrafficRecord>()
val foregroundListeners = Event2<Collection<TrafficRecord>, LongSparseArray<TrafficRecord>>()
fun register(ip: InetAddress, upstream: String?, downstream: String, mac: String) {
val record = TrafficRecord(
mac = mac.macToLong(),
ip = ip,
upstream = upstream,
downstream = downstream)
AppDatabase.instance.trafficRecordDao.insert(record)
synchronized(this) {
check(records.put(Pair(ip, downstream), record) == null)
scheduleUpdateLocked()
}
}
fun unregister(ip: InetAddress, downstream: String) = synchronized(this) {
update() // flush stats before removing
check(records.remove(Pair(ip, downstream)) != null)
}
private fun unscheduleUpdateLocked() {
RootSession.handler.removeCallbacksAndMessages(this)
scheduled = false
}
private fun scheduleUpdateLocked() {
if (scheduled) return
val now = System.currentTimeMillis()
val minute = TimeUnit.MINUTES.toMillis(1)
var timeout = minute - now % minute
if (foregroundListeners.isNotEmpty() && timeout > 1000) timeout = 1000
RootSession.handler.postAtTime(this::update, this, SystemClock.uptimeMillis() + timeout)
scheduled = true
}
fun rescheduleUpdate() = synchronized(this) {
unscheduleUpdateLocked()
scheduleUpdateLocked()
}
fun update() {
synchronized(this) {
scheduled = false
if (records.isEmpty()) return
val timestamp = System.currentTimeMillis()
val oldRecords = LongSparseArray<TrafficRecord>()
for (line in RootSession.use { it.execOutUnjoined("iptables -nvx -L vpnhotspot_fwd") }
.asSequence().drop(2)) {
val columns = line.split("\\s+".toRegex()).filter { it.isNotEmpty() }
try {
check(columns.size >= 9)
when (columns[2]) {
"DROP" -> { }
"ACCEPT" -> {
val isReceive = columns[7] == ANYWHERE
val isSend = columns[8] == ANYWHERE
check(isReceive != isSend)
val ip = parseNumericAddress(columns[if (isReceive) 8 else 7])
val downstream = columns[if (isReceive) 6 else 5]
var upstream: String? = columns[if (isReceive) 5 else 6]
if (upstream == "*") upstream = null
val key = Pair(ip, downstream)
val oldRecord = records[key]!!
check(upstream == oldRecord.upstream)
val record = if (oldRecord.id == null) oldRecord else TrafficRecord(
timestamp = timestamp,
mac = oldRecord.mac,
ip = ip,
upstream = upstream,
downstream = downstream,
previousId = oldRecord.id)
if (isReceive) {
record.receivedPackets = columns[0].toLong()
record.receivedBytes = columns[1].toLong()
} else {
record.sentPackets = columns[0].toLong()
record.sentBytes = columns[1].toLong()
}
if (oldRecord.id != null) {
check(records.put(key, record) == oldRecord)
oldRecords.put(oldRecord.id!!, oldRecord)
}
}
else -> check(false)
}
} catch (e: RuntimeException) {
Crashlytics.log(Log.WARN, TAG, line)
e.printStackTrace()
Crashlytics.logException(e)
}
}
for ((_, record) in records) if (record.id == null) AppDatabase.instance.trafficRecordDao.insert(record)
foregroundListeners(records.values, oldRecords)
scheduleUpdateLocked()
}
}
fun clean() = synchronized(this) {
update()
unscheduleUpdateLocked()
records.clear()
}
}

View File

@@ -14,7 +14,7 @@ abstract class UpstreamMonitor {
private fun generateMonitor(): UpstreamMonitor { private fun generateMonitor(): UpstreamMonitor {
val upstream = app.pref.getString(KEY, null) val upstream = app.pref.getString(KEY, null)
return if (upstream.isNullOrEmpty()) VpnMonitor else InterfaceMonitor(upstream) return if (upstream.isNullOrEmpty()) VpnMonitor else InterfaceMonitor(upstream!!)
} }
private var monitor = generateMonitor() private var monitor = generateMonitor()

View File

@@ -0,0 +1,24 @@
package be.mygod.vpnhotspot.room
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import be.mygod.vpnhotspot.App.Companion.app
@Database(entities = [ClientRecord::class, TrafficRecord::class], version = 1)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
companion object {
const val DB_NAME = "app.db"
val instance by lazy {
Room.databaseBuilder(app.deviceStorage, AppDatabase::class.java, DB_NAME)
.allowMainThreadQueries()
.build()
}
}
abstract val clientRecordDao: ClientRecord.Dao
abstract val trafficRecordDao: TrafficRecord.Dao
}

View File

@@ -0,0 +1,21 @@
package be.mygod.vpnhotspot.room
import androidx.room.*
@Entity
data class ClientRecord(@PrimaryKey
val mac: Long,
var nickname: CharSequence = "",
var blocked: Boolean = false) {
@androidx.room.Dao
interface Dao {
@Query("SELECT * FROM `ClientRecord` WHERE `mac` = :mac")
fun lookupOrNull(mac: Long): ClientRecord?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun updateInternal(value: ClientRecord): Long
}
}
fun ClientRecord.Dao.lookup(mac: Long) = lookupOrNull(mac) ?: ClientRecord(mac)
fun ClientRecord.Dao.update(value: ClientRecord) = check(updateInternal(value) == value.mac)

View File

@@ -0,0 +1,53 @@
package be.mygod.vpnhotspot.room
import android.os.Parcel
import android.text.TextUtils
import androidx.room.TypeConverter
import java.net.InetAddress
import java.nio.ByteBuffer
import java.nio.ByteOrder
class Converters {
@TypeConverter
fun persistCharSequence(cs: CharSequence): ByteArray {
val p = Parcel.obtain()
try {
TextUtils.writeToParcel(cs, p, 0)
return p.marshall()
} finally {
p.recycle()
}
}
@TypeConverter
fun unpersistCharSequence(data: ByteArray): CharSequence {
val p = Parcel.obtain()
try {
p.unmarshall(data, 0, data.size)
p.setDataPosition(0)
return TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(p)
} finally {
p.recycle()
}
}
@TypeConverter
fun persistInetAddress(address: InetAddress): ByteArray = address.address
@TypeConverter
fun unpersistInetAddress(data: ByteArray): InetAddress = InetAddress.getByAddress(data)
}
fun String.macToLong(): Long = ByteBuffer.allocate(8).run {
order(ByteOrder.LITTLE_ENDIAN)
mark()
put(split(':').map { Integer.parseInt(it, 16).toByte() }.toByteArray())
reset()
long
}
fun Long.macToString(): String = ByteBuffer.allocate(8).run {
order(ByteOrder.LITTLE_ENDIAN)
putLong(this@macToString)
array().take(6).joinToString(":") { "%02x".format(it) }
}

View File

@@ -0,0 +1,96 @@
package be.mygod.vpnhotspot.room
import android.os.Parcel
import android.os.Parcelable
import androidx.room.*
import java.net.InetAddress
@Entity(foreignKeys = [ForeignKey(entity = TrafficRecord::class, parentColumns = ["id"], childColumns = ["previousId"],
onDelete = ForeignKey.CASCADE, onUpdate = ForeignKey.RESTRICT)],
indices = [Index(value = ["previousId"], unique = true)])
data class TrafficRecord(
/**
* Setting id = null should only be used when a new row is created and not yet inserted into the database.
*
* https://www.sqlite.org/lang_createtable.html#primkeyconst:
* > Unless the column is an INTEGER PRIMARY KEY or the table is a WITHOUT ROWID table or the column is declared
* > NOT NULL, SQLite allows NULL values in a PRIMARY KEY column.
*/
@PrimaryKey(autoGenerate = true)
var id: Long? = null,
val timestamp: Long = System.currentTimeMillis(),
/**
* Foreign key/ID for (possibly non-existent, i.e. default) entry in ClientRecord.
*/
val mac: Long,
/**
* For now only stats for IPv4 will be recorded. But I'm going to put the more general class here just in case.
*/
val ip: InetAddress,
val upstream: String? = null,
val downstream: String,
var sentPackets: Long = 0,
var sentBytes: Long = 0,
var receivedPackets: Long = 0,
var receivedBytes: Long = 0,
/**
* ID for the previous traffic record.
*/
val previousId: Long? = null) {
@androidx.room.Dao
interface Dao {
@Insert
fun insertInternal(value: TrafficRecord): Long
@Query("""
SELECT MIN(TrafficRecord.timestamp) AS timestamp,
COUNT(TrafficRecord.id) AS count,
SUM(TrafficRecord.sentPackets) AS sentPackets,
SUM(TrafficRecord.sentBytes) AS sentBytes,
SUM(TrafficRecord.receivedPackets) AS receivedPackets,
SUM(TrafficRecord.receivedBytes) AS receivedBytes
FROM TrafficRecord LEFT JOIN TrafficRecord AS Next ON TrafficRecord.id = Next.previousId
/* We only want to find the last record for each chain so that we don't double count */
WHERE TrafficRecord.mac = :mac AND Next.id IS NULL""")
fun queryStats(mac: Long): ClientStats
}
}
fun TrafficRecord.Dao.insert(value: TrafficRecord) {
check(value.id == null)
value.id = insertInternal(value)
}
data class ClientStats(
val timestamp: Long = 0,
val count: Long = 0,
val sentPackets: Long = 0,
val sentBytes: Long = 0,
val receivedPackets: Long = 0,
val receivedBytes: Long = 0
) : Parcelable {
constructor(parcel: Parcel) : this(
parcel.readLong(),
parcel.readLong(),
parcel.readLong(),
parcel.readLong(),
parcel.readLong(),
parcel.readLong())
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeLong(timestamp)
parcel.writeLong(count)
parcel.writeLong(sentPackets)
parcel.writeLong(sentBytes)
parcel.writeLong(receivedPackets)
parcel.writeLong(receivedBytes)
}
override fun describeContents() = 0
companion object CREATOR : Parcelable.Creator<ClientStats> {
override fun createFromParcel(parcel: Parcel) = ClientStats(parcel)
override fun newArray(size: Int) = arrayOfNulls<ClientStats>(size)
}
}

View File

@@ -12,11 +12,8 @@ open class Event0 : ConcurrentHashMap<Any, () -> Unit>() {
} }
class StickyEvent0 : Event0() { class StickyEvent0 : Event0() {
override fun put(key: Any, value: () -> Unit): (() -> Unit)? { override fun put(key: Any, value: () -> Unit): (() -> Unit)? =
val result = super.put(key, value) super.put(key, value).also { if (it == null) value() }
if (result == null) value()
return result
}
} }
open class Event1<T> : ConcurrentHashMap<Any, (T) -> Unit>() { open class Event1<T> : ConcurrentHashMap<Any, (T) -> Unit>() {
@@ -26,9 +23,12 @@ open class Event1<T> : ConcurrentHashMap<Any, (T) -> Unit>() {
} }
class StickyEvent1<T>(private val fire: () -> T) : Event1<T>() { class StickyEvent1<T>(private val fire: () -> T) : Event1<T>() {
override fun put(key: Any, value: (T) -> Unit): ((T) -> Unit)? { override fun put(key: Any, value: (T) -> Unit): ((T) -> Unit)? =
val result = super.put(key, value) super.put(key, value).also { if (it == null) value(fire()) }
if (result == null) value(fire()) }
return result
open class Event2<T1, T2> : ConcurrentHashMap<Any, (T1, T2) -> Unit>() {
operator fun invoke(arg1: T1, arg2: T2) {
for ((_, handler) in this) handler(arg1, arg2)
} }
} }

View File

@@ -1,11 +1,13 @@
package be.mygod.vpnhotspot.util package be.mygod.vpnhotspot.util
import android.os.Handler
import android.os.HandlerThread
import android.util.Log import android.util.Log
import androidx.core.os.postDelayed import androidx.core.os.postDelayed
import be.mygod.vpnhotspot.App.Companion.app
import com.crashlytics.android.Crashlytics import com.crashlytics.android.Crashlytics
import com.topjohnwu.superuser.Shell import com.topjohnwu.superuser.Shell
import java.util.* import java.util.*
import java.util.concurrent.TimeUnit
import java.util.concurrent.locks.ReentrantLock import java.util.concurrent.locks.ReentrantLock
import kotlin.collections.ArrayList import kotlin.collections.ArrayList
import kotlin.concurrent.withLock import kotlin.concurrent.withLock
@@ -14,6 +16,8 @@ class RootSession : AutoCloseable {
companion object { companion object {
private const val TAG = "RootSession" private const val TAG = "RootSession"
val handler = Handler(HandlerThread("$TAG-HandlerThread").apply { start() }.looper)
private val monitor = ReentrantLock() private val monitor = ReentrantLock()
private fun onUnlock() { private fun onUnlock() {
if (monitor.holdCount == 1) instance?.startTimeout() if (monitor.holdCount == 1) instance?.startTimeout()
@@ -72,8 +76,10 @@ class RootSession : AutoCloseable {
shell.close() shell.close()
if (instance == this) instance = null if (instance == this) instance = null
} }
private fun startTimeout() = app.handler.postDelayed(60 * 1000, this) { monitor.withLock { close() } } private fun startTimeout() = handler.postDelayed(TimeUnit.MINUTES.toMillis(5), this) {
private fun haltTimeout() = app.handler.removeCallbacksAndMessages(this) monitor.withLock { close() }
}
private fun haltTimeout() = handler.removeCallbacksAndMessages(this)
/** /**
* Don't care about the results, but still sync. * Don't care about the results, but still sync.
@@ -95,11 +101,12 @@ class RootSession : AutoCloseable {
}).exec() }).exec()
} }
fun exec(command: String) = checkOutput(command, execQuiet(command)) fun exec(command: String) = checkOutput(command, execQuiet(command))
fun execOut(command: String): String { fun execOutUnjoined(command: String): List<String> {
val result = execQuiet(command) val result = execQuiet(command)
checkOutput(command, result, false) checkOutput(command, result, false)
return result.out.joinToString("\n") return result.out
} }
fun execOut(command: String): String = execOutUnjoined(command).joinToString("\n")
/** /**
* This transaction is different from what you may have in mind since you can revert it after committing it. * This transaction is different from what you may have in mind since you can revert it after committing it.

View File

@@ -1,20 +1,33 @@
package be.mygod.vpnhotspot.util package be.mygod.vpnhotspot.util
import android.content.* import android.content.*
import android.os.Build
import android.util.Log import android.util.Log
import android.view.View import android.view.View
import android.widget.ImageView import android.widget.ImageView
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.databinding.BindingAdapter import androidx.databinding.BindingAdapter
import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.BuildConfig import be.mygod.vpnhotspot.BuildConfig
import be.mygod.vpnhotspot.R import be.mygod.vpnhotspot.R
import be.mygod.vpnhotspot.widget.SmartSnackbar import be.mygod.vpnhotspot.widget.SmartSnackbar
import com.crashlytics.android.Crashlytics import com.crashlytics.android.Crashlytics
import java.net.InetAddress
import java.net.NetworkInterface import java.net.NetworkInterface
import java.net.SocketException import java.net.SocketException
/**
* This is a hack: we wrap longs around in 1 billion and such. Hopefully every language counts in base 10 and this works
* marvelously for everybody.
*/
fun Long.toPluralInt(): Int {
check(this >= 0) // please don't mess with me
if (this <= Int.MAX_VALUE) return toInt()
return (this % 1000000000).toInt() + 1000000000
}
fun CharSequence?.onEmpty(otherwise: CharSequence): CharSequence = if (isNullOrEmpty()) otherwise else this!!
fun debugLog(tag: String?, message: String?) { fun debugLog(tag: String?, message: String?) {
if (BuildConfig.DEBUG) Log.d(tag, message) if (BuildConfig.DEBUG) Log.d(tag, message)
Crashlytics.log("$tag: $message") Crashlytics.log("$tag: $message")
@@ -51,6 +64,19 @@ fun NetworkInterface.formatAddresses() =
})) }))
.joinToString("\n") .joinToString("\n")
private val parseNumericAddress by lazy {
// parseNumericAddressNoThrow is in dark grey list unfortunately
InetAddress::class.java.getDeclaredMethod("parseNumericAddress", String::class.java).apply {
isAccessible = true
}
}
fun parseNumericAddress(address: String) = parseNumericAddress.invoke(null, address) as InetAddress
fun parseNumericAddressNoThrow(address: String): InetAddress? = try {
parseNumericAddress(address)
} catch (_: IllegalArgumentException) {
null
}
/** /**
* Wrapper for kotlin.concurrent.thread that silences uncaught exceptions. * Wrapper for kotlin.concurrent.thread that silences uncaught exceptions.
*/ */
@@ -69,3 +95,6 @@ fun Context.stopAndUnbind(connection: ServiceConnection) {
connection.onServiceDisconnected(null) connection.onServiceDisconnected(null)
unbindService(connection) unbindService(connection)
} }
fun <K, V> HashMap<K, V>.computeIfAbsentCompat(key: K, value: () -> V) = if (Build.VERSION.SDK_INT >= 26)
computeIfAbsent(key) { value() } else this[key] ?: value().also { put(key, it) }

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<EditText
android:id="@android:id/edit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:importantForAutofill="no"
tools:text="Nick">
<requestFocus/>
</EditText>
</FrameLayout>

View File

@@ -11,6 +11,8 @@
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:focusable="true"
android:background="?android:attr/selectableItemBackground"
android:padding="16dp"> android:padding="16dp">
<ImageView <ImageView
@@ -32,15 +34,15 @@
android:orientation="vertical"> android:orientation="vertical">
<TextView <TextView
android:layout_width="match_parent" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@{client.title}" android:text="@{client.title}"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" android:textAppearance="@style/TextAppearance.AppCompat.Subhead"
android:textIsSelectable="true" android:textIsSelectable="@{client.record.nickname.length() == 0}"
tools:text="01:23:45:ab:cd:ef%p2p-p2p0-0"/> tools:text="01:23:45:ab:cd:ef%p2p-p2p0-0"/>
<TextView <TextView
android:layout_width="match_parent" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@{client.description}" android:text="@{client.description}"
android:textIsSelectable="true" android:textIsSelectable="true"

View File

@@ -41,7 +41,7 @@
tools:text="wlan0"/> tools:text="wlan0"/>
<be.mygod.vpnhotspot.widget.AutoCollapseTextView <be.mygod.vpnhotspot.widget.AutoCollapseTextView
android:layout_width="match_parent" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@{data.text}" android:text="@{data.text}"
android:textIsSelectable="@{data.selectable}" android:textIsSelectable="@{data.selectable}"

View File

@@ -44,7 +44,7 @@
android:textAppearance="@style/TextAppearance.AppCompat.Subhead"/> android:textAppearance="@style/TextAppearance.AppCompat.Subhead"/>
<be.mygod.vpnhotspot.widget.AutoCollapseTextView <be.mygod.vpnhotspot.widget.AutoCollapseTextView
android:layout_width="match_parent" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@{data.addresses}" android:text="@{data.addresses}"
android:textIsSelectable="true" android:textIsSelectable="true"

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@+id/nickname"
android:title="@string/clients_popup_nickname"/>
<item android:id="@+id/block"
android:title="@string/clients_popup_block"/>
<item android:id="@+id/unblock"
android:title="@string/clients_popup_unblock"/>
<item android:id="@+id/stats"
android:title="@string/clients_popup_stats"/>
</menu>

View File

@@ -57,6 +57,22 @@
<string name="connected_state_valid">%s (已连上)</string> <string name="connected_state_valid">%s (已连上)</string>
<string name="connected_state_failed">%s (已断开)</string> <string name="connected_state_failed">%s (已断开)</string>
<string name="clients_popup_nickname">昵称…</string>
<string name="clients_popup_block">拉黑</string>
<string name="clients_popup_unblock">洗白</string>
<string name="clients_popup_stats">流量…</string>
<string name="clients_nickname_title">%s 的昵称</string>
<string name="clients_stats_title">%s 的流量</string>
<plurals name="clients_stats_message_1">
<item quantity="other">自 %2$s 以来连了 %1$s 次</item>
</plurals>
<plurals name="clients_stats_message_2">
<item quantity="other">上传 %1$s 个包,%2$s</item>
</plurals>
<plurals name="clients_stats_message_3">
<item quantity="other">下载 %1$s 个包,%2$s</item>
</plurals>
<string name="settings_upstream">上游</string> <string name="settings_upstream">上游</string>
<string name="settings_downstream">下游</string> <string name="settings_downstream">下游</string>
<string name="settings_service_masquerade">IP 掩蔽</string> <string name="settings_service_masquerade">IP 掩蔽</string>

View File

@@ -61,6 +61,25 @@
<string name="connected_state_valid">%s (reachable)</string> <string name="connected_state_valid">%s (reachable)</string>
<string name="connected_state_failed">%s (lost)</string> <string name="connected_state_failed">%s (lost)</string>
<string name="clients_popup_nickname">Nickname…</string>
<string name="clients_popup_block">Block</string>
<string name="clients_popup_unblock">Unblock</string>
<string name="clients_popup_stats">Stats…</string>
<string name="clients_nickname_title">Nickname for %s</string>
<string name="clients_stats_title">Stats for %s</string>
<plurals name="clients_stats_message_1">
<item quantity="one">Connected 1 time since %2$s</item>
<item quantity="other">Connected %1$s times since %2$s</item>
</plurals>
<plurals name="clients_stats_message_2">
<item quantity="one">Sent 1 packet, %2$s</item>
<item quantity="other">Sent %1$s packets, %2$s</item>
</plurals>
<plurals name="clients_stats_message_3">
<item quantity="one">Received 1 packet, %2$s</item>
<item quantity="other">Received %1$s packets, %2$s</item>
</plurals>
<string name="settings_upstream">Upstream</string> <string name="settings_upstream">Upstream</string>
<string name="settings_downstream">Downstream</string> <string name="settings_downstream">Downstream</string>
<string name="settings_service_masquerade">IP Masquerade</string> <string name="settings_service_masquerade">IP Masquerade</string>