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 {
|
ext {
|
||||||
kotlinVersion = '1.3.20'
|
kotlinVersion = '1.3.20'
|
||||||
lifecycleVersion = '2.0.0'
|
lifecycleVersion = '2.0.0'
|
||||||
roomVersion = '2.0.0'
|
roomVersion = '2.1.0-alpha03'
|
||||||
}
|
}
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
|
|||||||
3
mobile/.gitignore
vendored
3
mobile/.gitignore
vendored
@@ -1,6 +1,3 @@
|
|||||||
/build
|
/build
|
||||||
/debug
|
/debug
|
||||||
release/
|
release/
|
||||||
|
|
||||||
# tests aren't ready yet
|
|
||||||
/src/androidTest
|
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ android {
|
|||||||
buildConfigField "boolean", "DONATIONS", "false"
|
buildConfigField "boolean", "DONATIONS", "false"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
sourceSets {
|
||||||
|
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
@@ -67,7 +70,7 @@ dependencies {
|
|||||||
implementation "androidx.lifecycle:lifecycle-extensions:$lifecycleVersion"
|
implementation "androidx.lifecycle:lifecycle-extensions:$lifecycleVersion"
|
||||||
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion"
|
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion"
|
||||||
implementation "androidx.preference:preference:1.1.0-alpha02"
|
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.luongvo:BadgeView:1.1.5'
|
||||||
implementation 'com.github.topjohnwu.libsu:core:2.2.0'
|
implementation 'com.github.topjohnwu.libsu:core:2.2.0'
|
||||||
implementation "com.google.android.material:material:1.0.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.linkedin.dexmaker:dexmaker:2.21.0'
|
||||||
implementation 'com.takisoft.preferencex:preferencex-simplemenu:1.0.0'
|
implementation 'com.takisoft.preferencex:preferencex-simplemenu:1.0.0'
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion"
|
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.android.billingclient:billing:1.2'
|
||||||
baseImplementation 'com.crashlytics.sdk.android:crashlytics:2.9.8'
|
baseImplementation 'com.crashlytics.sdk.android:crashlytics:2.9.8'
|
||||||
baseImplementation 'com.google.firebase:firebase-core:16.0.6'
|
baseImplementation 'com.google.firebase:firebase-core:16.0.6'
|
||||||
testImplementation "androidx.arch.core:core-testing:$lifecycleVersion"
|
testImplementation "androidx.arch.core:core-testing:$lifecycleVersion"
|
||||||
testImplementation "androidx.room:room-testing:$roomVersion"
|
|
||||||
testImplementation 'junit:junit:4.12'
|
testImplementation 'junit:junit:4.12'
|
||||||
|
androidTestImplementation "androidx.room:room-testing:$roomVersion"
|
||||||
androidTestImplementation 'androidx.test:runner:1.1.1'
|
androidTestImplementation 'androidx.test:runner:1.1.1'
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.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"))
|
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.annotation.StringRes
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.appcompat.app.AppCompatDialogFragment
|
import androidx.appcompat.app.AppCompatDialogFragment
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.versionedparcelable.VersionedParcelable
|
import androidx.versionedparcelable.VersionedParcelable
|
||||||
|
import be.mygod.vpnhotspot.util.launchUrl
|
||||||
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
||||||
import com.android.billingclient.api.*
|
import com.android.billingclient.api.*
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
@@ -62,9 +62,7 @@ class EBegFragment : AppCompatDialogFragment(), PurchasesUpdatedListener, Billin
|
|||||||
}
|
}
|
||||||
@Suppress("ConstantConditionIf")
|
@Suppress("ConstantConditionIf")
|
||||||
if (BuildConfig.DONATIONS) (view.findViewById<ViewStub>(R.id.donations__more_stub).inflate() as Button)
|
if (BuildConfig.DONATIONS) (view.findViewById<ViewStub>(R.id.donations__more_stub).inflate() as Button)
|
||||||
.setOnClickListener {
|
.setOnClickListener { requireContext().launchUrl("https://mygod.be/donate/") }
|
||||||
(activity as MainActivity).launchUrl("https://mygod.be/donate/".toUri())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openDialog(@StringRes title: Int, @StringRes message: Int) {
|
private fun openDialog(@StringRes title: Int, @StringRes message: Int) {
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import android.view.ViewGroup
|
|||||||
import android.view.ViewStub
|
import android.view.ViewStub
|
||||||
import android.widget.Button
|
import android.widget.Button
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.appcompat.app.AppCompatDialogFragment
|
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
|
* 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)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
view.findViewById<LinearLayout>(R.id.donations__google).visibility = View.GONE
|
view.findViewById<LinearLayout>(R.id.donations__google).visibility = View.GONE
|
||||||
(view.findViewById<ViewStub>(R.id.donations__more_stub).inflate() as Button).setOnClickListener {
|
(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.ConnectivityManager
|
||||||
import android.net.wifi.WifiManager
|
import android.net.wifi.WifiManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Handler
|
|
||||||
import android.preference.PreferenceManager
|
import android.preference.PreferenceManager
|
||||||
|
import androidx.browser.customtabs.CustomTabsIntent
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.getSystemService
|
import androidx.core.content.getSystemService
|
||||||
import be.mygod.vpnhotspot.room.AppDatabase
|
import be.mygod.vpnhotspot.room.AppDatabase
|
||||||
import be.mygod.vpnhotspot.util.DeviceStorageApp
|
import be.mygod.vpnhotspot.util.DeviceStorageApp
|
||||||
@@ -47,12 +48,18 @@ class App : Application() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lateinit var deviceStorage: Application
|
lateinit var deviceStorage: Application
|
||||||
val handler = Handler()
|
|
||||||
val pref: SharedPreferences by lazy { PreferenceManager.getDefaultSharedPreferences(deviceStorage) }
|
val pref: SharedPreferences by lazy { PreferenceManager.getDefaultSharedPreferences(deviceStorage) }
|
||||||
val connectivity by lazy { getSystemService<ConnectivityManager>()!! }
|
val connectivity by lazy { getSystemService<ConnectivityManager>()!! }
|
||||||
val uiMode by lazy { getSystemService<UiModeManager>()!! }
|
val uiMode by lazy { getSystemService<UiModeManager>()!! }
|
||||||
val wifi by lazy { getSystemService<WifiManager>()!! }
|
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 operatingChannel: Int get() {
|
||||||
val result = pref.getString(KEY_OPERATING_CHANNEL, null)?.toIntOrNull() ?: 0
|
val result = pref.getString(KEY_OPERATING_CHANNEL, null)?.toIntOrNull() ?: 0
|
||||||
return if (result in 1..165) result else 0
|
return if (result in 1..165) result else 0
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
package be.mygod.vpnhotspot
|
package be.mygod.vpnhotspot
|
||||||
|
|
||||||
import android.content.ActivityNotFoundException
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.browser.customtabs.CustomTabsIntent
|
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.databinding.DataBindingUtil
|
import androidx.databinding.DataBindingUtil
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
@@ -27,19 +24,6 @@ import q.rorbin.badgeview.QBadgeView
|
|||||||
class MainActivity : AppCompatActivity(), BottomNavigationView.OnNavigationItemSelectedListener {
|
class MainActivity : AppCompatActivity(), BottomNavigationView.OnNavigationItemSelectedListener {
|
||||||
private lateinit var binding: ActivityMainBinding
|
private lateinit var binding: ActivityMainBinding
|
||||||
private lateinit var badge: QBadgeView
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import android.net.wifi.p2p.WifiP2pGroup
|
|||||||
import android.net.wifi.p2p.WifiP2pInfo
|
import android.net.wifi.p2p.WifiP2pInfo
|
||||||
import android.net.wifi.p2p.WifiP2pManager
|
import android.net.wifi.p2p.WifiP2pManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.core.content.getSystemService
|
import androidx.core.content.getSystemService
|
||||||
import androidx.core.os.postDelayed
|
|
||||||
import be.mygod.vpnhotspot.App.Companion.app
|
import be.mygod.vpnhotspot.App.Companion.app
|
||||||
import be.mygod.vpnhotspot.net.wifi.WifiDoubleLock
|
import be.mygod.vpnhotspot.net.wifi.WifiDoubleLock
|
||||||
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper
|
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper
|
||||||
@@ -105,6 +105,7 @@ class RepeaterService : Service(), WifiP2pManager.ChannelListener, SharedPrefere
|
|||||||
private val p2pManager get() = RepeaterService.p2pManager!!
|
private val p2pManager get() = RepeaterService.p2pManager!!
|
||||||
private var channel: WifiP2pManager.Channel? = null
|
private var channel: WifiP2pManager.Channel? = null
|
||||||
private val binder = Binder()
|
private val binder = Binder()
|
||||||
|
private val handler = Handler()
|
||||||
private var receiverRegistered = false
|
private var receiverRegistered = false
|
||||||
private val receiver = broadcastReceiver { _, intent ->
|
private val receiver = broadcastReceiver { _, intent ->
|
||||||
when (intent.action) {
|
when (intent.action) {
|
||||||
@@ -180,7 +181,7 @@ class RepeaterService : Service(), WifiP2pManager.ChannelListener, SharedPrefere
|
|||||||
setOperatingChannel()
|
setOperatingChannel()
|
||||||
} catch (e: RuntimeException) {
|
} catch (e: RuntimeException) {
|
||||||
Timber.w(e)
|
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() {
|
override fun onDestroy() {
|
||||||
app.handler.removeCallbacksAndMessages(this)
|
handler.removeCallbacksAndMessages(null)
|
||||||
if (status != Status.IDLE) binder.shutdown()
|
if (status != Status.IDLE) binder.shutdown()
|
||||||
clean() // force clean to prevent leakage
|
clean() // force clean to prevent leakage
|
||||||
app.pref.unregisterOnSharedPreferenceChangeListener(this)
|
app.pref.unregisterOnSharedPreferenceChangeListener(this)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import android.content.Intent
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.preference.Preference
|
import androidx.preference.Preference
|
||||||
import androidx.preference.PreferenceFragmentCompat
|
import androidx.preference.PreferenceFragmentCompat
|
||||||
import androidx.preference.SwitchPreference
|
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.SharedPreferenceDataStore
|
||||||
import be.mygod.vpnhotspot.preference.SummaryFallbackProvider
|
import be.mygod.vpnhotspot.preference.SummaryFallbackProvider
|
||||||
import be.mygod.vpnhotspot.util.RootSession
|
import be.mygod.vpnhotspot.util.RootSession
|
||||||
|
import be.mygod.vpnhotspot.util.launchUrl
|
||||||
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -120,7 +120,7 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
findPreference<Preference>("misc.source").setOnPreferenceClickListener {
|
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
|
true
|
||||||
}
|
}
|
||||||
findPreference<Preference>("misc.donate").setOnPreferenceClickListener {
|
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.Event0
|
||||||
import be.mygod.vpnhotspot.util.broadcastReceiver
|
import be.mygod.vpnhotspot.util.broadcastReceiver
|
||||||
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
class TetheringService : IpNeighbourMonitoringService() {
|
class TetheringService : IpNeighbourMonitoringService() {
|
||||||
@@ -90,7 +93,7 @@ class TetheringService : IpNeighbourMonitoringService() {
|
|||||||
}
|
}
|
||||||
updateNotification()
|
updateNotification()
|
||||||
}
|
}
|
||||||
app.handler.post { binder.routingsChanged() }
|
GlobalScope.launch(Dispatchers.Main) { binder.routingsChanged() }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBind(intent: Intent?) = binder
|
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.IpNeighbour
|
||||||
import be.mygod.vpnhotspot.net.TetherType
|
import be.mygod.vpnhotspot.net.TetherType
|
||||||
import be.mygod.vpnhotspot.room.AppDatabase
|
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 be.mygod.vpnhotspot.util.onEmpty
|
||||||
import java.net.InetAddress
|
import java.net.InetAddress
|
||||||
import java.util.*
|
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>() {
|
companion object DiffCallback : DiffUtil.ItemCallback<Client>() {
|
||||||
override fun areItemsTheSame(oldItem: Client, newItem: Client) =
|
override fun areItemsTheSame(oldItem: Client, newItem: Client) =
|
||||||
oldItem.iface == newItem.iface && oldItem.mac == newItem.mac
|
oldItem.iface == newItem.iface && oldItem.mac == newItem.mac
|
||||||
override fun areContentsTheSame(oldItem: Client, newItem: Client) = oldItem == newItem
|
override fun areContentsTheSame(oldItem: Client, newItem: Client) = oldItem == newItem
|
||||||
}
|
}
|
||||||
|
|
||||||
private val macIface get() = "$mac%$iface"
|
|
||||||
val ip = TreeMap<InetAddress, IpNeighbour.State>(InetAddressComparator)
|
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 nickname get() = record.value?.nickname ?: ""
|
||||||
val blocked get() = record.value?.blocked == true
|
val blocked get() = record.value?.blocked == true
|
||||||
|
|
||||||
open val icon get() = TetherType.ofInterface(iface).icon
|
open val icon get() = TetherType.ofInterface(iface).icon
|
||||||
val title = Transformations.map(record) { record ->
|
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 ->
|
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) ->
|
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.INCOMPLETE -> R.string.connected_state_incomplete
|
||||||
IpNeighbour.State.VALID -> R.string.connected_state_valid
|
IpNeighbour.State.VALID -> R.string.connected_state_valid
|
||||||
IpNeighbour.State.FAILED -> R.string.connected_state_failed
|
IpNeighbour.State.FAILED -> R.string.connected_state_failed
|
||||||
else -> throw IllegalStateException("Invalid IpNeighbour.State: $state")
|
else -> throw IllegalStateException("Invalid IpNeighbour.State: $state")
|
||||||
}, ip.hostAddress))
|
}))
|
||||||
}
|
}
|
||||||
}.toString().trimEnd()
|
}.trimEnd()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun obtainRecord() = record.value ?: ClientRecord(mac)
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
if (this === other) return true
|
if (this === other) return true
|
||||||
if (javaClass != other?.javaClass) return false
|
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.IpNeighbour
|
||||||
import be.mygod.vpnhotspot.net.TetheringManager
|
import be.mygod.vpnhotspot.net.TetheringManager
|
||||||
import be.mygod.vpnhotspot.net.monitor.IpNeighbourMonitor
|
import be.mygod.vpnhotspot.net.monitor.IpNeighbourMonitor
|
||||||
|
import be.mygod.vpnhotspot.room.macToLong
|
||||||
import be.mygod.vpnhotspot.util.broadcastReceiver
|
import be.mygod.vpnhotspot.util.broadcastReceiver
|
||||||
|
|
||||||
class ClientViewModel : ViewModel(), ServiceConnection, IpNeighbourMonitor.Callback {
|
class ClientViewModel : ViewModel(), ServiceConnection, IpNeighbourMonitor.Callback {
|
||||||
@@ -29,11 +30,12 @@ class ClientViewModel : ViewModel(), ServiceConnection, IpNeighbourMonitor.Callb
|
|||||||
val clients = MutableLiveData<List<Client>>()
|
val clients = MutableLiveData<List<Client>>()
|
||||||
|
|
||||||
private fun populateClients() {
|
private fun populateClients() {
|
||||||
val clients = HashMap<Pair<String, String>, Client>()
|
val clients = HashMap<Pair<String, Long>, Client>()
|
||||||
val group = repeater?.group
|
val group = repeater?.group
|
||||||
val p2pInterface = group?.`interface`
|
val p2pInterface = group?.`interface`
|
||||||
if (p2pInterface != null) {
|
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) {
|
for (neighbour in neighbours) {
|
||||||
val key = Pair(neighbour.dev, neighbour.lladdr)
|
val key = Pair(neighbour.dev, neighbour.lladdr)
|
||||||
@@ -45,7 +47,7 @@ class ClientViewModel : ViewModel(), ServiceConnection, IpNeighbourMonitor.Callb
|
|||||||
}
|
}
|
||||||
client.ip += Pair(neighbour.ip, neighbour.state)
|
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() {
|
private fun refreshP2p() {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.DialogInterface
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.format.DateUtils
|
import android.text.format.DateUtils
|
||||||
import android.text.format.Formatter
|
import android.text.format.Formatter
|
||||||
|
import android.text.method.LinkMovementMethod
|
||||||
import android.util.LongSparseArray
|
import android.util.LongSparseArray
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
@@ -31,18 +32,27 @@ import be.mygod.vpnhotspot.databinding.FragmentClientsBinding
|
|||||||
import be.mygod.vpnhotspot.databinding.ListitemClientBinding
|
import be.mygod.vpnhotspot.databinding.ListitemClientBinding
|
||||||
import be.mygod.vpnhotspot.net.monitor.IpNeighbourMonitor
|
import be.mygod.vpnhotspot.net.monitor.IpNeighbourMonitor
|
||||||
import be.mygod.vpnhotspot.net.monitor.TrafficRecorder
|
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.computeIfAbsentCompat
|
||||||
import be.mygod.vpnhotspot.util.toPluralInt
|
import be.mygod.vpnhotspot.util.toPluralInt
|
||||||
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
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
|
import java.text.NumberFormat
|
||||||
|
|
||||||
class ClientsFragment : Fragment() {
|
class ClientsFragment : Fragment(), MainScope by MainScope.Supervisor() {
|
||||||
data class NicknameArg(val mac: String, val nickname: CharSequence) : VersionedParcelable
|
data class NicknameArg(val mac: Long, val nickname: CharSequence) : VersionedParcelable
|
||||||
class NicknameDialogFragment : AlertDialogFragment<NicknameArg, Empty>() {
|
class NicknameDialogFragment : AlertDialogFragment<NicknameArg, Empty>() {
|
||||||
override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) {
|
override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) {
|
||||||
setView(R.layout.dialog_nickname)
|
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)
|
setPositiveButton(android.R.string.ok, listener)
|
||||||
setNegativeButton(android.R.string.cancel, null)
|
setNegativeButton(android.R.string.cancel, null)
|
||||||
}
|
}
|
||||||
@@ -53,16 +63,19 @@ class ClientsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onClick(dialog: DialogInterface?, which: Int) {
|
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
|
nickname = this@NicknameDialogFragment.dialog!!.findViewById<EditText>(android.R.id.edit).text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data class StatsArg(val title: CharSequence, val stats: ClientStats) : VersionedParcelable
|
data class StatsArg(val title: CharSequence, val stats: ClientStats) : VersionedParcelable
|
||||||
class StatsDialogFragment : AlertDialogFragment<StatsArg, Empty>() {
|
class StatsDialogFragment : AlertDialogFragment<StatsArg, Empty>() {
|
||||||
override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) {
|
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 context = context
|
||||||
val resources = resources
|
val resources = resources
|
||||||
val format = NumberFormat.getIntegerInstance(resources.configuration.locale)
|
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),
|
private inner class ClientViewHolder(val binding: ListitemClientBinding) : RecyclerView.ViewHolder(binding.root),
|
||||||
View.OnClickListener, PopupMenu.OnMenuItemClickListener {
|
View.OnClickListener, PopupMenu.OnMenuItemClickListener {
|
||||||
init {
|
init {
|
||||||
binding.setLifecycleOwner(this@ClientsFragment) // todo some way better?
|
binding.setLifecycleOwner(this@ClientsFragment)
|
||||||
binding.root.setOnClickListener(this)
|
binding.root.setOnClickListener(this)
|
||||||
|
binding.description.movementMethod = LinkMovementMethod.getInstance()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClick(v: View) {
|
override fun onClick(v: View) {
|
||||||
@@ -116,11 +130,10 @@ class ClientsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
R.id.block, R.id.unblock -> {
|
R.id.block, R.id.unblock -> {
|
||||||
val client = binding.client ?: return false
|
val client = binding.client ?: return false
|
||||||
val wasWorking = TrafficRecorder.isWorking(client.mac.macToLong())
|
val wasWorking = TrafficRecorder.isWorking(client.mac)
|
||||||
client.record.apply {
|
client.obtainRecord().apply {
|
||||||
val value = value ?: ClientRecord(client.mac.macToLong())
|
blocked = !blocked
|
||||||
value.blocked = !value.blocked
|
AppDatabase.instance.clientRecordDao.update(this)
|
||||||
AppDatabase.instance.clientRecordDao.update(value)
|
|
||||||
}
|
}
|
||||||
IpNeighbourMonitor.instance?.flush()
|
IpNeighbourMonitor.instance?.flush()
|
||||||
if (!wasWorking && item.itemId == R.id.block) {
|
if (!wasWorking && item.itemId == R.id.block) {
|
||||||
@@ -129,10 +142,13 @@ class ClientsFragment : Fragment() {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
R.id.stats -> {
|
R.id.stats -> {
|
||||||
val client = binding.client ?: return false
|
binding.client?.let { client ->
|
||||||
StatsDialogFragment().withArg(StatsArg(client.title.value!!, // todo?
|
launch(start = CoroutineStart.UNDISPATCHED) {
|
||||||
AppDatabase.instance.trafficRecordDao.queryStats(client.mac.macToLong())))
|
StatsDialogFragment().withArg(StatsArg(client.title.value!!,
|
||||||
.show(fragmentManager ?: return false, "StatsDialogFragment")
|
AppDatabase.instance.trafficRecordDao.queryStats(client.mac)))
|
||||||
|
.show(fragmentManager ?: return@launch, "StatsDialogFragment")
|
||||||
|
}
|
||||||
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
else -> false
|
else -> false
|
||||||
@@ -153,7 +169,7 @@ class ClientsFragment : Fragment() {
|
|||||||
val client = getItem(position)
|
val client = getItem(position)
|
||||||
holder.binding.client = client
|
holder.binding.client = client
|
||||||
holder.binding.rate =
|
holder.binding.rate =
|
||||||
rates.computeIfAbsentCompat(Pair(client.iface, client.mac.macToLong())) { TrafficRate() }
|
rates.computeIfAbsentCompat(Pair(client.iface, client.mac)) { TrafficRate() }
|
||||||
holder.binding.executePendingBindings()
|
holder.binding.executePendingBindings()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,4 +227,9 @@ class ClientsFragment : Fragment() {
|
|||||||
TrafficRecorder.foregroundListeners -= this
|
TrafficRecorder.foregroundListeners -= this
|
||||||
super.onStop()
|
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 android.net.wifi.p2p.WifiP2pDevice
|
||||||
import be.mygod.vpnhotspot.net.TetherType
|
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
|
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
|
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
|
override val type get() = VIEW_TYPE_INTERFACE
|
||||||
private val data = Data()
|
private val data = Data()
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import android.net.wifi.WifiConfiguration
|
|||||||
import android.net.wifi.p2p.WifiP2pGroup
|
import android.net.wifi.p2p.WifiP2pGroup
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
|
import android.text.method.LinkMovementMethod
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import android.widget.EditText
|
import android.widget.EditText
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
@@ -32,7 +33,11 @@ import java.net.NetworkInterface
|
|||||||
import java.net.SocketException
|
import java.net.SocketException
|
||||||
|
|
||||||
class RepeaterManager(private val parent: TetheringFragment) : Manager(), ServiceConnection {
|
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() {
|
inner class Data : BaseObservable() {
|
||||||
val switchEnabled: Boolean
|
val switchEnabled: Boolean
|
||||||
@Bindable get() = when (binder?.service?.status) {
|
@Bindable get() = when (binder?.service?.status) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package be.mygod.vpnhotspot.net
|
package be.mygod.vpnhotspot.net
|
||||||
|
|
||||||
|
import be.mygod.vpnhotspot.room.macToLong
|
||||||
import be.mygod.vpnhotspot.util.parseNumericAddress
|
import be.mygod.vpnhotspot.util.parseNumericAddress
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -8,7 +9,7 @@ import java.net.InetAddress
|
|||||||
import java.net.NetworkInterface
|
import java.net.NetworkInterface
|
||||||
import java.net.SocketException
|
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 {
|
enum class State {
|
||||||
INCOMPLETE, VALID, FAILED, DELETING
|
INCOMPLETE, VALID, FAILED, DELETING
|
||||||
}
|
}
|
||||||
@@ -50,13 +51,13 @@ data class IpNeighbour(val ip: InetAddress, val dev: String, val lladdr: String,
|
|||||||
"NOARP" -> return emptyList() // skip
|
"NOARP" -> return emptyList() // skip
|
||||||
else -> throw IllegalArgumentException("Unknown state encountered: ${match.groupValues[7]}")
|
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)
|
val devParser = devFallback.matchEntire(dev)
|
||||||
if (devParser != null) try {
|
if (devParser != null) try {
|
||||||
val index = devParser.groupValues[1].toInt()
|
val index = devParser.groupValues[1].toInt()
|
||||||
val iface = NetworkInterface.getByIndex(index)
|
val iface = NetworkInterface.getByIndex(index)
|
||||||
if (iface == null) Timber.w("Failed to find network interface #$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) { }
|
} catch (_: SocketException) { }
|
||||||
listOf(result)
|
listOf(result)
|
||||||
} catch (e: Exception) {
|
} 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.TrafficRecorder
|
||||||
import be.mygod.vpnhotspot.net.monitor.UpstreamMonitor
|
import be.mygod.vpnhotspot.net.monitor.UpstreamMonitor
|
||||||
import be.mygod.vpnhotspot.room.AppDatabase
|
import be.mygod.vpnhotspot.room.AppDatabase
|
||||||
import be.mygod.vpnhotspot.room.macToLong
|
|
||||||
import be.mygod.vpnhotspot.util.RootSession
|
import be.mygod.vpnhotspot.util.RootSession
|
||||||
import be.mygod.vpnhotspot.util.computeIfAbsentCompat
|
import be.mygod.vpnhotspot.util.computeIfAbsentCompat
|
||||||
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.net.*
|
import java.net.*
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
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 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 {
|
private val transaction = RootSession.beginTransaction().safeguard {
|
||||||
val address = ip.hostAddress
|
val address = ip.hostAddress
|
||||||
iptablesInsert("vpnhotspot_fwd -i $downstream -s $address -j ACCEPT")
|
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)
|
val toRemove = HashSet(clients.keys)
|
||||||
for (neighbour in neighbours) {
|
for (neighbour in neighbours) {
|
||||||
if (neighbour.dev != downstream || neighbour.ip !is Inet4Address ||
|
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)
|
toRemove.remove(neighbour.ip)
|
||||||
try {
|
try {
|
||||||
clients.computeIfAbsentCompat(neighbour.ip) { Client(neighbour.ip, neighbour.lladdr) }
|
clients.computeIfAbsentCompat(neighbour.ip) { Client(neighbour.ip, neighbour.lladdr) }
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package be.mygod.vpnhotspot.net.monitor
|
package be.mygod.vpnhotspot.net.monitor
|
||||||
|
|
||||||
import be.mygod.vpnhotspot.App.Companion.app
|
|
||||||
import be.mygod.vpnhotspot.net.IpNeighbour
|
import be.mygod.vpnhotspot.net.IpNeighbour
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import java.net.InetAddress
|
import java.net.InetAddress
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
@@ -61,7 +63,7 @@ class IpNeighbourMonitor private constructor() : IpMonitor() {
|
|||||||
|
|
||||||
private fun postUpdateLocked() {
|
private fun postUpdateLocked() {
|
||||||
if (updatePosted || instance != this) return
|
if (updatePosted || instance != this) return
|
||||||
app.handler.post {
|
GlobalScope.launch(Dispatchers.Main) {
|
||||||
val neighbours = synchronized(neighbours) {
|
val neighbours = synchronized(neighbours) {
|
||||||
updatePosted = false
|
updatePosted = false
|
||||||
neighbours.values.toList()
|
neighbours.values.toList()
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import be.mygod.vpnhotspot.DebugHelper
|
|||||||
import be.mygod.vpnhotspot.net.Routing.Companion.IPTABLES
|
import be.mygod.vpnhotspot.net.Routing.Companion.IPTABLES
|
||||||
import be.mygod.vpnhotspot.room.AppDatabase
|
import be.mygod.vpnhotspot.room.AppDatabase
|
||||||
import be.mygod.vpnhotspot.room.TrafficRecord
|
import be.mygod.vpnhotspot.room.TrafficRecord
|
||||||
import be.mygod.vpnhotspot.room.macToLong
|
|
||||||
import be.mygod.vpnhotspot.util.Event2
|
import be.mygod.vpnhotspot.util.Event2
|
||||||
import be.mygod.vpnhotspot.util.RootSession
|
import be.mygod.vpnhotspot.util.RootSession
|
||||||
import be.mygod.vpnhotspot.util.parseNumericAddress
|
import be.mygod.vpnhotspot.util.parseNumericAddress
|
||||||
@@ -24,11 +23,8 @@ object TrafficRecorder {
|
|||||||
private val records = HashMap<Pair<InetAddress, String>, TrafficRecord>()
|
private val records = HashMap<Pair<InetAddress, String>, TrafficRecord>()
|
||||||
val foregroundListeners = Event2<Collection<TrafficRecord>, LongSparseArray<TrafficRecord>>()
|
val foregroundListeners = Event2<Collection<TrafficRecord>, LongSparseArray<TrafficRecord>>()
|
||||||
|
|
||||||
fun register(ip: InetAddress, downstream: String, mac: String) {
|
fun register(ip: InetAddress, downstream: String, mac: Long) {
|
||||||
val record = TrafficRecord(
|
val record = TrafficRecord(mac = mac, ip = ip, downstream = downstream)
|
||||||
mac = mac.macToLong(),
|
|
||||||
ip = ip,
|
|
||||||
downstream = downstream)
|
|
||||||
AppDatabase.instance.trafficRecordDao.insert(record)
|
AppDatabase.instance.trafficRecordDao.insert(record)
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
DebugHelper.log(TAG, "Registering $ip%$downstream")
|
DebugHelper.log(TAG, "Registering $ip%$downstream")
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import androidx.room.Database
|
|||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
import androidx.room.TypeConverters
|
import androidx.room.TypeConverters
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
import be.mygod.vpnhotspot.App.Companion.app
|
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)
|
@TypeConverters(Converters::class)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -14,6 +16,9 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
|
|
||||||
val instance by lazy {
|
val instance by lazy {
|
||||||
Room.databaseBuilder(app.deviceStorage, AppDatabase::class.java, DB_NAME)
|
Room.databaseBuilder(app.deviceStorage, AppDatabase::class.java, DB_NAME)
|
||||||
|
.addMigrations(
|
||||||
|
Migration2
|
||||||
|
)
|
||||||
.allowMainThreadQueries()
|
.allowMainThreadQueries()
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
@@ -21,4 +26,9 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
|
|
||||||
abstract val clientRecordDao: ClientRecord.Dao
|
abstract val clientRecordDao: ClientRecord.Dao
|
||||||
abstract val trafficRecordDao: TrafficRecord.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
|
data class ClientRecord(@PrimaryKey
|
||||||
val mac: Long,
|
val mac: Long,
|
||||||
var nickname: CharSequence = "",
|
var nickname: CharSequence = "",
|
||||||
var blocked: Boolean = false) {
|
var blocked: Boolean = false,
|
||||||
|
var macLookupPending: Boolean = true) {
|
||||||
@androidx.room.Dao
|
@androidx.room.Dao
|
||||||
abstract class Dao {
|
abstract class Dao {
|
||||||
@Query("SELECT * FROM `ClientRecord` WHERE `mac` = :mac")
|
@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")
|
@Query("SELECT * FROM `ClientRecord` WHERE `mac` = :mac")
|
||||||
abstract fun lookupSync(mac: Long): LiveData<ClientRecord>
|
abstract fun lookupSync(mac: Long): LiveData<ClientRecord>
|
||||||
@@ -23,7 +24,7 @@ data class ClientRecord(@PrimaryKey
|
|||||||
fun update(value: ClientRecord) = check(updateInternal(value) == value.mac)
|
fun update(value: ClientRecord) = check(updateInternal(value) == value.mac)
|
||||||
|
|
||||||
@Transaction
|
@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()
|
operation()
|
||||||
update(this)
|
update(this)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,8 +50,9 @@ fun String.macToLong(): Long = ByteBuffer.allocate(8).run {
|
|||||||
long
|
long
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun Iterable<Byte>.macToString() = joinToString(":") { "%02x".format(it) }
|
||||||
fun Long.macToString(): String = ByteBuffer.allocate(8).run {
|
fun Long.macToString(): String = ByteBuffer.allocate(8).run {
|
||||||
order(ByteOrder.LITTLE_ENDIAN)
|
order(ByteOrder.LITTLE_ENDIAN)
|
||||||
putLong(this@macToString)
|
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
|
SUM(TrafficRecord.receivedBytes) AS receivedBytes
|
||||||
FROM TrafficRecord LEFT JOIN TrafficRecord AS Next ON TrafficRecord.id = Next.previousId
|
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 */
|
/* 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""")
|
WHERE TrafficRecord.mac = :mac AND Next.id IS NULL
|
||||||
abstract fun queryStats(mac: Long): ClientStats
|
""")
|
||||||
|
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.os.Build
|
||||||
import android.system.Os
|
import android.system.Os
|
||||||
import android.system.OsConstants
|
import android.system.OsConstants
|
||||||
|
import android.text.Spannable
|
||||||
|
import android.text.SpannableString
|
||||||
|
import android.text.SpannableStringBuilder
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.databinding.BindingAdapter
|
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.InetAddress
|
||||||
import java.net.NetworkInterface
|
import java.net.NetworkInterface
|
||||||
import java.net.SocketException
|
import java.net.SocketException
|
||||||
@@ -43,20 +50,40 @@ fun setVisibility(view: View, value: Boolean) {
|
|||||||
view.isVisible = value
|
view.isVisible = value
|
||||||
}
|
}
|
||||||
|
|
||||||
fun NetworkInterface.formatAddresses() =
|
fun makeIpSpan(ip: String) = SpannableString(ip).apply {
|
||||||
(interfaceAddresses.asSequence()
|
if (app.hasTouch) {
|
||||||
.map { "${it.address.hostAddress}/${it.networkPrefixLength}" }
|
val filteredIp = ip.split('%', limit = 2).first()
|
||||||
.toList() +
|
setSpan(CustomTabsUrlSpan("https://ipinfo.io/$filteredIp"), 0, filteredIp.length,
|
||||||
listOfNotNull(try {
|
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
hardwareAddress?.joinToString(":") { "%02x".format(it) }
|
}
|
||||||
} catch (_: SocketException) {
|
}
|
||||||
null
|
fun makeMacSpan(mac: String) = SpannableString(mac).apply {
|
||||||
}))
|
if (app.hasTouch) {
|
||||||
.joinToString("\n")
|
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? =
|
fun parseNumericAddress(address: String?): InetAddress? =
|
||||||
Os.inet_pton(OsConstants.AF_INET, address) ?: Os.inet_pton(OsConstants.AF_INET6, address)
|
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) {
|
fun Context.stopAndUnbind(connection: ServiceConnection) {
|
||||||
connection.onServiceDisconnected(null)
|
connection.onServiceDisconnected(null)
|
||||||
unbindService(connection)
|
unbindService(connection)
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ package be.mygod.vpnhotspot.widget
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import androidx.appcompat.widget.AppCompatTextView
|
|
||||||
import androidx.core.view.isGone
|
import androidx.core.view.isGone
|
||||||
|
|
||||||
class AutoCollapseTextView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null,
|
class AutoCollapseTextView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null,
|
||||||
defStyleAttr: Int = android.R.attr.textViewStyle) :
|
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) {
|
override fun onTextChanged(text: CharSequence?, start: Int, lengthBefore: Int, lengthAfter: Int) {
|
||||||
super.onTextChanged(text, start, lengthBefore, lengthAfter)
|
super.onTextChanged(text, start, lengthBefore, lengthAfter)
|
||||||
isGone = text.isNullOrEmpty()
|
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:layout_weight="1"
|
||||||
android:orientation="vertical">
|
android:orientation="vertical">
|
||||||
|
|
||||||
<TextView
|
<be.mygod.vpnhotspot.widget.LinkTextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@{client.title}"
|
android:text="@{client.title}"
|
||||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead"
|
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"/>
|
tools:text="01:23:45:ab:cd:ef%p2p-p2p0-0"/>
|
||||||
|
|
||||||
<be.mygod.vpnhotspot.widget.AutoCollapseTextView
|
<be.mygod.vpnhotspot.widget.AutoCollapseTextView
|
||||||
|
android:id="@+id/description"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@{client.description}"
|
android:text="@{client.description}"
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
tools:text="wlan0"/>
|
tools:text="wlan0"/>
|
||||||
|
|
||||||
<be.mygod.vpnhotspot.widget.AutoCollapseTextView
|
<be.mygod.vpnhotspot.widget.AutoCollapseTextView
|
||||||
|
android:id="@+id/text"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@{data.text}"
|
android:text="@{data.text}"
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead"/>
|
android:textAppearance="@style/TextAppearance.AppCompat.Subhead"/>
|
||||||
|
|
||||||
<be.mygod.vpnhotspot.widget.AutoCollapseTextView
|
<be.mygod.vpnhotspot.widget.AutoCollapseTextView
|
||||||
|
android:id="@+id/addresses"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@{data.addresses}"
|
android:text="@{data.addresses}"
|
||||||
|
|||||||
@@ -54,9 +54,9 @@
|
|||||||
<string name="tethering_manage_bluetooth">蓝牙网络共享</string>
|
<string name="tethering_manage_bluetooth">蓝牙网络共享</string>
|
||||||
<string name="tethering_manage_failed">Android 系统无法打开网络共享。</string>
|
<string name="tethering_manage_failed">Android 系统无法打开网络共享。</string>
|
||||||
|
|
||||||
<string name="connected_state_incomplete">%s (正在连接)</string>
|
<string name="connected_state_incomplete">(正在连接)</string>
|
||||||
<string name="connected_state_valid">%s (已连上)</string>
|
<string name="connected_state_valid">(已连上)</string>
|
||||||
<string name="connected_state_failed">%s (已断开)</string>
|
<string name="connected_state_failed">(已断开)</string>
|
||||||
|
|
||||||
<string name="clients_popup_nickname">昵称…</string>
|
<string name="clients_popup_nickname">昵称…</string>
|
||||||
<string name="clients_popup_block">拉黑</string>
|
<string name="clients_popup_block">拉黑</string>
|
||||||
|
|||||||
@@ -58,9 +58,9 @@
|
|||||||
<string name="tethering_manage_bluetooth">Bluetooth tethering</string>
|
<string name="tethering_manage_bluetooth">Bluetooth tethering</string>
|
||||||
<string name="tethering_manage_failed">Android system has failed to start 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_incomplete">" (connecting)"</string>
|
||||||
<string name="connected_state_valid">%s (reachable)</string>
|
<string name="connected_state_valid">" (reachable)"</string>
|
||||||
<string name="connected_state_failed">%s (lost)</string>
|
<string name="connected_state_failed">" (lost)"</string>
|
||||||
|
|
||||||
<string name="clients_popup_nickname">Nickname…</string>
|
<string name="clients_popup_nickname">Nickname…</string>
|
||||||
<string name="clients_popup_block">Block</string>
|
<string name="clients_popup_block">Block</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user