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:
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
146
mobile/schemas/be.mygod.vpnhotspot.room.AppDatabase/1.json
Normal file
146
mobile/schemas/be.mygod.vpnhotspot.room.AppDatabase/1.json
Normal 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\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)!!)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
123
mobile/src/main/java/be/mygod/vpnhotspot/net/TrafficRecorder.kt
Normal file
123
mobile/src/main/java/be/mygod/vpnhotspot/net/TrafficRecorder.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
24
mobile/src/main/java/be/mygod/vpnhotspot/room/AppDatabase.kt
Normal file
24
mobile/src/main/java/be/mygod/vpnhotspot/room/AppDatabase.kt
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
53
mobile/src/main/java/be/mygod/vpnhotspot/room/Converters.kt
Normal file
53
mobile/src/main/java/be/mygod/vpnhotspot/room/Converters.kt
Normal 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) }
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
17
mobile/src/main/res/layout/dialog_nickname.xml
Normal file
17
mobile/src/main/res/layout/dialog_nickname.xml
Normal 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>
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
11
mobile/src/main/res/menu/popup_client.xml
Normal file
11
mobile/src/main/res/menu/popup_client.xml
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user