VPN Hotspot 2.0: Client+ (#39)

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

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

View File

@@ -77,6 +77,7 @@ API light grey list:
* [`Landroid/net/wifi/p2p/WifiP2pManager;->requestPersistentGroupInfo(Landroid/net/wifi/p2p/WifiP2pManager$Channel;Landroid/net/wifi/p2p/WifiP2pManager$PersistentGroupInfoListener;)V`](https://android.googlesource.com/platform/prebuilts/runtime/+/94fec32/appcompat/hiddenapi-light-greylist.txt#4412)
* [`Landroid/net/wifi/p2p/WifiP2pManager;->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:

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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'

View File

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

View File

@@ -10,6 +10,7 @@ import android.os.Build
import android.os.Handler
import android.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()

View File

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

View File

@@ -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()

View File

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

View File

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

View File

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

View File

@@ -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()

View File

@@ -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()
}
}

View File

@@ -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)!!)

View File

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

View File

@@ -1,11 +1,13 @@
package be.mygod.vpnhotspot.net
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)

View File

@@ -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"

View File

@@ -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()
}
}
}

View File

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

View File

@@ -14,7 +14,7 @@ abstract class UpstreamMonitor {
private fun generateMonitor(): UpstreamMonitor {
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()

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,11 +12,8 @@ open class Event0 : ConcurrentHashMap<Any, () -> Unit>() {
}
class StickyEvent0 : Event0() {
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)
}
}

View File

@@ -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.

View File

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

View File

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

View File

@@ -11,6 +11,8 @@
<LinearLayout
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"

View File

@@ -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}"

View File

@@ -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"

View File

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

View File

@@ -57,6 +57,22 @@
<string name="connected_state_valid">%s (已连上)</string>
<string name="connected_state_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>

View File

@@ -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>