Implement MAC lookup (#68)

* Implement MAC lookup

* Refine error processing

* Use long to store MAC consistently

* Link back to macvendors.co

* Undo some havoc

* Do not show mac spans for TV

* Show MAC and IP in a consistent order

* Add IP spans by ipinfo.io

* Add SpanFormatter

* Fix IPv6 ipinfo.io link

* Refine SpanFormatter

* Fix pressing the link
This commit is contained in:
Mygod
2019-01-26 21:20:40 +08:00
committed by GitHub
parent 94114f7a4b
commit d4208affbb
38 changed files with 562 additions and 112 deletions

View File

@@ -11,44 +11,63 @@ import be.mygod.vpnhotspot.net.InetAddressComparator
import be.mygod.vpnhotspot.net.IpNeighbour
import be.mygod.vpnhotspot.net.TetherType
import be.mygod.vpnhotspot.room.AppDatabase
import be.mygod.vpnhotspot.room.macToLong
import be.mygod.vpnhotspot.room.ClientRecord
import be.mygod.vpnhotspot.room.macToString
import be.mygod.vpnhotspot.util.makeIpSpan
import be.mygod.vpnhotspot.util.makeMacSpan
import be.mygod.vpnhotspot.util.onEmpty
import java.net.InetAddress
import java.util.*
open class Client(val mac: String, val iface: String) {
open class Client(val mac: Long, val iface: String) {
companion object DiffCallback : DiffUtil.ItemCallback<Client>() {
override fun areItemsTheSame(oldItem: Client, newItem: Client) =
oldItem.iface == newItem.iface && oldItem.mac == newItem.mac
override fun areContentsTheSame(oldItem: Client, newItem: Client) = oldItem == newItem
}
private val macIface get() = "$mac%$iface"
val ip = TreeMap<InetAddress, IpNeighbour.State>(InetAddressComparator)
val record = AppDatabase.instance.clientRecordDao.lookupSync(mac.macToLong())
val macString by lazy { mac.macToString() }
private val record = AppDatabase.instance.clientRecordDao.lookupSync(mac)
private val macIface get() = SpannableStringBuilder(makeMacSpan(macString)).apply {
append('%')
append(iface)
}
val nickname get() = record.value?.nickname ?: ""
val blocked get() = record.value?.blocked == true
open val icon get() = TetherType.ofInterface(iface).icon
val title = Transformations.map(record) { record ->
SpannableStringBuilder(record.nickname.onEmpty(macIface)).apply {
if (record.blocked) setSpan(StrikethroughSpan(), 0, length, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
/**
* we hijack the get title process to check if we need to perform MacLookup,
* as record might not be initialized in other more appropriate places
*/
if (record?.nickname.isNullOrEmpty() && record?.macLookupPending != false) MacLookup.perform(mac)
SpannableStringBuilder(record?.nickname.onEmpty(macIface)).apply {
if (record?.blocked == true) {
setSpan(StrikethroughSpan(), 0, length, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
}
}
}
val titleSelectable = Transformations.map(record) { it?.nickname.isNullOrEmpty() }
val description = Transformations.map(record) { record ->
StringBuilder(if (record.nickname.isEmpty()) "" else "$macIface\n").apply {
SpannableStringBuilder().apply {
if (!record?.nickname.isNullOrEmpty()) appendln(macIface)
ip.entries.forEach { (ip, state) ->
appendln(app.getString(when (state) {
append(makeIpSpan(ip.hostAddress))
appendln(app.getText(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))
}))
}
}.toString().trimEnd()
}.trimEnd()
}
fun obtainRecord() = record.value ?: ClientRecord(mac)
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false

View File

@@ -12,6 +12,7 @@ import be.mygod.vpnhotspot.RepeaterService
import be.mygod.vpnhotspot.net.IpNeighbour
import be.mygod.vpnhotspot.net.TetheringManager
import be.mygod.vpnhotspot.net.monitor.IpNeighbourMonitor
import be.mygod.vpnhotspot.room.macToLong
import be.mygod.vpnhotspot.util.broadcastReceiver
class ClientViewModel : ViewModel(), ServiceConnection, IpNeighbourMonitor.Callback {
@@ -29,11 +30,12 @@ class ClientViewModel : ViewModel(), ServiceConnection, IpNeighbourMonitor.Callb
val clients = MutableLiveData<List<Client>>()
private fun populateClients() {
val clients = HashMap<Pair<String, String>, Client>()
val clients = HashMap<Pair<String, Long>, Client>()
val group = repeater?.group
val p2pInterface = group?.`interface`
if (p2pInterface != null) {
for (client in p2p) clients[Pair(p2pInterface, client.deviceAddress)] = WifiP2pClient(p2pInterface, client)
for (client in p2p) clients[Pair(p2pInterface, client.deviceAddress.macToLong())] =
WifiP2pClient(p2pInterface, client)
}
for (neighbour in neighbours) {
val key = Pair(neighbour.dev, neighbour.lladdr)
@@ -45,7 +47,7 @@ class ClientViewModel : ViewModel(), ServiceConnection, IpNeighbourMonitor.Callb
}
client.ip += Pair(neighbour.ip, neighbour.state)
}
this.clients.postValue(clients.values.sortedWith(compareBy<Client> { it.iface }.thenBy { it.mac }))
this.clients.postValue(clients.values.sortedWith(compareBy<Client> { it.iface }.thenBy { it.macString }))
}
private fun refreshP2p() {

View File

@@ -4,6 +4,7 @@ import android.content.DialogInterface
import android.os.Bundle
import android.text.format.DateUtils
import android.text.format.Formatter
import android.text.method.LinkMovementMethod
import android.util.LongSparseArray
import android.view.LayoutInflater
import android.view.MenuItem
@@ -31,18 +32,27 @@ import be.mygod.vpnhotspot.databinding.FragmentClientsBinding
import be.mygod.vpnhotspot.databinding.ListitemClientBinding
import be.mygod.vpnhotspot.net.monitor.IpNeighbourMonitor
import be.mygod.vpnhotspot.net.monitor.TrafficRecorder
import be.mygod.vpnhotspot.room.*
import be.mygod.vpnhotspot.room.AppDatabase
import be.mygod.vpnhotspot.room.ClientStats
import be.mygod.vpnhotspot.room.TrafficRecord
import be.mygod.vpnhotspot.room.macToString
import be.mygod.vpnhotspot.util.MainScope
import be.mygod.vpnhotspot.util.SpanFormatter
import be.mygod.vpnhotspot.util.computeIfAbsentCompat
import be.mygod.vpnhotspot.util.toPluralInt
import be.mygod.vpnhotspot.widget.SmartSnackbar
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.text.NumberFormat
class ClientsFragment : Fragment() {
data class NicknameArg(val mac: String, val nickname: CharSequence) : VersionedParcelable
class ClientsFragment : Fragment(), MainScope by MainScope.Supervisor() {
data class NicknameArg(val mac: Long, val nickname: CharSequence) : VersionedParcelable
class NicknameDialogFragment : AlertDialogFragment<NicknameArg, Empty>() {
override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) {
setView(R.layout.dialog_nickname)
setTitle(getString(R.string.clients_nickname_title, arg.mac))
setTitle(getString(R.string.clients_nickname_title, arg.mac.macToString()))
setPositiveButton(android.R.string.ok, listener)
setNegativeButton(android.R.string.cancel, null)
}
@@ -53,8 +63,11 @@ class ClientsFragment : Fragment() {
}
override fun onClick(dialog: DialogInterface?, which: Int) {
AppDatabase.instance.clientRecordDao.upsert(arg.mac.macToLong()) {
nickname = this@NicknameDialogFragment.dialog!!.findViewById<EditText>(android.R.id.edit).text
GlobalScope.launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED) {
MacLookup.abort(arg.mac)
AppDatabase.instance.clientRecordDao.upsert(arg.mac) {
nickname = this@NicknameDialogFragment.dialog!!.findViewById<EditText>(android.R.id.edit).text
}
}
}
}
@@ -62,7 +75,7 @@ class ClientsFragment : Fragment() {
data class StatsArg(val title: CharSequence, val stats: ClientStats) : VersionedParcelable
class StatsDialogFragment : AlertDialogFragment<StatsArg, Empty>() {
override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) {
setTitle(getString(R.string.clients_stats_title, arg.title))
setTitle(SpanFormatter.format(getString(R.string.clients_stats_title), arg.title))
val context = context
val resources = resources
val format = NumberFormat.getIntegerInstance(resources.configuration.locale)
@@ -93,8 +106,9 @@ class ClientsFragment : Fragment() {
private inner class ClientViewHolder(val binding: ListitemClientBinding) : RecyclerView.ViewHolder(binding.root),
View.OnClickListener, PopupMenu.OnMenuItemClickListener {
init {
binding.setLifecycleOwner(this@ClientsFragment) // todo some way better?
binding.setLifecycleOwner(this@ClientsFragment)
binding.root.setOnClickListener(this)
binding.description.movementMethod = LinkMovementMethod.getInstance()
}
override fun onClick(v: View) {
@@ -116,11 +130,10 @@ class ClientsFragment : Fragment() {
}
R.id.block, R.id.unblock -> {
val client = binding.client ?: return false
val wasWorking = TrafficRecorder.isWorking(client.mac.macToLong())
client.record.apply {
val value = value ?: ClientRecord(client.mac.macToLong())
value.blocked = !value.blocked
AppDatabase.instance.clientRecordDao.update(value)
val wasWorking = TrafficRecorder.isWorking(client.mac)
client.obtainRecord().apply {
blocked = !blocked
AppDatabase.instance.clientRecordDao.update(this)
}
IpNeighbourMonitor.instance?.flush()
if (!wasWorking && item.itemId == R.id.block) {
@@ -129,10 +142,13 @@ class ClientsFragment : Fragment() {
true
}
R.id.stats -> {
val client = binding.client ?: return false
StatsDialogFragment().withArg(StatsArg(client.title.value!!, // todo?
AppDatabase.instance.trafficRecordDao.queryStats(client.mac.macToLong())))
.show(fragmentManager ?: return false, "StatsDialogFragment")
binding.client?.let { client ->
launch(start = CoroutineStart.UNDISPATCHED) {
StatsDialogFragment().withArg(StatsArg(client.title.value!!,
AppDatabase.instance.trafficRecordDao.queryStats(client.mac)))
.show(fragmentManager ?: return@launch, "StatsDialogFragment")
}
}
true
}
else -> false
@@ -153,7 +169,7 @@ class ClientsFragment : Fragment() {
val client = getItem(position)
holder.binding.client = client
holder.binding.rate =
rates.computeIfAbsentCompat(Pair(client.iface, client.mac.macToLong())) { TrafficRate() }
rates.computeIfAbsentCompat(Pair(client.iface, client.mac)) { TrafficRate() }
holder.binding.executePendingBindings()
}
@@ -211,4 +227,9 @@ class ClientsFragment : Fragment() {
TrafficRecorder.foregroundListeners -= this
super.onStop()
}
override fun onDestroy() {
job.cancel()
super.onDestroy()
}
}

View File

@@ -0,0 +1,62 @@
package be.mygod.vpnhotspot.client
import androidx.annotation.MainThread
import be.mygod.vpnhotspot.room.AppDatabase
import be.mygod.vpnhotspot.room.macToString
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.json.JSONException
import org.json.JSONObject
import timber.log.Timber
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
/**
* This class generates a default nickname for new clients.
*/
object MacLookup {
class UnexpectedError(mac: Long, val error: String) :
JSONException("Server returned error for ${mac.macToString()}: $error")
private val macLookupBusy = mutableMapOf<Long, Pair<HttpURLConnection, Job>>()
private val countryCodeRegex = "[A-Z]{2}".toRegex()
@MainThread
fun abort(mac: Long) = macLookupBusy.remove(mac)?.let { (conn, job) ->
job.cancel()
conn.disconnect()
}
@MainThread
fun perform(mac: Long) {
abort(mac)
val conn = URL("https://macvendors.co/api/" + mac.macToString()).openConnection() as HttpURLConnection
macLookupBusy[mac] = conn to GlobalScope.launch(Dispatchers.IO) {
try {
val response = conn.inputStream.bufferedReader().readText()
val obj = JSONObject(response).getJSONObject("result")
obj.optString("error", null)?.also { throw UnexpectedError(mac, it) }
val company = obj.getString("company")
val country = obj.optString("country")
if (countryCodeRegex.matchEntire(country) == null) Timber.w(UnexpectedError(mac, response))
val result = if (country != null) {
String(country.flatMap { listOf('\uD83C', it + 0xDDA5) }.toCharArray()) + ' ' + company
} else company
AppDatabase.instance.clientRecordDao.upsert(mac) {
nickname = result
macLookupPending = false
}
} catch (e: IOException) {
Timber.d(e)
} catch (e: JSONException) {
if ((e as? UnexpectedError)?.error == "no result") {
// no vendor found, we should not retry in the future
AppDatabase.instance.clientRecordDao.upsert(mac) { macLookupPending = false }
} else Timber.w(e)
}
}
}
}

View File

@@ -2,7 +2,8 @@ package be.mygod.vpnhotspot.client
import android.net.wifi.p2p.WifiP2pDevice
import be.mygod.vpnhotspot.net.TetherType
import be.mygod.vpnhotspot.room.macToLong
class WifiP2pClient(p2pInterface: String, p2p: WifiP2pDevice) : Client(p2p.deviceAddress ?: "", p2pInterface) {
class WifiP2pClient(p2pInterface: String, p2p: WifiP2pDevice) : Client(p2p.deviceAddress!!.macToLong(), p2pInterface) {
override val icon: Int get() = TetherType.WIFI_P2P.icon
}