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:
@@ -6,7 +6,7 @@ buildscript {
|
||||
ext {
|
||||
kotlinVersion = '1.3.20'
|
||||
lifecycleVersion = '2.0.0'
|
||||
roomVersion = '2.0.0'
|
||||
roomVersion = '2.1.0-alpha03'
|
||||
}
|
||||
repositories {
|
||||
google()
|
||||
|
||||
3
mobile/.gitignore
vendored
3
mobile/.gitignore
vendored
@@ -1,6 +1,3 @@
|
||||
/build
|
||||
/debug
|
||||
release/
|
||||
|
||||
# tests aren't ready yet
|
||||
/src/androidTest
|
||||
|
||||
@@ -55,6 +55,9 @@ android {
|
||||
buildConfigField "boolean", "DONATIONS", "false"
|
||||
}
|
||||
}
|
||||
sourceSets {
|
||||
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -67,7 +70,7 @@ dependencies {
|
||||
implementation "androidx.lifecycle:lifecycle-extensions:$lifecycleVersion"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion"
|
||||
implementation "androidx.preference:preference:1.1.0-alpha02"
|
||||
implementation "androidx.room:room-runtime:$roomVersion"
|
||||
implementation "androidx.room:room-coroutines:$roomVersion"
|
||||
implementation 'com.github.luongvo:BadgeView:1.1.5'
|
||||
implementation 'com.github.topjohnwu.libsu:core:2.2.0'
|
||||
implementation "com.google.android.material:material:1.0.0"
|
||||
@@ -75,14 +78,16 @@ dependencies {
|
||||
implementation 'com.linkedin.dexmaker:dexmaker:2.21.0'
|
||||
implementation 'com.takisoft.preferencex:preferencex-simplemenu:1.0.0'
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion"
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.1.1'
|
||||
baseImplementation 'com.android.billingclient:billing:1.2'
|
||||
baseImplementation 'com.crashlytics.sdk.android:crashlytics:2.9.8'
|
||||
baseImplementation 'com.google.firebase:firebase-core:16.0.6'
|
||||
testImplementation "androidx.arch.core:core-testing:$lifecycleVersion"
|
||||
testImplementation "androidx.room:room-testing:$roomVersion"
|
||||
testImplementation 'junit:junit:4.12'
|
||||
androidTestImplementation "androidx.room:room-testing:$roomVersion"
|
||||
androidTestImplementation 'androidx.test:runner:1.1.1'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
|
||||
androidTestImplementation "androidx.test.ext:junit-ktx:1.1.0"
|
||||
}
|
||||
|
||||
if (getGradle().getStartParameter().getTaskRequests().toString().contains("Base"))
|
||||
|
||||
152
mobile/schemas/be.mygod.vpnhotspot.room.AppDatabase/2.json
Normal file
152
mobile/schemas/be.mygod.vpnhotspot.room.AppDatabase/2.json
Normal file
@@ -0,0 +1,152 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 2,
|
||||
"identityHash": "92a6c0406ed7265dbd98eb3c24095651",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "ClientRecord",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`mac` INTEGER NOT NULL, `nickname` BLOB NOT NULL, `blocked` INTEGER NOT NULL, `macLookupPending` 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
|
||||
},
|
||||
{
|
||||
"fieldPath": "macLookupPending",
|
||||
"columnName": "macLookupPending",
|
||||
"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, \"92a6c0406ed7265dbd98eb3c24095651\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package be.mygod.vpnhotspot.room
|
||||
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.IOException
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MigrationTest {
|
||||
companion object {
|
||||
private const val TEST_DB = "migration-test"
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
val privateDatabase = MigrationTestHelper(InstrumentationRegistry.getInstrumentation(),
|
||||
AppDatabase::class.java.canonicalName, FrameworkSQLiteOpenHelperFactory())
|
||||
|
||||
@Test
|
||||
@Throws(IOException::class)
|
||||
fun migrate2() {
|
||||
val db = privateDatabase.createDatabase(TEST_DB, 1)
|
||||
db.close()
|
||||
privateDatabase.runMigrationsAndValidate(TEST_DB, 2, true, AppDatabase.Migration2)
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@ import android.widget.Spinner
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatDialogFragment
|
||||
import androidx.core.net.toUri
|
||||
import androidx.versionedparcelable.VersionedParcelable
|
||||
import be.mygod.vpnhotspot.util.launchUrl
|
||||
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
||||
import com.android.billingclient.api.*
|
||||
import timber.log.Timber
|
||||
@@ -62,9 +62,7 @@ class EBegFragment : AppCompatDialogFragment(), PurchasesUpdatedListener, Billin
|
||||
}
|
||||
@Suppress("ConstantConditionIf")
|
||||
if (BuildConfig.DONATIONS) (view.findViewById<ViewStub>(R.id.donations__more_stub).inflate() as Button)
|
||||
.setOnClickListener {
|
||||
(activity as MainActivity).launchUrl("https://mygod.be/donate/".toUri())
|
||||
}
|
||||
.setOnClickListener { requireContext().launchUrl("https://mygod.be/donate/") }
|
||||
}
|
||||
|
||||
private fun openDialog(@StringRes title: Int, @StringRes message: Int) {
|
||||
|
||||
@@ -7,8 +7,8 @@ import android.view.ViewGroup
|
||||
import android.view.ViewStub
|
||||
import android.widget.Button
|
||||
import android.widget.LinearLayout
|
||||
import androidx.core.net.toUri
|
||||
import androidx.appcompat.app.AppCompatDialogFragment
|
||||
import be.mygod.vpnhotspot.util.launchUrl
|
||||
|
||||
/**
|
||||
* Based on: https://github.com/PrivacyApps/donations/blob/747d36a18433c7e9329691054122a8ad337a62d2/Donations/src/main/java/org/sufficientlysecure/donations/DonationsFragment.java
|
||||
@@ -21,7 +21,7 @@ class EBegFragment : AppCompatDialogFragment() {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
view.findViewById<LinearLayout>(R.id.donations__google).visibility = View.GONE
|
||||
(view.findViewById<ViewStub>(R.id.donations__more_stub).inflate() as Button).setOnClickListener {
|
||||
(activity as MainActivity).launchUrl("https://mygod.be/donate/".toUri())
|
||||
requireContext().launchUrl("https://mygod.be/donate/")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,9 @@ import android.content.res.Configuration
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.preference.PreferenceManager
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import be.mygod.vpnhotspot.room.AppDatabase
|
||||
import be.mygod.vpnhotspot.util.DeviceStorageApp
|
||||
@@ -47,12 +48,18 @@ class App : Application() {
|
||||
}
|
||||
|
||||
lateinit var deviceStorage: Application
|
||||
val handler = Handler()
|
||||
val pref: SharedPreferences by lazy { PreferenceManager.getDefaultSharedPreferences(deviceStorage) }
|
||||
val connectivity by lazy { getSystemService<ConnectivityManager>()!! }
|
||||
val uiMode by lazy { getSystemService<UiModeManager>()!! }
|
||||
val wifi by lazy { getSystemService<WifiManager>()!! }
|
||||
|
||||
val hasTouch by lazy { packageManager.hasSystemFeature("android.hardware.faketouch") }
|
||||
val customTabsIntent by lazy {
|
||||
CustomTabsIntent.Builder()
|
||||
.setToolbarColor(ContextCompat.getColor(this, R.color.colorPrimary))
|
||||
.build()
|
||||
}
|
||||
|
||||
val operatingChannel: Int get() {
|
||||
val result = pref.getString(KEY_OPERATING_CHANNEL, null)?.toIntOrNull() ?: 0
|
||||
return if (result in 1..165) result else 0
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
package be.mygod.vpnhotspot
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.fragment.app.Fragment
|
||||
@@ -27,19 +24,6 @@ import q.rorbin.badgeview.QBadgeView
|
||||
class MainActivity : AppCompatActivity(), BottomNavigationView.OnNavigationItemSelectedListener {
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
private lateinit var badge: QBadgeView
|
||||
private val customTabsIntent by lazy {
|
||||
CustomTabsIntent.Builder()
|
||||
.setToolbarColor(ContextCompat.getColor(this, R.color.colorPrimary))
|
||||
.build()
|
||||
}
|
||||
|
||||
fun launchUrl(url: Uri) {
|
||||
if (packageManager.hasSystemFeature("android.hardware.faketouch")) try {
|
||||
customTabsIntent.launchUrl(this, url)
|
||||
return
|
||||
} catch (_: ActivityNotFoundException) { } catch (_: SecurityException) { }
|
||||
SmartSnackbar.make(url.toString()).show()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
@@ -11,10 +11,10 @@ import android.net.wifi.p2p.WifiP2pGroup
|
||||
import android.net.wifi.p2p.WifiP2pInfo
|
||||
import android.net.wifi.p2p.WifiP2pManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.os.postDelayed
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import be.mygod.vpnhotspot.net.wifi.WifiDoubleLock
|
||||
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper
|
||||
@@ -105,6 +105,7 @@ class RepeaterService : Service(), WifiP2pManager.ChannelListener, SharedPrefere
|
||||
private val p2pManager get() = RepeaterService.p2pManager!!
|
||||
private var channel: WifiP2pManager.Channel? = null
|
||||
private val binder = Binder()
|
||||
private val handler = Handler()
|
||||
private var receiverRegistered = false
|
||||
private val receiver = broadcastReceiver { _, intent ->
|
||||
when (intent.action) {
|
||||
@@ -180,7 +181,7 @@ class RepeaterService : Service(), WifiP2pManager.ChannelListener, SharedPrefere
|
||||
setOperatingChannel()
|
||||
} catch (e: RuntimeException) {
|
||||
Timber.w(e)
|
||||
app.handler.postDelayed(1000, this, this::onChannelDisconnected)
|
||||
handler.postDelayed(this::onChannelDisconnected, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,7 +319,7 @@ class RepeaterService : Service(), WifiP2pManager.ChannelListener, SharedPrefere
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
app.handler.removeCallbacksAndMessages(this)
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
if (status != Status.IDLE) binder.shutdown()
|
||||
clean() // force clean to prevent leakage
|
||||
app.pref.unregisterOnSharedPreferenceChangeListener(this)
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.net.toUri
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.preference.SwitchPreference
|
||||
@@ -19,6 +18,7 @@ import be.mygod.vpnhotspot.preference.AlwaysAutoCompleteEditTextPreferenceDialog
|
||||
import be.mygod.vpnhotspot.preference.SharedPreferenceDataStore
|
||||
import be.mygod.vpnhotspot.preference.SummaryFallbackProvider
|
||||
import be.mygod.vpnhotspot.util.RootSession
|
||||
import be.mygod.vpnhotspot.util.launchUrl
|
||||
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
@@ -120,7 +120,7 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() {
|
||||
true
|
||||
}
|
||||
findPreference<Preference>("misc.source").setOnPreferenceClickListener {
|
||||
(activity as MainActivity).launchUrl("https://github.com/Mygod/VPNHotspot/blob/master/README.md".toUri())
|
||||
requireContext().launchUrl("https://github.com/Mygod/VPNHotspot/blob/master/README.md")
|
||||
true
|
||||
}
|
||||
findPreference<Preference>("misc.donate").setOnPreferenceClickListener {
|
||||
|
||||
@@ -11,6 +11,9 @@ import be.mygod.vpnhotspot.net.wifi.WifiDoubleLock
|
||||
import be.mygod.vpnhotspot.util.Event0
|
||||
import be.mygod.vpnhotspot.util.broadcastReceiver
|
||||
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
class TetheringService : IpNeighbourMonitoringService() {
|
||||
@@ -90,7 +93,7 @@ class TetheringService : IpNeighbourMonitoringService() {
|
||||
}
|
||||
updateNotification()
|
||||
}
|
||||
app.handler.post { binder.routingsChanged() }
|
||||
GlobalScope.launch(Dispatchers.Main) { binder.routingsChanged() }
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?) = binder
|
||||
|
||||
@@ -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,16 +63,19 @@ class ClientsFragment : Fragment() {
|
||||
}
|
||||
|
||||
override fun onClick(dialog: DialogInterface?, which: Int) {
|
||||
AppDatabase.instance.clientRecordDao.upsert(arg.mac.macToLong()) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ class InterfaceManager(private val parent: TetheringFragment, val iface: String)
|
||||
override val active get() = parent.binder?.isActive(iface) == true
|
||||
}
|
||||
|
||||
val addresses = parent.ifaceLookup[iface]?.formatAddresses() ?: ""
|
||||
private val addresses = parent.ifaceLookup[iface]?.formatAddresses() ?: ""
|
||||
override val type get() = VIEW_TYPE_INTERFACE
|
||||
private val data = Data()
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.net.wifi.WifiConfiguration
|
||||
import android.net.wifi.p2p.WifiP2pGroup
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.view.WindowManager
|
||||
import android.widget.EditText
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
@@ -32,7 +33,11 @@ import java.net.NetworkInterface
|
||||
import java.net.SocketException
|
||||
|
||||
class RepeaterManager(private val parent: TetheringFragment) : Manager(), ServiceConnection {
|
||||
class ViewHolder(val binding: ListitemRepeaterBinding) : RecyclerView.ViewHolder(binding.root)
|
||||
class ViewHolder(val binding: ListitemRepeaterBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||
init {
|
||||
binding.addresses.movementMethod = LinkMovementMethod.getInstance()
|
||||
}
|
||||
}
|
||||
inner class Data : BaseObservable() {
|
||||
val switchEnabled: Boolean
|
||||
@Bindable get() = when (binder?.service?.status) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package be.mygod.vpnhotspot.net
|
||||
|
||||
import be.mygod.vpnhotspot.room.macToLong
|
||||
import be.mygod.vpnhotspot.util.parseNumericAddress
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
@@ -8,7 +9,7 @@ import java.net.InetAddress
|
||||
import java.net.NetworkInterface
|
||||
import java.net.SocketException
|
||||
|
||||
data class IpNeighbour(val ip: InetAddress, val dev: String, val lladdr: String, val state: State) {
|
||||
data class IpNeighbour(val ip: InetAddress, val dev: String, val lladdr: Long, val state: State) {
|
||||
enum class State {
|
||||
INCOMPLETE, VALID, FAILED, DELETING
|
||||
}
|
||||
@@ -50,13 +51,13 @@ data class IpNeighbour(val ip: InetAddress, val dev: String, val lladdr: String,
|
||||
"NOARP" -> return emptyList() // skip
|
||||
else -> throw IllegalArgumentException("Unknown state encountered: ${match.groupValues[7]}")
|
||||
}
|
||||
val result = IpNeighbour(ip, dev, lladdr, state)
|
||||
val result = IpNeighbour(ip, dev, lladdr.macToLong(), state)
|
||||
val devParser = devFallback.matchEntire(dev)
|
||||
if (devParser != null) try {
|
||||
val index = devParser.groupValues[1].toInt()
|
||||
val iface = NetworkInterface.getByIndex(index)
|
||||
if (iface == null) Timber.w("Failed to find network interface #$index")
|
||||
else return listOf(IpNeighbour(ip, iface.name, lladdr, state), result)
|
||||
else return listOf(IpNeighbour(ip, iface.name, lladdr.macToLong(), state), result)
|
||||
} catch (_: SocketException) { }
|
||||
listOf(result)
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -8,10 +8,10 @@ import be.mygod.vpnhotspot.net.monitor.IpNeighbourMonitor
|
||||
import be.mygod.vpnhotspot.net.monitor.TrafficRecorder
|
||||
import be.mygod.vpnhotspot.net.monitor.UpstreamMonitor
|
||||
import be.mygod.vpnhotspot.room.AppDatabase
|
||||
import be.mygod.vpnhotspot.room.macToLong
|
||||
import be.mygod.vpnhotspot.util.RootSession
|
||||
import be.mygod.vpnhotspot.util.computeIfAbsentCompat
|
||||
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import timber.log.Timber
|
||||
import java.net.*
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
@@ -146,7 +146,7 @@ class Routing(val downstream: String, ownerAddress: InterfaceAddress? = null) :
|
||||
}
|
||||
private val upstream = Upstream(RULE_PRIORITY_UPSTREAM)
|
||||
|
||||
private inner class Client(private val ip: Inet4Address, mac: String) : AutoCloseable {
|
||||
private inner class Client(private val ip: Inet4Address, mac: Long) : AutoCloseable {
|
||||
private val transaction = RootSession.beginTransaction().safeguard {
|
||||
val address = ip.hostAddress
|
||||
iptablesInsert("vpnhotspot_fwd -i $downstream -s $address -j ACCEPT")
|
||||
@@ -172,7 +172,8 @@ class Routing(val downstream: String, ownerAddress: InterfaceAddress? = null) :
|
||||
val toRemove = HashSet(clients.keys)
|
||||
for (neighbour in neighbours) {
|
||||
if (neighbour.dev != downstream || neighbour.ip !is Inet4Address ||
|
||||
AppDatabase.instance.clientRecordDao.lookup(neighbour.lladdr.macToLong())?.blocked == true) continue
|
||||
runBlocking { AppDatabase.instance.clientRecordDao.lookup(neighbour.lladdr) }
|
||||
?.blocked == true) continue
|
||||
toRemove.remove(neighbour.ip)
|
||||
try {
|
||||
clients.computeIfAbsentCompat(neighbour.ip) { Client(neighbour.ip, neighbour.lladdr) }
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package be.mygod.vpnhotspot.net.monitor
|
||||
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import be.mygod.vpnhotspot.net.IpNeighbour
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.InetAddress
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
@@ -61,7 +63,7 @@ class IpNeighbourMonitor private constructor() : IpMonitor() {
|
||||
|
||||
private fun postUpdateLocked() {
|
||||
if (updatePosted || instance != this) return
|
||||
app.handler.post {
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
val neighbours = synchronized(neighbours) {
|
||||
updatePosted = false
|
||||
neighbours.values.toList()
|
||||
|
||||
@@ -6,7 +6,6 @@ import be.mygod.vpnhotspot.DebugHelper
|
||||
import be.mygod.vpnhotspot.net.Routing.Companion.IPTABLES
|
||||
import be.mygod.vpnhotspot.room.AppDatabase
|
||||
import be.mygod.vpnhotspot.room.TrafficRecord
|
||||
import be.mygod.vpnhotspot.room.macToLong
|
||||
import be.mygod.vpnhotspot.util.Event2
|
||||
import be.mygod.vpnhotspot.util.RootSession
|
||||
import be.mygod.vpnhotspot.util.parseNumericAddress
|
||||
@@ -24,11 +23,8 @@ object TrafficRecorder {
|
||||
private val records = HashMap<Pair<InetAddress, String>, TrafficRecord>()
|
||||
val foregroundListeners = Event2<Collection<TrafficRecord>, LongSparseArray<TrafficRecord>>()
|
||||
|
||||
fun register(ip: InetAddress, downstream: String, mac: String) {
|
||||
val record = TrafficRecord(
|
||||
mac = mac.macToLong(),
|
||||
ip = ip,
|
||||
downstream = downstream)
|
||||
fun register(ip: InetAddress, downstream: String, mac: Long) {
|
||||
val record = TrafficRecord(mac = mac, ip = ip, downstream = downstream)
|
||||
AppDatabase.instance.trafficRecordDao.insert(record)
|
||||
synchronized(this) {
|
||||
DebugHelper.log(TAG, "Registering $ip%$downstream")
|
||||
|
||||
@@ -4,9 +4,11 @@ import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
|
||||
@Database(entities = [ClientRecord::class, TrafficRecord::class], version = 1)
|
||||
@Database(entities = [ClientRecord::class, TrafficRecord::class], version = 2)
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
companion object {
|
||||
@@ -14,6 +16,9 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
|
||||
val instance by lazy {
|
||||
Room.databaseBuilder(app.deviceStorage, AppDatabase::class.java, DB_NAME)
|
||||
.addMigrations(
|
||||
Migration2
|
||||
)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
}
|
||||
@@ -21,4 +26,9 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
|
||||
abstract val clientRecordDao: ClientRecord.Dao
|
||||
abstract val trafficRecordDao: TrafficRecord.Dao
|
||||
|
||||
object Migration2 : Migration(1, 2) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) =
|
||||
database.execSQL("ALTER TABLE `ClientRecord` ADD COLUMN `macLookupPending` INTEGER NOT NULL DEFAULT 1")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,14 @@ import androidx.room.*
|
||||
data class ClientRecord(@PrimaryKey
|
||||
val mac: Long,
|
||||
var nickname: CharSequence = "",
|
||||
var blocked: Boolean = false) {
|
||||
var blocked: Boolean = false,
|
||||
var macLookupPending: Boolean = true) {
|
||||
@androidx.room.Dao
|
||||
abstract class Dao {
|
||||
@Query("SELECT * FROM `ClientRecord` WHERE `mac` = :mac")
|
||||
abstract fun lookup(mac: Long): ClientRecord?
|
||||
abstract suspend fun lookup(mac: Long): ClientRecord?
|
||||
|
||||
fun lookupOrDefault(mac: Long) = lookup(mac) ?: ClientRecord(mac)
|
||||
suspend fun lookupOrDefault(mac: Long) = lookup(mac) ?: ClientRecord(mac)
|
||||
|
||||
@Query("SELECT * FROM `ClientRecord` WHERE `mac` = :mac")
|
||||
abstract fun lookupSync(mac: Long): LiveData<ClientRecord>
|
||||
@@ -23,7 +24,7 @@ data class ClientRecord(@PrimaryKey
|
||||
fun update(value: ClientRecord) = check(updateInternal(value) == value.mac)
|
||||
|
||||
@Transaction
|
||||
open fun upsert(mac: Long, operation: ClientRecord.() -> Unit) = lookupOrDefault(mac).apply {
|
||||
open suspend fun upsert(mac: Long, operation: ClientRecord.() -> Unit) = lookupOrDefault(mac).apply {
|
||||
operation()
|
||||
update(this)
|
||||
}
|
||||
|
||||
@@ -50,8 +50,9 @@ fun String.macToLong(): Long = ByteBuffer.allocate(8).run {
|
||||
long
|
||||
}
|
||||
|
||||
fun Iterable<Byte>.macToString() = joinToString(":") { "%02x".format(it) }
|
||||
fun Long.macToString(): String = ByteBuffer.allocate(8).run {
|
||||
order(ByteOrder.LITTLE_ENDIAN)
|
||||
putLong(this@macToString)
|
||||
array().take(6).joinToString(":") { "%02x".format(it) }
|
||||
array().take(6).macToString()
|
||||
}
|
||||
|
||||
@@ -55,8 +55,9 @@ data class TrafficRecord(
|
||||
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""")
|
||||
abstract fun queryStats(mac: Long): ClientStats
|
||||
WHERE TrafficRecord.mac = :mac AND Next.id IS NULL
|
||||
""")
|
||||
abstract suspend fun queryStats(mac: Long): ClientStats
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package be.mygod.vpnhotspot.util
|
||||
|
||||
import android.text.style.URLSpan
|
||||
import android.view.View
|
||||
|
||||
class CustomTabsUrlSpan(url: String) : URLSpan(url) {
|
||||
override fun onClick(widget: View) = widget.context.launchUrl(url)
|
||||
}
|
||||
14
mobile/src/main/java/be/mygod/vpnhotspot/util/MainScope.kt
Normal file
14
mobile/src/main/java/be/mygod/vpnhotspot/util/MainScope.kt
Normal file
@@ -0,0 +1,14 @@
|
||||
package be.mygod.vpnhotspot.util
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
|
||||
interface MainScope : CoroutineScope {
|
||||
class Supervisor : MainScope {
|
||||
override val job = SupervisorJob()
|
||||
}
|
||||
val job: Job
|
||||
override val coroutineContext get() = Dispatchers.Main + job
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package be.mygod.vpnhotspot.util
|
||||
|
||||
import android.text.Spannable
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import android.text.SpannedString
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Provides [String.format] style functions that work with [Spanned] strings and preserve formatting.
|
||||
*
|
||||
* https://github.com/george-steel/android-utils/blob/289aff11e53593a55d780f9f5986e49343a79e55/src/org/oshkimaadziig/george/androidutils/SpanFormatter.java
|
||||
*
|
||||
* @author George T. Steel
|
||||
*/
|
||||
object SpanFormatter {
|
||||
private val formatSequence = "%([0-9]+\\$|<?)([^a-zA-z%]*)([[a-zA-Z%]&&[^tT]]|[tT][a-zA-Z])".toPattern()
|
||||
|
||||
/**
|
||||
* Version of [String.format] that works on [Spanned] strings to preserve rich text formatting.
|
||||
* Both the `format` as well as any `%s args` can be Spanned and will have their formatting preserved.
|
||||
* Due to the way [Spannable]s work, any argument's spans will can only be included **once** in the result.
|
||||
* Any duplicates will appear as text only.
|
||||
*
|
||||
* @param format the format string (see [java.util.Formatter.format])
|
||||
* @param args
|
||||
* the list of arguments passed to the formatter. If there are
|
||||
* more arguments than required by `format`,
|
||||
* additional arguments are ignored.
|
||||
* @return the formatted string (with spans).
|
||||
*/
|
||||
fun format(format: CharSequence, vararg args: Any) = format(Locale.getDefault(), format, *args)
|
||||
|
||||
/**
|
||||
* Version of [String.format] that works on [Spanned] strings to preserve rich text formatting.
|
||||
* Both the `format` as well as any `%s args` can be Spanned and will have their formatting preserved.
|
||||
* Due to the way [Spannable]s work, any argument's spans will can only be included **once** in the result.
|
||||
* Any duplicates will appear as text only.
|
||||
*
|
||||
* @param locale
|
||||
* the locale to apply; `null` value means no localization.
|
||||
* @param format the format string (see [java.util.Formatter.format])
|
||||
* @param args
|
||||
* the list of arguments passed to the formatter.
|
||||
* @return the formatted string (with spans).
|
||||
* @see String.format
|
||||
*/
|
||||
fun format(locale: Locale, format: CharSequence, vararg args: Any): SpannedString {
|
||||
val out = SpannableStringBuilder(format)
|
||||
|
||||
var i = 0
|
||||
var argAt = -1
|
||||
|
||||
while (i < out.length) {
|
||||
val m = formatSequence.matcher(out)
|
||||
if (!m.find(i)) break
|
||||
i = m.start()
|
||||
val exprEnd = m.end()
|
||||
|
||||
val argTerm = m.group(1)
|
||||
val modTerm = m.group(2)
|
||||
val typeTerm = m.group(3)
|
||||
|
||||
val cookedArg = when (typeTerm) {
|
||||
"%" -> "%"
|
||||
"n" -> "\n"
|
||||
else -> {
|
||||
val argItem = args[when (argTerm) {
|
||||
"" -> ++argAt
|
||||
"<" -> argAt
|
||||
else -> Integer.parseInt(argTerm.substring(0, argTerm.length - 1)) - 1
|
||||
}]
|
||||
if (typeTerm == "s" && argItem is Spanned) argItem else {
|
||||
String.format(locale, "%$modTerm$typeTerm", argItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out.replace(i, exprEnd, cookedArg)
|
||||
i += cookedArg.length
|
||||
}
|
||||
|
||||
return SpannedString(out)
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,18 @@ import android.content.*
|
||||
import android.os.Build
|
||||
import android.system.Os
|
||||
import android.system.OsConstants
|
||||
import android.text.Spannable
|
||||
import android.text.SpannableString
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.databinding.BindingAdapter
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import be.mygod.vpnhotspot.room.macToString
|
||||
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
||||
import java.net.InetAddress
|
||||
import java.net.NetworkInterface
|
||||
import java.net.SocketException
|
||||
@@ -43,20 +50,40 @@ fun setVisibility(view: View, value: Boolean) {
|
||||
view.isVisible = value
|
||||
}
|
||||
|
||||
fun NetworkInterface.formatAddresses() =
|
||||
(interfaceAddresses.asSequence()
|
||||
.map { "${it.address.hostAddress}/${it.networkPrefixLength}" }
|
||||
.toList() +
|
||||
listOfNotNull(try {
|
||||
hardwareAddress?.joinToString(":") { "%02x".format(it) }
|
||||
} catch (_: SocketException) {
|
||||
null
|
||||
}))
|
||||
.joinToString("\n")
|
||||
fun makeIpSpan(ip: String) = SpannableString(ip).apply {
|
||||
if (app.hasTouch) {
|
||||
val filteredIp = ip.split('%', limit = 2).first()
|
||||
setSpan(CustomTabsUrlSpan("https://ipinfo.io/$filteredIp"), 0, filteredIp.length,
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
}
|
||||
}
|
||||
fun makeMacSpan(mac: String) = SpannableString(mac).apply {
|
||||
if (app.hasTouch) {
|
||||
setSpan(CustomTabsUrlSpan("https://macvendors.co/results/$mac"), 0, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
}
|
||||
}
|
||||
|
||||
fun NetworkInterface.formatAddresses() = SpannableStringBuilder().apply {
|
||||
try {
|
||||
hardwareAddress?.apply { appendln(makeMacSpan(asIterable().macToString())) }
|
||||
} catch (_: SocketException) { }
|
||||
for (address in interfaceAddresses) {
|
||||
append(makeIpSpan(address.address.hostAddress))
|
||||
appendln("/${address.networkPrefixLength}")
|
||||
}
|
||||
}.trimEnd()
|
||||
|
||||
fun parseNumericAddress(address: String?): InetAddress? =
|
||||
Os.inet_pton(OsConstants.AF_INET, address) ?: Os.inet_pton(OsConstants.AF_INET6, address)
|
||||
|
||||
fun Context.launchUrl(url: String) {
|
||||
if (app.hasTouch) try {
|
||||
app.customTabsIntent.launchUrl(this, url.toUri())
|
||||
return
|
||||
} catch (_: ActivityNotFoundException) { } catch (_: SecurityException) { }
|
||||
SmartSnackbar.make(url).show()
|
||||
}
|
||||
|
||||
fun Context.stopAndUnbind(connection: ServiceConnection) {
|
||||
connection.onServiceDisconnected(null)
|
||||
unbindService(connection)
|
||||
|
||||
@@ -2,12 +2,11 @@ package be.mygod.vpnhotspot.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
import androidx.core.view.isGone
|
||||
|
||||
class AutoCollapseTextView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = android.R.attr.textViewStyle) :
|
||||
AppCompatTextView(context, attrs, defStyleAttr) {
|
||||
LinkTextView(context, attrs, defStyleAttr) {
|
||||
override fun onTextChanged(text: CharSequence?, start: Int, lengthBefore: Int, lengthAfter: Int) {
|
||||
super.onTextChanged(text, start, lengthBefore, lengthAfter)
|
||||
isGone = text.isNullOrEmpty()
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package be.mygod.vpnhotspot.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.util.AttributeSet
|
||||
import androidx.appcompat.widget.AppCompatTextView
|
||||
|
||||
open class LinkTextView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = android.R.attr.textViewStyle) :
|
||||
AppCompatTextView(context, attrs, defStyleAttr) {
|
||||
override fun setTextIsSelectable(selectable: Boolean) {
|
||||
super.setTextIsSelectable(selectable)
|
||||
movementMethod = LinkMovementMethod.getInstance() // override what was set in setTextIsSelectable
|
||||
}
|
||||
}
|
||||
@@ -36,15 +36,16 @@
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
<be.mygod.vpnhotspot.widget.LinkTextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@{client.title}"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead"
|
||||
android:textIsSelectable="@{client.record.nickname.length() == 0}"
|
||||
android:textIsSelectable="@{client.titleSelectable}"
|
||||
tools:text="01:23:45:ab:cd:ef%p2p-p2p0-0"/>
|
||||
|
||||
<be.mygod.vpnhotspot.widget.AutoCollapseTextView
|
||||
android:id="@+id/description"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@{client.description}"
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
tools:text="wlan0"/>
|
||||
|
||||
<be.mygod.vpnhotspot.widget.AutoCollapseTextView
|
||||
android:id="@+id/text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@{data.text}"
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead"/>
|
||||
|
||||
<be.mygod.vpnhotspot.widget.AutoCollapseTextView
|
||||
android:id="@+id/addresses"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@{data.addresses}"
|
||||
|
||||
@@ -54,9 +54,9 @@
|
||||
<string name="tethering_manage_bluetooth">蓝牙网络共享</string>
|
||||
<string name="tethering_manage_failed">Android 系统无法打开网络共享。</string>
|
||||
|
||||
<string name="connected_state_incomplete">%s (正在连接)</string>
|
||||
<string name="connected_state_valid">%s (已连上)</string>
|
||||
<string name="connected_state_failed">%s (已断开)</string>
|
||||
<string name="connected_state_incomplete">(正在连接)</string>
|
||||
<string name="connected_state_valid">(已连上)</string>
|
||||
<string name="connected_state_failed">(已断开)</string>
|
||||
|
||||
<string name="clients_popup_nickname">昵称…</string>
|
||||
<string name="clients_popup_block">拉黑</string>
|
||||
|
||||
@@ -58,9 +58,9 @@
|
||||
<string name="tethering_manage_bluetooth">Bluetooth tethering</string>
|
||||
<string name="tethering_manage_failed">Android system has failed to start tethering.</string>
|
||||
|
||||
<string name="connected_state_incomplete">%s (connecting)</string>
|
||||
<string name="connected_state_valid">%s (reachable)</string>
|
||||
<string name="connected_state_failed">%s (lost)</string>
|
||||
<string name="connected_state_incomplete">" (connecting)"</string>
|
||||
<string name="connected_state_valid">" (reachable)"</string>
|
||||
<string name="connected_state_failed">" (lost)"</string>
|
||||
|
||||
<string name="clients_popup_nickname">Nickname…</string>
|
||||
<string name="clients_popup_block">Block</string>
|
||||
|
||||
Reference in New Issue
Block a user