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:
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
62
mobile/src/main/java/be/mygod/vpnhotspot/client/MacLookup.kt
Normal file
62
mobile/src/main/java/be/mygod/vpnhotspot/client/MacLookup.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user