Implement MAC lookup (#68)

* Implement MAC lookup

* Refine error processing

* Use long to store MAC consistently

* Link back to macvendors.co

* Undo some havoc

* Do not show mac spans for TV

* Show MAC and IP in a consistent order

* Add IP spans by ipinfo.io

* Add SpanFormatter

* Fix IPv6 ipinfo.io link

* Refine SpanFormatter

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

View File

@@ -6,7 +6,7 @@ buildscript {
ext {
kotlinVersion = '1.3.20'
lifecycleVersion = '2.0.0'
roomVersion = '2.0.0'
roomVersion = '2.1.0-alpha03'
}
repositories {
google()

3
mobile/.gitignore vendored
View File

@@ -1,6 +1,3 @@
/build
/debug
release/
# tests aren't ready yet
/src/androidTest

View File

@@ -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"))

View 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\")"
]
}
}

View File

@@ -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)
}
}

View File

@@ -12,8 +12,8 @@ import android.widget.Spinner
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatDialogFragment
import androidx.core.net.toUri
import androidx.versionedparcelable.VersionedParcelable
import be.mygod.vpnhotspot.util.launchUrl
import be.mygod.vpnhotspot.widget.SmartSnackbar
import com.android.billingclient.api.*
import timber.log.Timber
@@ -62,9 +62,7 @@ class EBegFragment : AppCompatDialogFragment(), PurchasesUpdatedListener, Billin
}
@Suppress("ConstantConditionIf")
if (BuildConfig.DONATIONS) (view.findViewById<ViewStub>(R.id.donations__more_stub).inflate() as Button)
.setOnClickListener {
(activity as MainActivity).launchUrl("https://mygod.be/donate/".toUri())
}
.setOnClickListener { requireContext().launchUrl("https://mygod.be/donate/") }
}
private fun openDialog(@StringRes title: Int, @StringRes message: Int) {

View File

@@ -7,8 +7,8 @@ import android.view.ViewGroup
import android.view.ViewStub
import android.widget.Button
import android.widget.LinearLayout
import androidx.core.net.toUri
import androidx.appcompat.app.AppCompatDialogFragment
import be.mygod.vpnhotspot.util.launchUrl
/**
* Based on: https://github.com/PrivacyApps/donations/blob/747d36a18433c7e9329691054122a8ad337a62d2/Donations/src/main/java/org/sufficientlysecure/donations/DonationsFragment.java
@@ -21,7 +21,7 @@ class EBegFragment : AppCompatDialogFragment() {
super.onViewCreated(view, savedInstanceState)
view.findViewById<LinearLayout>(R.id.donations__google).visibility = View.GONE
(view.findViewById<ViewStub>(R.id.donations__more_stub).inflate() as Button).setOnClickListener {
(activity as MainActivity).launchUrl("https://mygod.be/donate/".toUri())
requireContext().launchUrl("https://mygod.be/donate/")
}
}
}

View File

@@ -8,8 +8,9 @@ import android.content.res.Configuration
import android.net.ConnectivityManager
import android.net.wifi.WifiManager
import android.os.Build
import android.os.Handler
import android.preference.PreferenceManager
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import be.mygod.vpnhotspot.room.AppDatabase
import be.mygod.vpnhotspot.util.DeviceStorageApp
@@ -47,12 +48,18 @@ class App : Application() {
}
lateinit var deviceStorage: Application
val handler = Handler()
val pref: SharedPreferences by lazy { PreferenceManager.getDefaultSharedPreferences(deviceStorage) }
val connectivity by lazy { getSystemService<ConnectivityManager>()!! }
val uiMode by lazy { getSystemService<UiModeManager>()!! }
val wifi by lazy { getSystemService<WifiManager>()!! }
val hasTouch by lazy { packageManager.hasSystemFeature("android.hardware.faketouch") }
val customTabsIntent by lazy {
CustomTabsIntent.Builder()
.setToolbarColor(ContextCompat.getColor(this, R.color.colorPrimary))
.build()
}
val operatingChannel: Int get() {
val result = pref.getString(KEY_OPERATING_CHANNEL, null)?.toIntOrNull() ?: 0
return if (result in 1..165) result else 0

View File

@@ -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)

View File

@@ -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)

View File

@@ -5,7 +5,6 @@ import android.content.Intent
import android.os.Build
import android.os.Bundle
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SwitchPreference
@@ -19,6 +18,7 @@ import be.mygod.vpnhotspot.preference.AlwaysAutoCompleteEditTextPreferenceDialog
import be.mygod.vpnhotspot.preference.SharedPreferenceDataStore
import be.mygod.vpnhotspot.preference.SummaryFallbackProvider
import be.mygod.vpnhotspot.util.RootSession
import be.mygod.vpnhotspot.util.launchUrl
import be.mygod.vpnhotspot.widget.SmartSnackbar
import timber.log.Timber
import java.io.File
@@ -120,7 +120,7 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() {
true
}
findPreference<Preference>("misc.source").setOnPreferenceClickListener {
(activity as MainActivity).launchUrl("https://github.com/Mygod/VPNHotspot/blob/master/README.md".toUri())
requireContext().launchUrl("https://github.com/Mygod/VPNHotspot/blob/master/README.md")
true
}
findPreference<Preference>("misc.donate").setOnPreferenceClickListener {

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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) }

View File

@@ -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()

View File

@@ -6,7 +6,6 @@ import be.mygod.vpnhotspot.DebugHelper
import be.mygod.vpnhotspot.net.Routing.Companion.IPTABLES
import be.mygod.vpnhotspot.room.AppDatabase
import be.mygod.vpnhotspot.room.TrafficRecord
import be.mygod.vpnhotspot.room.macToLong
import be.mygod.vpnhotspot.util.Event2
import be.mygod.vpnhotspot.util.RootSession
import be.mygod.vpnhotspot.util.parseNumericAddress
@@ -24,11 +23,8 @@ object TrafficRecorder {
private val records = HashMap<Pair<InetAddress, String>, TrafficRecord>()
val foregroundListeners = Event2<Collection<TrafficRecord>, LongSparseArray<TrafficRecord>>()
fun register(ip: InetAddress, downstream: String, mac: String) {
val record = TrafficRecord(
mac = mac.macToLong(),
ip = ip,
downstream = downstream)
fun register(ip: InetAddress, downstream: String, mac: Long) {
val record = TrafficRecord(mac = mac, ip = ip, downstream = downstream)
AppDatabase.instance.trafficRecordDao.insert(record)
synchronized(this) {
DebugHelper.log(TAG, "Registering $ip%$downstream")

View File

@@ -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")
}
}

View File

@@ -7,13 +7,14 @@ import androidx.room.*
data class ClientRecord(@PrimaryKey
val mac: Long,
var nickname: CharSequence = "",
var blocked: Boolean = false) {
var blocked: Boolean = false,
var macLookupPending: Boolean = true) {
@androidx.room.Dao
abstract class Dao {
@Query("SELECT * FROM `ClientRecord` WHERE `mac` = :mac")
abstract fun lookup(mac: Long): ClientRecord?
abstract suspend fun lookup(mac: Long): ClientRecord?
fun lookupOrDefault(mac: Long) = lookup(mac) ?: ClientRecord(mac)
suspend fun lookupOrDefault(mac: Long) = lookup(mac) ?: ClientRecord(mac)
@Query("SELECT * FROM `ClientRecord` WHERE `mac` = :mac")
abstract fun lookupSync(mac: Long): LiveData<ClientRecord>
@@ -23,7 +24,7 @@ data class ClientRecord(@PrimaryKey
fun update(value: ClientRecord) = check(updateInternal(value) == value.mac)
@Transaction
open fun upsert(mac: Long, operation: ClientRecord.() -> Unit) = lookupOrDefault(mac).apply {
open suspend fun upsert(mac: Long, operation: ClientRecord.() -> Unit) = lookupOrDefault(mac).apply {
operation()
update(this)
}

View File

@@ -50,8 +50,9 @@ fun String.macToLong(): Long = ByteBuffer.allocate(8).run {
long
}
fun Iterable<Byte>.macToString() = joinToString(":") { "%02x".format(it) }
fun Long.macToString(): String = ByteBuffer.allocate(8).run {
order(ByteOrder.LITTLE_ENDIAN)
putLong(this@macToString)
array().take(6).joinToString(":") { "%02x".format(it) }
array().take(6).macToString()
}

View File

@@ -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
}
}

View File

@@ -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)
}

View 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
}

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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()

View File

@@ -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
}
}

View File

@@ -36,15 +36,16 @@
android:layout_weight="1"
android:orientation="vertical">
<TextView
<be.mygod.vpnhotspot.widget.LinkTextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{client.title}"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead"
android:textIsSelectable="@{client.record.nickname.length() == 0}"
android:textIsSelectable="@{client.titleSelectable}"
tools:text="01:23:45:ab:cd:ef%p2p-p2p0-0"/>
<be.mygod.vpnhotspot.widget.AutoCollapseTextView
android:id="@+id/description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{client.description}"

View File

@@ -41,6 +41,7 @@
tools:text="wlan0"/>
<be.mygod.vpnhotspot.widget.AutoCollapseTextView
android:id="@+id/text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{data.text}"

View File

@@ -44,6 +44,7 @@
android:textAppearance="@style/TextAppearance.AppCompat.Subhead"/>
<be.mygod.vpnhotspot.widget.AutoCollapseTextView
android:id="@+id/addresses"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{data.addresses}"

View File

@@ -54,9 +54,9 @@
<string name="tethering_manage_bluetooth">蓝牙网络共享</string>
<string name="tethering_manage_failed">Android 系统无法打开网络共享。</string>
<string name="connected_state_incomplete">%s (正在连接)</string>
<string name="connected_state_valid">%s (已连上)</string>
<string name="connected_state_failed">%s (已断开)</string>
<string name="connected_state_incomplete">正在连接</string>
<string name="connected_state_valid">已连上</string>
<string name="connected_state_failed">已断开</string>
<string name="clients_popup_nickname">昵称…</string>
<string name="clients_popup_block">拉黑</string>

View File

@@ -58,9 +58,9 @@
<string name="tethering_manage_bluetooth">Bluetooth tethering</string>
<string name="tethering_manage_failed">Android system has failed to start tethering.</string>
<string name="connected_state_incomplete">%s (connecting)</string>
<string name="connected_state_valid">%s (reachable)</string>
<string name="connected_state_failed">%s (lost)</string>
<string name="connected_state_incomplete">" (connecting)"</string>
<string name="connected_state_valid">" (reachable)"</string>
<string name="connected_state_failed">" (lost)"</string>
<string name="clients_popup_nickname">Nickname…</string>
<string name="clients_popup_block">Block</string>