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

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

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