diff --git a/README.md b/README.md index 6e9ec918..73b6bdf2 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/build.gradle b/build.gradle index 21440af6..99485d20 100644 --- a/build.gradle +++ b/build.gradle @@ -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" } } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index f4809947..8220c46a 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/mobile/build.gradle b/mobile/build.gradle index 229796d3..b3257caa 100644 --- a/mobile/build.gradle +++ b/mobile/build.gradle @@ -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' diff --git a/mobile/schemas/be.mygod.vpnhotspot.room.AppDatabase/1.json b/mobile/schemas/be.mygod.vpnhotspot.room.AppDatabase/1.json new file mode 100644 index 00000000..cbab8a8e --- /dev/null +++ b/mobile/schemas/be.mygod.vpnhotspot.room.AppDatabase/1.json @@ -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\")" + ] + } +} \ No newline at end of file diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/App.kt b/mobile/src/main/java/be/mygod/vpnhotspot/App.kt index 64502e95..e497b4d6 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/App.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/App.kt @@ -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() diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt index 67d8154f..13afdab6 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt @@ -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) } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyInterfaceManager.kt b/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyInterfaceManager.kt index e499186a..f7aa41b3 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyInterfaceManager.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyInterfaceManager.kt @@ -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() @@ -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 = 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() diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt index 7b8810af..aecf7311 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt @@ -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) } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt index 035cddae..09eb7ebf 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt @@ -31,8 +31,9 @@ class TetheringService : IpNeighbourMonitoringService(), UpstreamMonitor.Callbac private var dns: List = 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) { diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/client/Client.kt b/mobile/src/main/java/be/mygod/vpnhotspot/client/Client.kt index 26dd2590..aa75ddf4 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/client/Client.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/client/Client.kt @@ -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() { 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() + private val macIface get() = "$mac%$iface" + val ip = TreeMap(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) { - 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) + 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.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) } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientMonitorService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientMonitorService.kt index e0065f80..bd6ff7b0 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientMonitorService.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientMonitorService.kt @@ -21,7 +21,7 @@ class ClientMonitorService : Service(), ServiceConnection, IpNeighbourMonitor.Ca private var tetheredInterfaces = emptySet() private val receiver = broadcastReceiver { _, intent -> - val extras = intent.extras!! + val extras = intent.extras ?: return@broadcastReceiver tetheredInterfaces = TetheringManager.getTetheredIfaces(extras).toSet() + TetheringManager.getLocalOnlyTetheredIfaces(extras) populateClients() diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientsFragment.kt b/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientsFragment.kt index be75b8e0..5553dce4 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientsFragment.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientsFragment.kt @@ -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(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(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(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) { + private var clientsLookup: LongSparseArray? = null override fun submitList(list: MutableList?) { super.submitList(list) + clientsLookup = if (list == null) null else LongSparseArray(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, oldRecords: LongSparseArray) { + 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() + } } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringFragment.kt b/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringFragment.kt index 15d78e8c..2f0e00cd 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringFragment.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringFragment.kt @@ -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)!!) diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/InetAddressComparator.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/InetAddressComparator.kt new file mode 100644 index 00000000..e168ebb1 --- /dev/null +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/InetAddressComparator.kt @@ -0,0 +1,13 @@ +package be.mygod.vpnhotspot.net + +import java.net.InetAddress + +object InetAddressComparator : Comparator { + 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 + } +} diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbour.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbour.kt index a3e0c589..f6cd0421 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbour.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbour.kt @@ -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> { 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) diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbourMonitor.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbourMonitor.kt index 21468c0b..db0e2fa6 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbourMonitor.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbourMonitor.kt @@ -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() + private val neighbours = HashMap() override val monitoredObject: String get() = "neigh" diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/Routing.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/Routing.kt index 4a312308..b0005ee8 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/Routing.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/Routing.kt @@ -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 is not supported on 7.1-. @@ -22,16 +34,19 @@ class Routing(val upstream: String?, private val downstream: String, ownerAddres */ private val IPTABLES = if (Build.VERSION.SDK_INT >= 26) "iptables -w 1" else "iptables -w" - fun 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") - it.submit("$IPTABLES -X vpnhotspot_fwd") - it.submit("while $IPTABLES -t nat -D POSTROUTING -j vpnhotspot_masquerade; do done") - it.submit("$IPTABLES -t nat -F vpnhotspot_masquerade") - it.submit("$IPTABLES -t nat -X vpnhotspot_masquerade") - it.submit("while ip rule del priority 17900; do done") - it.submit("while ip rule del iif lo uidrange 0-0 lookup local_network priority 11000; do done") + fun 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") + it.submit("$IPTABLES -X vpnhotspot_fwd") + it.submit("while $IPTABLES -t nat -D POSTROUTING -j vpnhotspot_masquerade; do done") + it.submit("$IPTABLES -t nat -F vpnhotspot_masquerade") + it.submit("$IPTABLES -t nat -X vpnhotspot_masquerade") + it.submit("while ip rule del priority 17900; do done") + it.submit("while ip rule del iif lo uidrange 0-0 lookup local_network priority 11000; do done") + } } fun RootSession.Transaction.iptablesAdd(content: String, table: String = "filter") = @@ -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() + 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? = 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() + } + } } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/TrafficRecorder.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/TrafficRecorder.kt new file mode 100644 index 00000000..0a83187d --- /dev/null +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/TrafficRecorder.kt @@ -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, TrafficRecord>() + val foregroundListeners = Event2, LongSparseArray>() + + 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() + 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() + } +} diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/UpstreamMonitor.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/UpstreamMonitor.kt index 708dfc8c..2cf7aa1c 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/UpstreamMonitor.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/UpstreamMonitor.kt @@ -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() diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/room/AppDatabase.kt b/mobile/src/main/java/be/mygod/vpnhotspot/room/AppDatabase.kt new file mode 100644 index 00000000..768e3202 --- /dev/null +++ b/mobile/src/main/java/be/mygod/vpnhotspot/room/AppDatabase.kt @@ -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 +} diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/room/ClientRecord.kt b/mobile/src/main/java/be/mygod/vpnhotspot/room/ClientRecord.kt new file mode 100644 index 00000000..43d882e7 --- /dev/null +++ b/mobile/src/main/java/be/mygod/vpnhotspot/room/ClientRecord.kt @@ -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) diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/room/Converters.kt b/mobile/src/main/java/be/mygod/vpnhotspot/room/Converters.kt new file mode 100644 index 00000000..dcf2a074 --- /dev/null +++ b/mobile/src/main/java/be/mygod/vpnhotspot/room/Converters.kt @@ -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) } +} diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/room/TrafficRecord.kt b/mobile/src/main/java/be/mygod/vpnhotspot/room/TrafficRecord.kt new file mode 100644 index 00000000..05b0e416 --- /dev/null +++ b/mobile/src/main/java/be/mygod/vpnhotspot/room/TrafficRecord.kt @@ -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 { + override fun createFromParcel(parcel: Parcel) = ClientStats(parcel) + + override fun newArray(size: Int) = arrayOfNulls(size) + } +} diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/util/Events.kt b/mobile/src/main/java/be/mygod/vpnhotspot/util/Events.kt index d6e39beb..68f38b7f 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/util/Events.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/util/Events.kt @@ -12,11 +12,8 @@ open class Event0 : ConcurrentHashMap 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 : ConcurrentHashMap Unit>() { @@ -26,9 +23,12 @@ open class Event1 : ConcurrentHashMap Unit>() { } class StickyEvent1(private val fire: () -> T) : Event1() { - 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 : ConcurrentHashMap Unit>() { + operator fun invoke(arg1: T1, arg2: T2) { + for ((_, handler) in this) handler(arg1, arg2) } } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/util/RootSession.kt b/mobile/src/main/java/be/mygod/vpnhotspot/util/RootSession.kt index 98b8f899..a2cdf2c6 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/util/RootSession.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/util/RootSession.kt @@ -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 { 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. diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/util/Utils.kt b/mobile/src/main/java/be/mygod/vpnhotspot/util/Utils.kt index b97bc70c..c8475560 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/util/Utils.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/util/Utils.kt @@ -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 HashMap.computeIfAbsentCompat(key: K, value: () -> V) = if (Build.VERSION.SDK_INT >= 26) + computeIfAbsent(key) { value() } else this[key] ?: value().also { put(key, it) } diff --git a/mobile/src/main/res/layout/dialog_nickname.xml b/mobile/src/main/res/layout/dialog_nickname.xml new file mode 100644 index 00000000..b0677114 --- /dev/null +++ b/mobile/src/main/res/layout/dialog_nickname.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/mobile/src/main/res/layout/fragment_repeater.xml b/mobile/src/main/res/layout/fragment_clients.xml similarity index 100% rename from mobile/src/main/res/layout/fragment_repeater.xml rename to mobile/src/main/res/layout/fragment_clients.xml diff --git a/mobile/src/main/res/layout/listitem_client.xml b/mobile/src/main/res/layout/listitem_client.xml index b1c9fce0..fbddf4eb 100644 --- a/mobile/src/main/res/layout/listitem_client.xml +++ b/mobile/src/main/res/layout/listitem_client.xml @@ -11,6 +11,8 @@ + + + + + + diff --git a/mobile/src/main/res/values-zh-rCN/strings.xml b/mobile/src/main/res/values-zh-rCN/strings.xml index 6853e103..d2b528ce 100644 --- a/mobile/src/main/res/values-zh-rCN/strings.xml +++ b/mobile/src/main/res/values-zh-rCN/strings.xml @@ -57,6 +57,22 @@ %s (已连上) %s (已断开) + 昵称… + 拉黑 + 洗白 + 流量… + %s 的昵称 + %s 的流量 + + 自 %2$s 以来连了 %1$s 次 + + + 上传 %1$s 个包,%2$s + + + 下载 %1$s 个包,%2$s + + 上游 下游 IP 掩蔽 diff --git a/mobile/src/main/res/values/strings.xml b/mobile/src/main/res/values/strings.xml index 551a2cba..943f19dd 100644 --- a/mobile/src/main/res/values/strings.xml +++ b/mobile/src/main/res/values/strings.xml @@ -61,6 +61,25 @@ %s (reachable) %s (lost) + Nickname… + Block + Unblock + Stats… + Nickname for %s + Stats for %s + + Connected 1 time since %2$s + Connected %1$s times since %2$s + + + Sent 1 packet, %2$s + Sent %1$s packets, %2$s + + + Received 1 packet, %2$s + Received %1$s packets, %2$s + + Upstream Downstream IP Masquerade