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;->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)
|
||||
* [`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:
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@ apply plugin: 'com.github.ben-manes.versions'
|
||||
|
||||
buildscript {
|
||||
ext {
|
||||
kotlinVersion = '1.2.70'
|
||||
androidxVersion = '1.0.0-rc02'
|
||||
kotlinVersion = '1.2.71'
|
||||
androidxVersion = '1.0.0'
|
||||
roomVersion = '2.0.0'
|
||||
}
|
||||
repositories {
|
||||
google()
|
||||
@@ -15,10 +16,10 @@ buildscript {
|
||||
}
|
||||
}
|
||||
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.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"
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
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'
|
||||
|
||||
android {
|
||||
buildToolsVersion "28.0.2"
|
||||
buildToolsVersion "28.0.3"
|
||||
compileSdkVersion 28
|
||||
defaultConfig {
|
||||
applicationId "be.mygod.vpnhotspot"
|
||||
@@ -14,6 +14,11 @@ android {
|
||||
versionCode 36
|
||||
versionName "1.4.2"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
javaCompileOptions {
|
||||
annotationProcessorOptions {
|
||||
arguments = ["room.schemaLocation": "$projectDir/schemas".toString()]
|
||||
}
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
@@ -40,19 +45,22 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
kapt "androidx.room:room-compiler:$roomVersion"
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
implementation "androidx.browser:browser:$androidxVersion"
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.0.0-alpha2'
|
||||
implementation "androidx.core:core-ktx:$androidxVersion"
|
||||
implementation "androidx.preference:preference:$androidxVersion"
|
||||
implementation "androidx.room:room-runtime:$roomVersion"
|
||||
implementation 'com.android.billingclient:billing:1.1'
|
||||
implementation 'com.crashlytics.sdk.android:crashlytics:2.9.5'
|
||||
implementation 'com.github.luongvo:BadgeView:1.1.5'
|
||||
implementation 'com.github.topjohnwu:libsu:2.0.2'
|
||||
implementation "com.google.android.material:material:$androidxVersion"
|
||||
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"
|
||||
testImplementation "androidx.room:room-testing:$roomVersion"
|
||||
testImplementation 'junit:junit:4.12'
|
||||
androidTestImplementation 'androidx.test:runner:1.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.preference.PreferenceManager
|
||||
import androidx.core.content.getSystemService
|
||||
import be.mygod.vpnhotspot.room.AppDatabase
|
||||
import be.mygod.vpnhotspot.util.DeviceStorageApp
|
||||
import be.mygod.vpnhotspot.util.Event0
|
||||
import be.mygod.vpnhotspot.util.RootSession
|
||||
@@ -30,6 +31,7 @@ class App : Application() {
|
||||
if (Build.VERSION.SDK_INT >= 24) {
|
||||
deviceStorage = DeviceStorageApp(this)
|
||||
deviceStorage.moveSharedPreferencesFrom(this, PreferenceManager.getDefaultSharedPreferencesName(this))
|
||||
deviceStorage.moveDatabaseFrom(this, AppDatabase.DB_NAME)
|
||||
} else deviceStorage = this
|
||||
Fabric.with(deviceStorage, Crashlytics())
|
||||
ServiceNotification.updateNotificationChannels()
|
||||
|
||||
@@ -33,7 +33,7 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService() {
|
||||
private var routingManager: LocalOnlyInterfaceManager? = null
|
||||
private var receiverRegistered = false
|
||||
private val receiver = broadcastReceiver { _, intent ->
|
||||
val ifaces = TetheringManager.getLocalOnlyTetheredIfaces(intent.extras)
|
||||
val ifaces = TetheringManager.getLocalOnlyTetheredIfaces(intent.extras ?: return@broadcastReceiver)
|
||||
debugLog(TAG, "onTetherStateChangedLocked: $ifaces")
|
||||
check(ifaces.size <= 1)
|
||||
val iface = ifaces.singleOrNull()
|
||||
@@ -45,7 +45,7 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService() {
|
||||
} else {
|
||||
val routingManager = routingManager
|
||||
if (routingManager == null) {
|
||||
this.routingManager = LocalOnlyInterfaceManager(iface)
|
||||
this.routingManager = LocalOnlyInterfaceManager(this, iface)
|
||||
IpNeighbourMonitor.registerCallback(this)
|
||||
} else check(iface == routingManager.downstream)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package be.mygod.vpnhotspot
|
||||
|
||||
import android.content.Context
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import be.mygod.vpnhotspot.net.Routing
|
||||
import be.mygod.vpnhotspot.net.UpstreamMonitor
|
||||
@@ -8,7 +9,7 @@ import com.crashlytics.android.Crashlytics
|
||||
import java.net.InetAddress
|
||||
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 dns = emptyList<InetAddress>()
|
||||
|
||||
@@ -32,7 +33,7 @@ class LocalOnlyInterfaceManager(val downstream: String) : UpstreamMonitor.Callba
|
||||
|
||||
private fun clean() {
|
||||
val routing = routing ?: return
|
||||
routing.started = false
|
||||
routing.stop()
|
||||
initRouting(routing.upstream, routing.hostAddress, dns)
|
||||
}
|
||||
|
||||
@@ -40,22 +41,19 @@ class LocalOnlyInterfaceManager(val downstream: String) : UpstreamMonitor.Callba
|
||||
dns: List<InetAddress> = this.dns) {
|
||||
this.dns = dns
|
||||
try {
|
||||
this.routing = Routing(upstream, downstream, owner).apply {
|
||||
routing = Routing(this.owner, upstream, downstream, owner, app.strict).apply {
|
||||
try {
|
||||
val strict = app.strict
|
||||
if (strict && upstream == null) return@apply // in this case, nothing to be done
|
||||
if (app.dhcpWorkaround) dhcpWorkaround()
|
||||
ipForward() // local only interfaces need to enable ip_forward
|
||||
rule()
|
||||
forward(strict)
|
||||
if (app.masquerade) masquerade(strict)
|
||||
forward()
|
||||
if (app.masquerade) masquerade()
|
||||
dnsRedirect(dns)
|
||||
commit()
|
||||
} catch (e: Exception) {
|
||||
revert()
|
||||
throw e
|
||||
} finally {
|
||||
commit()
|
||||
}
|
||||
} // otw nothing needs to be done
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
SmartSnackbar.make(e.localizedMessage).show()
|
||||
|
||||
@@ -238,7 +238,7 @@ class RepeaterService : Service(), WifiP2pManager.ChannelListener, SharedPrefere
|
||||
private fun doStart(group: WifiP2pGroup) {
|
||||
this.group = group
|
||||
check(routingManager == null)
|
||||
routingManager = LocalOnlyInterfaceManager(group.`interface`!!)
|
||||
routingManager = LocalOnlyInterfaceManager(this, group.`interface`!!)
|
||||
status = Status.ACTIVE
|
||||
showNotification(group)
|
||||
}
|
||||
|
||||
@@ -31,8 +31,9 @@ class TetheringService : IpNeighbourMonitoringService(), UpstreamMonitor.Callbac
|
||||
private var dns: List<InetAddress> = emptyList()
|
||||
private var receiverRegistered = false
|
||||
private val receiver = broadcastReceiver { _, intent ->
|
||||
val extras = intent.extras ?: return@broadcastReceiver
|
||||
synchronized(routings) {
|
||||
for (iface in routings.keys - TetheringManager.getTetheredIfaces(intent.extras!!))
|
||||
for (iface in routings.keys - TetheringManager.getTetheredIfaces(extras))
|
||||
routings.remove(iface)?.revert()
|
||||
updateRoutingsLocked()
|
||||
}
|
||||
@@ -65,7 +66,7 @@ class TetheringService : IpNeighbourMonitoringService(), UpstreamMonitor.Callbac
|
||||
val (downstream, value) = iterator.next()
|
||||
if (value != null) if (value.upstream == upstream) continue else value.revert()
|
||||
try {
|
||||
routings[downstream] = Routing(upstream, downstream).apply {
|
||||
routings[downstream] = Routing(this, upstream, downstream).apply {
|
||||
try {
|
||||
if (app.dhcpWorkaround) dhcpWorkaround()
|
||||
// system tethering already has working forwarding rules
|
||||
@@ -73,15 +74,13 @@ class TetheringService : IpNeighbourMonitoringService(), UpstreamMonitor.Callbac
|
||||
rule()
|
||||
// here we always enforce strict mode as fallback is handled by system which we disable
|
||||
forward()
|
||||
if (app.strict) overrideSystemRules()
|
||||
if (app.masquerade) masquerade()
|
||||
if (upstream != null) dnsRedirect(dns)
|
||||
if (disableIpv6) disableIpv6()
|
||||
commit()
|
||||
} catch (e: Exception) {
|
||||
revert()
|
||||
throw e
|
||||
} finally {
|
||||
commit()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
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 be.mygod.vpnhotspot.App.Companion.app
|
||||
import be.mygod.vpnhotspot.R
|
||||
import be.mygod.vpnhotspot.net.InetAddressComparator
|
||||
import be.mygod.vpnhotspot.net.IpNeighbour
|
||||
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>() {
|
||||
override fun areItemsTheSame(oldItem: Client, newItem: Client) =
|
||||
oldItem.iface == newItem.iface && oldItem.mac == newItem.mac
|
||||
@@ -16,17 +29,31 @@ abstract class Client {
|
||||
|
||||
abstract val iface: 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
|
||||
val title get() = "$mac%$iface"
|
||||
val description get() = ip.entries.joinToString("\n") { (ip, state) ->
|
||||
app.getString(when (state) {
|
||||
val title: CharSequence get() {
|
||||
val result = SpannableStringBuilder(record.nickname.onEmpty(macIface))
|
||||
if (record.blocked) result.setSpan(StrikethroughSpan(), 0, result.length, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
|
||||
return result
|
||||
}
|
||||
val description: String @Bindable get() {
|
||||
val result = StringBuilder(if (record.nickname.isEmpty()) "" else "$macIface\n")
|
||||
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)
|
||||
}, 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 {
|
||||
@@ -38,8 +65,9 @@ abstract class Client {
|
||||
if (iface != other.iface) return false
|
||||
if (mac != other.mac) return false
|
||||
if (ip != other.ip) return false
|
||||
if (record != other.record) return false
|
||||
|
||||
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 val receiver = broadcastReceiver { _, intent ->
|
||||
val extras = intent.extras!!
|
||||
val extras = intent.extras ?: return@broadcastReceiver
|
||||
tetheredInterfaces = TetheringManager.getTetheredIfaces(extras).toSet() +
|
||||
TetheringManager.getLocalOnlyTetheredIfaces(extras)
|
||||
populateClients()
|
||||
|
||||
@@ -1,47 +1,187 @@
|
||||
package be.mygod.vpnhotspot.client
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.DialogInterface
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Bundle
|
||||
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.MenuItem
|
||||
import android.view.View
|
||||
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.fragment.app.Fragment
|
||||
import androidx.recyclerview.widget.DefaultItemAnimator
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import be.mygod.vpnhotspot.AlertDialogFragment
|
||||
import be.mygod.vpnhotspot.BR
|
||||
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.net.IpNeighbourMonitor
|
||||
import be.mygod.vpnhotspot.net.TrafficRecorder
|
||||
import be.mygod.vpnhotspot.room.*
|
||||
import be.mygod.vpnhotspot.util.ServiceForegroundConnector
|
||||
import be.mygod.vpnhotspot.util.toPluralInt
|
||||
import java.text.NumberFormat
|
||||
|
||||
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 var clientsLookup: LongSparseArray<Client>? = null
|
||||
override fun submitList(list: MutableList<Client>?) {
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
holder.binding.client = getItem(position)
|
||||
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 var clients: ClientMonitorService.Binder? = null
|
||||
|
||||
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.itemAnimator = DefaultItemAnimator()
|
||||
binding.clients.adapter = adapter
|
||||
@@ -63,4 +203,16 @@ class ClientsFragment : Fragment(), ServiceConnection {
|
||||
clients.clientsChanged -= this
|
||||
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
|
||||
val adapter = ManagerAdapter()
|
||||
private val receiver = broadcastReceiver { _, intent ->
|
||||
val extras = intent.extras!!
|
||||
val extras = intent.extras ?: return@broadcastReceiver
|
||||
adapter.update(TetheringManager.getTetheredIfaces(extras),
|
||||
TetheringManager.getLocalOnlyTetheredIfaces(extras),
|
||||
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
|
||||
|
||||
import android.util.Log
|
||||
import be.mygod.vpnhotspot.util.parseNumericAddressNoThrow
|
||||
import com.crashlytics.android.Crashlytics
|
||||
import java.io.File
|
||||
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 {
|
||||
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)
|
||||
return null
|
||||
}
|
||||
val ip = match.groupValues[2]
|
||||
val ip = parseNumericAddressNoThrow(match.groupValues[2]) ?: return null
|
||||
val dev = match.groupValues[4]
|
||||
var lladdr = checkLladdrNotLoopback(match.groupValues[6])
|
||||
// use ARP as fallback
|
||||
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] }
|
||||
.singleOrNull() ?: "")
|
||||
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>> {
|
||||
if (System.nanoTime() - arpCacheTime >= ARP_CACHE_EXPIRE) try {
|
||||
arpCache = File("/proc/net/arp").bufferedReader().readLines()
|
||||
.asSequence()
|
||||
.map { it.split(spaces) }
|
||||
.drop(1)
|
||||
.filter { it.size >= 6 && mac.matcher(it[ARP_HW_ADDRESS]).matches() }
|
||||
.toList()
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
Crashlytics.logException(e)
|
||||
|
||||
@@ -2,6 +2,7 @@ package be.mygod.vpnhotspot.net
|
||||
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import be.mygod.vpnhotspot.util.debugLog
|
||||
import java.net.InetAddress
|
||||
|
||||
class IpNeighbourMonitor private constructor() : IpMonitor() {
|
||||
companion object {
|
||||
@@ -31,7 +32,7 @@ class IpNeighbourMonitor private constructor() : IpMonitor() {
|
||||
}
|
||||
|
||||
private var updatePosted = false
|
||||
val neighbours = HashMap<String, IpNeighbour>()
|
||||
private val neighbours = HashMap<InetAddress, IpNeighbour>()
|
||||
|
||||
override val monitoredObject: String get() = "neigh"
|
||||
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
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.IBinder
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
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.computeIfAbsentCompat
|
||||
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.*
|
||||
|
||||
/**
|
||||
@@ -12,7 +23,8 @@ import java.net.*
|
||||
*
|
||||
* 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 {
|
||||
/**
|
||||
* -w <seconds> is not supported on 7.1-.
|
||||
@@ -22,7 +34,9 @@ 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"
|
||||
|
||||
fun clean() = RootSession.use {
|
||||
fun clean() {
|
||||
TrafficRecorder.clean()
|
||||
RootSession.use {
|
||||
it.submit("$IPTABLES -t nat -F PREROUTING")
|
||||
it.submit("while $IPTABLES -D FORWARD -j vpnhotspot_fwd; do done")
|
||||
it.submit("$IPTABLES -F vpnhotspot_fwd")
|
||||
@@ -33,6 +47,7 @@ class Routing(val upstream: String?, private val downstream: String, ownerAddres
|
||||
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") =
|
||||
exec("$IPTABLES -t $table -A $content", "$IPTABLES -t $table -D $content")
|
||||
@@ -47,7 +62,8 @@ class Routing(val upstream: String?, private val downstream: String, ownerAddres
|
||||
val hostAddress = ownerAddress ?: NetworkInterface.getByName(downstream)?.interfaceAddresses?.asSequence()
|
||||
?.singleOrNull { it.address is Inet4Address } ?: throw InterfaceNotFoundException()
|
||||
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")
|
||||
|
||||
@@ -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.iptablesInsert("FORWARD -j vpnhotspot_fwd")
|
||||
if (strict) {
|
||||
if (upstream != null) {
|
||||
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")
|
||||
}
|
||||
transaction.iptablesAdd("vpnhotspot_fwd -i $downstream ! -o $downstream -j DROP") // ensure blocking works
|
||||
// the real forwarding filters will be added in Subroute when clients are connected
|
||||
}
|
||||
|
||||
fun overrideSystemRules() = transaction.iptablesAdd("vpnhotspot_fwd -i $downstream -j DROP")
|
||||
|
||||
fun masquerade(strict: Boolean = true) {
|
||||
fun masquerade() {
|
||||
val hostSubnet = "${hostAddress.address.hostAddress}/${hostAddress.networkPrefixLength}"
|
||||
transaction.execQuiet("$IPTABLES -t nat -N vpnhotspot_masquerade")
|
||||
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",
|
||||
"ip rule del iif lo uidrange 0-0 lookup local_network priority 11000")
|
||||
|
||||
fun commit() = transaction.commit()
|
||||
fun revert() = transaction.revert()
|
||||
fun commit() {
|
||||
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 {
|
||||
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()
|
||||
|
||||
|
||||
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() {
|
||||
override fun put(key: Any, value: () -> Unit): (() -> Unit)? {
|
||||
val result = super.put(key, value)
|
||||
if (result == null) value()
|
||||
return result
|
||||
}
|
||||
override fun put(key: Any, value: () -> Unit): (() -> Unit)? =
|
||||
super.put(key, value).also { if (it == null) value() }
|
||||
}
|
||||
|
||||
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>() {
|
||||
override fun put(key: Any, value: (T) -> Unit): ((T) -> Unit)? {
|
||||
val result = super.put(key, value)
|
||||
if (result == null) value(fire())
|
||||
return result
|
||||
override fun put(key: Any, value: (T) -> Unit): ((T) -> Unit)? =
|
||||
super.put(key, value).also { if (it == null) value(fire()) }
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.Log
|
||||
import androidx.core.os.postDelayed
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import com.crashlytics.android.Crashlytics
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.collections.ArrayList
|
||||
import kotlin.concurrent.withLock
|
||||
@@ -14,6 +16,8 @@ class RootSession : AutoCloseable {
|
||||
companion object {
|
||||
private const val TAG = "RootSession"
|
||||
|
||||
val handler = Handler(HandlerThread("$TAG-HandlerThread").apply { start() }.looper)
|
||||
|
||||
private val monitor = ReentrantLock()
|
||||
private fun onUnlock() {
|
||||
if (monitor.holdCount == 1) instance?.startTimeout()
|
||||
@@ -72,8 +76,10 @@ class RootSession : AutoCloseable {
|
||||
shell.close()
|
||||
if (instance == this) instance = null
|
||||
}
|
||||
private fun startTimeout() = app.handler.postDelayed(60 * 1000, this) { monitor.withLock { close() } }
|
||||
private fun haltTimeout() = app.handler.removeCallbacksAndMessages(this)
|
||||
private fun startTimeout() = handler.postDelayed(TimeUnit.MINUTES.toMillis(5), this) {
|
||||
monitor.withLock { close() }
|
||||
}
|
||||
private fun haltTimeout() = handler.removeCallbacksAndMessages(this)
|
||||
|
||||
/**
|
||||
* Don't care about the results, but still sync.
|
||||
@@ -95,11 +101,12 @@ class RootSession : AutoCloseable {
|
||||
}).exec()
|
||||
}
|
||||
fun exec(command: String) = checkOutput(command, execQuiet(command))
|
||||
fun execOut(command: String): String {
|
||||
fun execOutUnjoined(command: String): List<String> {
|
||||
val result = execQuiet(command)
|
||||
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.
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
package be.mygod.vpnhotspot.util
|
||||
|
||||
import android.content.*
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.databinding.BindingAdapter
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import be.mygod.vpnhotspot.BuildConfig
|
||||
import be.mygod.vpnhotspot.R
|
||||
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
||||
import com.crashlytics.android.Crashlytics
|
||||
import java.net.InetAddress
|
||||
import java.net.NetworkInterface
|
||||
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?) {
|
||||
if (BuildConfig.DEBUG) Log.d(tag, message)
|
||||
Crashlytics.log("$tag: $message")
|
||||
@@ -51,6 +64,19 @@ fun NetworkInterface.formatAddresses() =
|
||||
}))
|
||||
.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.
|
||||
*/
|
||||
@@ -69,3 +95,6 @@ fun Context.stopAndUnbind(connection: ServiceConnection) {
|
||||
connection.onServiceDisconnected(null)
|
||||
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
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:focusable="true"
|
||||
android:background="?android:attr/selectableItemBackground"
|
||||
android:padding="16dp">
|
||||
|
||||
<ImageView
|
||||
@@ -32,15 +34,15 @@
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@{client.title}"
|
||||
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"/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@{client.description}"
|
||||
android:textIsSelectable="true"
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
tools:text="wlan0"/>
|
||||
|
||||
<be.mygod.vpnhotspot.widget.AutoCollapseTextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@{data.text}"
|
||||
android:textIsSelectable="@{data.selectable}"
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead"/>
|
||||
|
||||
<be.mygod.vpnhotspot.widget.AutoCollapseTextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@{data.addresses}"
|
||||
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_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_downstream">下游</string>
|
||||
<string name="settings_service_masquerade">IP 掩蔽</string>
|
||||
|
||||
@@ -61,6 +61,25 @@
|
||||
<string name="connected_state_valid">%s (reachable)</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_downstream">Downstream</string>
|
||||
<string name="settings_service_masquerade">IP Masquerade</string>
|
||||
|
||||
Reference in New Issue
Block a user