diff --git a/build.gradle b/build.gradle index a968246b..06c62432 100644 --- a/build.gradle +++ b/build.gradle @@ -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() diff --git a/mobile/.gitignore b/mobile/.gitignore index 92b33fd8..5b914899 100644 --- a/mobile/.gitignore +++ b/mobile/.gitignore @@ -1,6 +1,3 @@ /build /debug release/ - -# tests aren't ready yet -/src/androidTest diff --git a/mobile/build.gradle b/mobile/build.gradle index 977583df..6199898f 100644 --- a/mobile/build.gradle +++ b/mobile/build.gradle @@ -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")) diff --git a/mobile/schemas/be.mygod.vpnhotspot.room.AppDatabase/2.json b/mobile/schemas/be.mygod.vpnhotspot.room.AppDatabase/2.json new file mode 100644 index 00000000..0ff4cdb6 --- /dev/null +++ b/mobile/schemas/be.mygod.vpnhotspot.room.AppDatabase/2.json @@ -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\")" + ] + } +} \ No newline at end of file diff --git a/mobile/src/androidTest/java/be/mygod/vpnhotspot/room/MigrationTest.kt b/mobile/src/androidTest/java/be/mygod/vpnhotspot/room/MigrationTest.kt new file mode 100644 index 00000000..b7c5a154 --- /dev/null +++ b/mobile/src/androidTest/java/be/mygod/vpnhotspot/room/MigrationTest.kt @@ -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) + } +} diff --git a/mobile/src/base/java/be/mygod/vpnhotspot/EBegFragment.kt b/mobile/src/base/java/be/mygod/vpnhotspot/EBegFragment.kt index a6ff9847..c69cec9d 100644 --- a/mobile/src/base/java/be/mygod/vpnhotspot/EBegFragment.kt +++ b/mobile/src/base/java/be/mygod/vpnhotspot/EBegFragment.kt @@ -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(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) { diff --git a/mobile/src/fdroid/java/be/mygod/vpnhotspot/EBegFragment.kt b/mobile/src/fdroid/java/be/mygod/vpnhotspot/EBegFragment.kt index 51d899c3..6e93fd84 100644 --- a/mobile/src/fdroid/java/be/mygod/vpnhotspot/EBegFragment.kt +++ b/mobile/src/fdroid/java/be/mygod/vpnhotspot/EBegFragment.kt @@ -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(R.id.donations__google).visibility = View.GONE (view.findViewById(R.id.donations__more_stub).inflate() as Button).setOnClickListener { - (activity as MainActivity).launchUrl("https://mygod.be/donate/".toUri()) + requireContext().launchUrl("https://mygod.be/donate/") } } } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/App.kt b/mobile/src/main/java/be/mygod/vpnhotspot/App.kt index a0de29de..efe4e3e6 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/App.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/App.kt @@ -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()!! } val uiMode by lazy { getSystemService()!! } val wifi by lazy { getSystemService()!! } + 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 diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/MainActivity.kt b/mobile/src/main/java/be/mygod/vpnhotspot/MainActivity.kt index 39c1b6af..50f589e9 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/MainActivity.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/MainActivity.kt @@ -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) diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt index 479a28cb..9de5293f 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt @@ -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) diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/SettingsPreferenceFragment.kt b/mobile/src/main/java/be/mygod/vpnhotspot/SettingsPreferenceFragment.kt index 538370ca..62d2df97 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/SettingsPreferenceFragment.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/SettingsPreferenceFragment.kt @@ -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("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("misc.donate").setOnPreferenceClickListener { diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt index 3366c28c..ceddb924 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt @@ -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 diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/client/Client.kt b/mobile/src/main/java/be/mygod/vpnhotspot/client/Client.kt index 98d96b0b..01dd121a 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/client/Client.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/client/Client.kt @@ -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() { 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(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 diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientViewModel.kt b/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientViewModel.kt index d43812bb..89dacc44 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientViewModel.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientViewModel.kt @@ -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>() private fun populateClients() { - val clients = HashMap, Client>() + val clients = HashMap, 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 { it.iface }.thenBy { it.mac })) + this.clients.postValue(clients.values.sortedWith(compareBy { it.iface }.thenBy { it.macString })) } private fun refreshP2p() { diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientsFragment.kt b/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientsFragment.kt index 0bb2df26..0a23e72c 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientsFragment.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientsFragment.kt @@ -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() { override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) { setView(R.layout.dialog_nickname) - setTitle(getString(R.string.clients_nickname_title, arg.mac)) + setTitle(getString(R.string.clients_nickname_title, arg.mac.macToString())) setPositiveButton(android.R.string.ok, listener) setNegativeButton(android.R.string.cancel, null) } @@ -53,8 +63,11 @@ class ClientsFragment : Fragment() { } override fun onClick(dialog: DialogInterface?, which: Int) { - AppDatabase.instance.clientRecordDao.upsert(arg.mac.macToLong()) { - nickname = this@NicknameDialogFragment.dialog!!.findViewById(android.R.id.edit).text + GlobalScope.launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED) { + MacLookup.abort(arg.mac) + AppDatabase.instance.clientRecordDao.upsert(arg.mac) { + nickname = this@NicknameDialogFragment.dialog!!.findViewById(android.R.id.edit).text + } } } } @@ -62,7 +75,7 @@ class ClientsFragment : Fragment() { data class StatsArg(val title: CharSequence, val stats: ClientStats) : VersionedParcelable class StatsDialogFragment : AlertDialogFragment() { 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() + } } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/client/MacLookup.kt b/mobile/src/main/java/be/mygod/vpnhotspot/client/MacLookup.kt new file mode 100644 index 00000000..2db9e742 --- /dev/null +++ b/mobile/src/main/java/be/mygod/vpnhotspot/client/MacLookup.kt @@ -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>() + 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) + } + } + } +} diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/client/WifiP2pClient.kt b/mobile/src/main/java/be/mygod/vpnhotspot/client/WifiP2pClient.kt index 5c0904e1..ea5027f3 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/client/WifiP2pClient.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/client/WifiP2pClient.kt @@ -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 } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/manage/InterfaceManager.kt b/mobile/src/main/java/be/mygod/vpnhotspot/manage/InterfaceManager.kt index ff6f4aca..87eebf78 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/manage/InterfaceManager.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/manage/InterfaceManager.kt @@ -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() diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/manage/RepeaterManager.kt b/mobile/src/main/java/be/mygod/vpnhotspot/manage/RepeaterManager.kt index ad6de7ed..fb3be4f4 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/manage/RepeaterManager.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/manage/RepeaterManager.kt @@ -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) { diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbour.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbour.kt index 6136cf5e..5b721ca5 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbour.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbour.kt @@ -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) { diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/Routing.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/Routing.kt index d49e9c2c..6c0e1efe 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/Routing.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/Routing.kt @@ -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) } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/IpNeighbourMonitor.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/IpNeighbourMonitor.kt index e7ef24bc..0a8c379b 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/IpNeighbourMonitor.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/IpNeighbourMonitor.kt @@ -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() diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/TrafficRecorder.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/TrafficRecorder.kt index 04fa1880..677608e2 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/TrafficRecorder.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/TrafficRecorder.kt @@ -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, TrafficRecord>() val foregroundListeners = Event2, LongSparseArray>() - 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") diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/room/AppDatabase.kt b/mobile/src/main/java/be/mygod/vpnhotspot/room/AppDatabase.kt index 768e3202..d97dda58 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/room/AppDatabase.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/room/AppDatabase.kt @@ -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") + } } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/room/ClientRecord.kt b/mobile/src/main/java/be/mygod/vpnhotspot/room/ClientRecord.kt index 1d4fc093..ea6f303e 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/room/ClientRecord.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/room/ClientRecord.kt @@ -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 @@ -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) } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/room/Converters.kt b/mobile/src/main/java/be/mygod/vpnhotspot/room/Converters.kt index 5aee1339..9a4c95de 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/room/Converters.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/room/Converters.kt @@ -50,8 +50,9 @@ fun String.macToLong(): Long = ByteBuffer.allocate(8).run { long } +fun Iterable.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() } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/room/TrafficRecord.kt b/mobile/src/main/java/be/mygod/vpnhotspot/room/TrafficRecord.kt index 451a3a20..163efd0b 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/room/TrafficRecord.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/room/TrafficRecord.kt @@ -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 } } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/util/CustomTabsUrlSpan.kt b/mobile/src/main/java/be/mygod/vpnhotspot/util/CustomTabsUrlSpan.kt new file mode 100644 index 00000000..ac08bb7c --- /dev/null +++ b/mobile/src/main/java/be/mygod/vpnhotspot/util/CustomTabsUrlSpan.kt @@ -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) +} diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/util/MainScope.kt b/mobile/src/main/java/be/mygod/vpnhotspot/util/MainScope.kt new file mode 100644 index 00000000..01760f85 --- /dev/null +++ b/mobile/src/main/java/be/mygod/vpnhotspot/util/MainScope.kt @@ -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 +} diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/util/SpanFormatter.kt b/mobile/src/main/java/be/mygod/vpnhotspot/util/SpanFormatter.kt new file mode 100644 index 00000000..8ed8a93a --- /dev/null +++ b/mobile/src/main/java/be/mygod/vpnhotspot/util/SpanFormatter.kt @@ -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]+\\$| "%" + "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) + } +} diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/util/Utils.kt b/mobile/src/main/java/be/mygod/vpnhotspot/util/Utils.kt index 36a9dc5d..43f27df0 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/util/Utils.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/util/Utils.kt @@ -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) diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/widget/AutoCollapseTextView.kt b/mobile/src/main/java/be/mygod/vpnhotspot/widget/AutoCollapseTextView.kt index 21d4ab2c..ed6bde33 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/widget/AutoCollapseTextView.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/widget/AutoCollapseTextView.kt @@ -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() diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/widget/LinkTextView.kt b/mobile/src/main/java/be/mygod/vpnhotspot/widget/LinkTextView.kt new file mode 100644 index 00000000..54915931 --- /dev/null +++ b/mobile/src/main/java/be/mygod/vpnhotspot/widget/LinkTextView.kt @@ -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 + } +} diff --git a/mobile/src/main/res/layout/listitem_client.xml b/mobile/src/main/res/layout/listitem_client.xml index e394320d..b578f6dc 100644 --- a/mobile/src/main/res/layout/listitem_client.xml +++ b/mobile/src/main/res/layout/listitem_client.xml @@ -36,15 +36,16 @@ android:layout_weight="1" android:orientation="vertical"> - 蓝牙网络共享 Android 系统无法打开网络共享。 - %s (正在连接) - %s (已连上) - %s (已断开) + (正在连接) + (已连上) + (已断开) 昵称… 拉黑 diff --git a/mobile/src/main/res/values/strings.xml b/mobile/src/main/res/values/strings.xml index 0b43c268..5293a179 100644 --- a/mobile/src/main/res/values/strings.xml +++ b/mobile/src/main/res/values/strings.xml @@ -58,9 +58,9 @@ Bluetooth tethering Android system has failed to start tethering. - %s (connecting) - %s (reachable) - %s (lost) + " (connecting)" + " (reachable)" + " (lost)" Nickname… Block