From cbc65f989c3f219e09074c590951adfaa4f1f216 Mon Sep 17 00:00:00 2001 From: Mygod Date: Wed, 6 Feb 2019 01:26:06 +0800 Subject: [PATCH] Support monitoring tethered interface This would be useful to be used in together with Instant Tethering + Turn off hotspot automatically. Refine #26, #53. --- .../IpNeighbourMonitoringService.kt | 5 +- .../vpnhotspot/LocalOnlyHotspotService.kt | 4 +- .../be/mygod/vpnhotspot/RepeaterService.kt | 4 +- .../be/mygod/vpnhotspot/RoutingManager.kt | 24 ++++++--- .../mygod/vpnhotspot/ServiceNotification.kt | 45 ++++++++-------- .../be/mygod/vpnhotspot/TetheringService.kt | 51 +++++++++++++----- .../vpnhotspot/manage/InterfaceManager.kt | 7 ++- .../vpnhotspot/manage/TetheringFragment.kt | 52 +++++++++++++++---- .../java/be/mygod/vpnhotspot/net/Routing.kt | 11 ++++ .../java/be/mygod/vpnhotspot/util/Utils.kt | 4 +- .../res/drawable/ic_image_remove_red_eye.xml | 10 ++++ mobile/src/main/res/menu/toolbar_monitor.xml | 10 ++++ mobile/src/main/res/values-zh-rCN/strings.xml | 4 ++ mobile/src/main/res/values/strings.xml | 4 ++ 14 files changed, 174 insertions(+), 61 deletions(-) create mode 100644 mobile/src/main/res/drawable/ic_image_remove_red_eye.xml create mode 100644 mobile/src/main/res/menu/toolbar_monitor.xml diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/IpNeighbourMonitoringService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/IpNeighbourMonitoringService.kt index f7def4f1..bd44d1a6 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/IpNeighbourMonitoringService.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/IpNeighbourMonitoringService.kt @@ -8,6 +8,7 @@ abstract class IpNeighbourMonitoringService : Service(), IpNeighbourMonitor.Call private var neighbours = emptyList() protected abstract val activeIfaces: List + protected open val inactiveIfaces get() = emptyList() override fun onIpNeighbourAvailable(neighbours: List) { this.neighbours = neighbours @@ -20,6 +21,8 @@ abstract class IpNeighbourMonitoringService : Service(), IpNeighbourMonitor.Call .distinctBy { it.lladdr } .size } - ServiceNotification.startForeground(this, activeIfaces.associate { Pair(it, sizeLookup[it] ?: 0) }) + ServiceNotification.startForeground(this, + activeIfaces.associate { Pair(it, sizeLookup[it] ?: 0) }, + inactiveIfaces) } } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt index d3d84833..22fe8ec3 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt @@ -52,7 +52,7 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService() { } else { val routingManager = routingManager if (routingManager == null) { - this.routingManager = RoutingManager.LocalOnly(this, iface).apply { initRouting() } + this.routingManager = RoutingManager.LocalOnly(this, iface).apply { start() } IpNeighbourMonitor.registerCallback(this) } else check(iface == routingManager.downstream) } @@ -128,7 +128,7 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService() { } private fun unregisterReceiver() { - routingManager?.stop() + routingManager?.destroy() routingManager = null if (receiverRegistered) { unregisterReceiver(receiver) diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt index f5aaccd4..021caa5d 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt @@ -277,7 +277,7 @@ class RepeaterService : Service(), WifiP2pManager.ChannelListener, SharedPrefere private fun doStart(group: WifiP2pGroup) { binder.group = group check(routingManager == null) - routingManager = RoutingManager.LocalOnly(this, group.`interface`!!).apply { initRouting() } + routingManager = RoutingManager.LocalOnly(this, group.`interface`!!).apply { start() } status = Status.ACTIVE showNotification(group) } @@ -309,7 +309,7 @@ class RepeaterService : Service(), WifiP2pManager.ChannelListener, SharedPrefere } private fun clean() { unregisterReceiver() - routingManager?.stop() + routingManager?.destroy() routingManager = null status = Status.IDLE ServiceNotification.stopForeground(this) diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/RoutingManager.kt b/mobile/src/main/java/be/mygod/vpnhotspot/RoutingManager.kt index 43ea6de3..59bcf00f 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/RoutingManager.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/RoutingManager.kt @@ -30,15 +30,21 @@ abstract class RoutingManager(private val caller: Any, val downstream: String, p } } - var routing: Routing? = null - + var started = false + private var routing: Routing? = null init { - app.onPreCleanRoutings[this] = { routing?.stop() } - app.onRoutingsCleaned[this] = { initRouting() } if (isWifi) WifiDoubleLock.acquire(this) } - fun initRouting() = try { + fun start(): Boolean { + check(!started) + started = true + app.onPreCleanRoutings[this] = { routing?.stop() } + app.onRoutingsCleaned[this] = { initRouting() } + return initRouting() + } + + private fun initRouting() = try { routing = Routing(caller, downstream).apply { try { configure() @@ -58,9 +64,15 @@ abstract class RoutingManager(private val caller: Any, val downstream: String, p protected abstract fun Routing.configure() fun stop() { + if (!started) return routing?.revert() - if (isWifi) WifiDoubleLock.release(this) app.onPreCleanRoutings -= this app.onRoutingsCleaned -= this + started = false + } + + fun destroy() { + if (isWifi) WifiDoubleLock.release(this) + stop() } } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/ServiceNotification.kt b/mobile/src/main/java/be/mygod/vpnhotspot/ServiceNotification.kt index a4dffd67..0830a3f1 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/ServiceNotification.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/ServiceNotification.kt @@ -9,12 +9,14 @@ import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import be.mygod.vpnhotspot.App.Companion.app +import java.util.* object ServiceNotification { private const val CHANNEL = "tethering" private const val CHANNEL_ID = 1 - private val deviceCountsMap = HashMap>() + private val deviceCountsMap = WeakHashMap>() + private val inactiveMap = WeakHashMap>() private val manager = app.getSystemService()!! private fun buildNotification(context: Context): Notification { @@ -27,32 +29,29 @@ object ServiceNotification { Intent(context, MainActivity::class.java), PendingIntent.FLAG_UPDATE_CURRENT)) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) val deviceCounts = deviceCountsMap.values.flatMap { it.entries }.sortedBy { it.key } - return when (deviceCounts.size) { - 0 -> builder.build() - 1 -> { - val (dev, size) = deviceCounts.single() - builder.setContentText(context.resources.getQuantityString(R.plurals.notification_connected_devices, - size, size, dev)) - .build() - } - else -> { - val deviceCount = deviceCounts.sumBy { it.value } - NotificationCompat.BigTextStyle(builder - .setContentText(context.resources.getQuantityString(R.plurals.notification_connected_devices, - deviceCount, deviceCount, - context.resources.getQuantityString(R.plurals.notification_interfaces, - deviceCounts.size, deviceCounts.size)))) - .bigText(deviceCounts.joinToString("\n") { (dev, size) -> - context.resources.getQuantityString(R.plurals.notification_connected_devices, - size, size, dev) - }) - .build() - } + val inactive = inactiveMap.values.flatten() + var lines = deviceCounts.map { (dev, size) -> + context.resources.getQuantityString(R.plurals.notification_connected_devices, size, size, dev) + } + if (inactive.isNotEmpty()) { + lines += context.getString(R.string.notification_interfaces_inactive) + inactive.joinToString() + } + return if (lines.size <= 1) builder.setContentText(lines.singleOrNull()).build() else { + val deviceCount = deviceCounts.sumBy { it.value } + val interfaceCount = deviceCounts.size + inactive.size + NotificationCompat.BigTextStyle(builder + .setContentText(context.resources.getQuantityString(R.plurals.notification_connected_devices, + deviceCount, deviceCount, + context.resources.getQuantityString(R.plurals.notification_interfaces, + interfaceCount, interfaceCount)))) + .bigText(lines.joinToString("\n")) + .build() } } - fun startForeground(service: Service, deviceCounts: Map) { + fun startForeground(service: Service, deviceCounts: Map, inactive: List = emptyList()) { deviceCountsMap[service] = deviceCounts + if (inactive.isEmpty()) inactiveMap.remove(service) else inactiveMap[service] = inactive service.startForeground(CHANNEL_ID, buildNotification(service)) } fun stopForeground(service: Service) { diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt index 3037369d..6a15c1cd 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt @@ -16,16 +16,22 @@ import kotlinx.coroutines.launch class TetheringService : IpNeighbourMonitoringService() { companion object { const val EXTRA_ADD_INTERFACES = "interface.add" + const val EXTRA_ADD_INTERFACE_MONITOR = "interface.add.monitor" const val EXTRA_REMOVE_INTERFACE = "interface.remove" } inner class Binder : android.os.Binder() { val routingsChanged = Event0() + val monitoredIfaces get() = synchronized(downstreams) { + downstreams.values.filter { it.monitor }.map { it.downstream } + } - fun isActive(iface: String): Boolean = synchronized(downstreams) { downstreams.containsKey(iface) } + fun isActive(iface: String) = synchronized(downstreams) { downstreams.containsKey(iface) } + fun isInactive(iface: String) = synchronized(downstreams) { downstreams[iface] }?.run { !started && monitor } + fun monitored(iface: String) = synchronized(downstreams) { downstreams[iface] }?.monitor } - private inner class Downstream(caller: Any, downstream: String) : + private inner class Downstream(caller: Any, downstream: String, var monitor: Boolean = false) : RoutingManager(caller, downstream, TetherType.ofInterface(downstream).isWifi) { override fun Routing.configure() { forward() @@ -41,12 +47,23 @@ class TetheringService : IpNeighbourMonitoringService() { private val receiver = broadcastReceiver { _, intent -> val extras = intent.extras ?: return@broadcastReceiver synchronized(downstreams) { - for (iface in downstreams.keys - TetheringManager.getTetheredIfaces(extras)) - downstreams.remove(iface)?.stop() + val toRemove = downstreams.toMutableMap() // make a copy + for (iface in TetheringManager.getTetheredIfaces(extras)) { + val downstream = toRemove.remove(iface) ?: continue + if (downstream.monitor && !downstream.started) downstream.start() + } + for ((iface, downstream) in toRemove) { + if (downstream.monitor) downstream.stop() else downstreams.remove(iface)?.destroy() + } onDownstreamsChangedLocked() } } - override val activeIfaces get() = synchronized(downstreams) { downstreams.keys.toList() } + override val activeIfaces get() = synchronized(downstreams) { + downstreams.values.filter { it.started }.map { it.downstream } + } + override val inactiveIfaces get() = synchronized(downstreams) { + downstreams.values.filter { !it.started }.map { it.downstream } + } private fun onDownstreamsChangedLocked() { if (downstreams.isEmpty()) { @@ -67,22 +84,30 @@ class TetheringService : IpNeighbourMonitoringService() { override fun onBind(intent: Intent?) = binder override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - if (intent != null) { - val ifaces = intent.getStringArrayExtra(EXTRA_ADD_INTERFACES) ?: emptyArray() - synchronized(downstreams) { - for (iface in ifaces) Downstream(this, iface).let { downstream -> - if (downstream.initRouting()) downstreams[iface] = downstream else downstream.stop() + if (intent != null) synchronized(downstreams) { + for (iface in intent.getStringArrayExtra(EXTRA_ADD_INTERFACES) ?: emptyArray()) { + if (downstreams[iface] == null) Downstream(this, iface).apply { + if (start()) check(downstreams.put(iface, this) == null) else destroy() } - downstreams.remove(intent.getStringExtra(EXTRA_REMOVE_INTERFACE))?.stop() - onDownstreamsChangedLocked() } + intent.getStringExtra(EXTRA_ADD_INTERFACE_MONITOR)?.let { iface -> + val downstream = downstreams[iface] + if (downstream == null) Downstream(this, iface, true).apply { + start() + check(downstreams.put(iface, this) == null) + downstreams[iface] = this + } else downstream.monitor = true + } + downstreams.remove(intent.getStringExtra(EXTRA_REMOVE_INTERFACE))?.destroy() + updateNotification() // call this first just in case we are shutting down immediately + onDownstreamsChangedLocked() } else if (downstreams.isEmpty()) stopSelf(startId) return START_NOT_STICKY } override fun onDestroy() { synchronized(downstreams) { - downstreams.values.forEach { it.stop() } // force clean to prevent leakage + downstreams.values.forEach { it.destroy() } // force clean to prevent leakage unregisterReceiver() } super.onDestroy() 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 87eebf78..b390fe3b 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/manage/InterfaceManager.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/manage/InterfaceManager.kt @@ -4,6 +4,7 @@ import android.content.Intent import android.view.View import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView +import be.mygod.vpnhotspot.R import be.mygod.vpnhotspot.TetheringService import be.mygod.vpnhotspot.databinding.ListitemInterfaceBinding import be.mygod.vpnhotspot.net.TetherType @@ -30,12 +31,14 @@ class InterfaceManager(private val parent: TetheringFragment, val iface: String) } private inner class Data : be.mygod.vpnhotspot.manage.Data() { override val icon get() = TetherType.ofInterface(iface).icon - override val title get() = iface + override val title get() = if (parent.binder?.monitored(iface) == true) { + parent.getString(R.string.tethering_state_monitored, iface) + } else iface override val text get() = addresses override val active get() = parent.binder?.isActive(iface) == true } - private val addresses = parent.ifaceLookup[iface]?.formatAddresses() ?: "" + private val addresses = parent.ifaceLookup[iface] ?.formatAddresses(parent.binder?.isInactive(iface) == true) ?: "" override val type get() = VIEW_TYPE_INTERFACE private val data = Data() diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringFragment.kt b/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringFragment.kt index 4c8d1d91..347380d3 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringFragment.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringFragment.kt @@ -9,29 +9,26 @@ import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import android.os.IBinder -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup +import android.view.* +import androidx.core.content.ContextCompat import androidx.databinding.DataBindingUtil import androidx.fragment.app.Fragment import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView -import be.mygod.vpnhotspot.LocalOnlyHotspotService -import be.mygod.vpnhotspot.R -import be.mygod.vpnhotspot.RepeaterService -import be.mygod.vpnhotspot.TetheringService +import be.mygod.vpnhotspot.* import be.mygod.vpnhotspot.databinding.FragmentTetheringBinding import be.mygod.vpnhotspot.net.TetherType import be.mygod.vpnhotspot.net.TetheringManager import be.mygod.vpnhotspot.util.ServiceForegroundConnector import be.mygod.vpnhotspot.util.broadcastReceiver +import kotlinx.android.synthetic.main.activity_main.* import timber.log.Timber import java.net.NetworkInterface import java.net.SocketException -class TetheringFragment : Fragment(), ServiceConnection { +class TetheringFragment : Fragment(), ServiceConnection, MenuItem.OnMenuItemClickListener { companion object { const val START_LOCAL_ONLY_HOTSPOT = 1 const val REPEATER_EDIT_CONFIGURATION = 2 @@ -63,7 +60,10 @@ class TetheringFragment : Fragment(), ServiceConnection { val list = ArrayList() if (RepeaterService.supported) list.add(repeaterManager) if (Build.VERSION.SDK_INT >= 26) list.add(localOnlyHotspotManager) - list.addAll(activeIfaces.map { InterfaceManager(this@TetheringFragment, it) }.sortedBy { it.iface }) + val monitoredIfaces = binder?.monitoredIfaces ?: emptyList() + updateMonitorList(activeIfaces - monitoredIfaces) + list.addAll((activeIfaces + monitoredIfaces).toSortedSet() + .map { InterfaceManager(this@TetheringFragment, it) }) list.add(ManageBar) if (Build.VERSION.SDK_INT >= 24) { list.addAll(tetherManagers) @@ -94,6 +94,27 @@ class TetheringFragment : Fragment(), ServiceConnection { extras.getStringArrayList(TetheringManager.EXTRA_ERRORED_TETHER)!!) } + private fun updateMonitorList(canMonitor: List = emptyList()) { + val toolbar = requireActivity().toolbar + val menu = toolbar.menu + if (canMonitor.isEmpty()) menu.removeItem(R.id.monitor) else { + var item = menu.findItem(R.id.monitor) + if (item == null) { + toolbar.inflateMenu(R.menu.toolbar_monitor) + item = menu.findItem(R.id.monitor)!! + } + item.subMenu.apply { + clear() + canMonitor.sorted().forEach { add(it).setOnMenuItemClickListener(this@TetheringFragment) } + } + } + } + override fun onMenuItemClick(item: MenuItem?): Boolean { + ContextCompat.startForegroundService(requireContext(), Intent(context, TetheringService::class.java) + .putExtra(TetheringService.EXTRA_ADD_INTERFACE_MONITOR, item?.title ?: return false)) + return true + } + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { binding = DataBindingUtil.inflate(inflater, R.layout.fragment_tethering, container, false) binding.setLifecycleOwner(this) @@ -127,7 +148,13 @@ class TetheringFragment : Fragment(), ServiceConnection { override fun onServiceConnected(name: ComponentName?, service: IBinder?) { binder = service as TetheringService.Binder - service.routingsChanged[this] = { adapter.notifyDataSetChanged() } + service.routingsChanged[this] = { + requireContext().apply { + // flush tethered interfaces + unregisterReceiver(receiver) + registerReceiver(receiver, IntentFilter(TetheringManager.ACTION_TETHER_STATE_CHANGED)) + } + } requireContext().registerReceiver(receiver, IntentFilter(TetheringManager.ACTION_TETHER_STATE_CHANGED)) } @@ -136,4 +163,9 @@ class TetheringFragment : Fragment(), ServiceConnection { binder = null requireContext().unregisterReceiver(receiver) } + + override fun onDestroy() { + updateMonitorList() + super.onDestroy() + } } 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 271066f9..614192b2 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/Routing.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/Routing.kt @@ -41,6 +41,11 @@ class Routing(private val caller: Any, private val downstream: String) : IpNeigh */ val IPTABLES = if (Build.VERSION.SDK_INT >= 26) "iptables -w 1" else "iptables -w" + /** + * For debugging: check that we do not start a Routing for the same interface twice. + */ + private var downstreams = mutableSetOf() + fun clean() { TrafficRecorder.clean() RootSession.use { @@ -54,6 +59,7 @@ class Routing(private val caller: Any, private val downstream: String) : IpNeigh it.execQuiet("while ip rule del priority $RULE_PRIORITY_UPSTREAM; do done") it.execQuiet("while ip rule del priority $RULE_PRIORITY_UPSTREAM_FALLBACK; do done") } + downstreams.clear() } private fun RootSession.Transaction.iptables(command: String, revert: String) { @@ -82,6 +88,10 @@ class Routing(private val caller: Any, private val downstream: String) : IpNeigh override val message: String get() = app.getString(R.string.exception_interface_not_found) } + init { + check(downstreams.add(downstream)) { "Double routing detected" } + } + private val hostAddress = try { NetworkInterface.getByName(downstream)!!.interfaceAddresses!!.asSequence().single { it.address is Inet4Address } } catch (e: Exception) { @@ -302,5 +312,6 @@ class Routing(private val caller: Any, private val downstream: String) : IpNeigh fallbackUpstream.subrouting?.transaction?.revert() upstream.subrouting?.transaction?.revert() transaction.revert() + check(downstreams.remove(downstream)) { "Double reverting detected" } } } 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 05bae15f..50df1df4 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/util/Utils.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/util/Utils.kt @@ -58,11 +58,11 @@ fun makeMacSpan(mac: String) = if (app.hasTouch) SpannableString(mac).apply { setSpan(CustomTabsUrlSpan("https://macvendors.co/results/$mac"), 0, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) } else mac -fun NetworkInterface.formatAddresses() = SpannableStringBuilder().apply { +fun NetworkInterface.formatAddresses(macOnly: Boolean = false) = SpannableStringBuilder().apply { try { hardwareAddress?.apply { appendln(makeMacSpan(asIterable().macToString())) } } catch (_: SocketException) { } - for (address in interfaceAddresses) { + if (!macOnly) for (address in interfaceAddresses) { append(makeIpSpan(address.address)) appendln("/${address.networkPrefixLength}") } diff --git a/mobile/src/main/res/drawable/ic_image_remove_red_eye.xml b/mobile/src/main/res/drawable/ic_image_remove_red_eye.xml new file mode 100644 index 00000000..c9c622d7 --- /dev/null +++ b/mobile/src/main/res/drawable/ic_image_remove_red_eye.xml @@ -0,0 +1,10 @@ + + + diff --git a/mobile/src/main/res/menu/toolbar_monitor.xml b/mobile/src/main/res/menu/toolbar_monitor.xml new file mode 100644 index 00000000..74c1b8d8 --- /dev/null +++ b/mobile/src/main/res/menu/toolbar_monitor.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/mobile/src/main/res/values-zh-rCN/strings.xml b/mobile/src/main/res/values-zh-rCN/strings.xml index afe696db..08823872 100644 --- a/mobile/src/main/res/values-zh-rCN/strings.xml +++ b/mobile/src/main/res/values-zh-rCN/strings.xml @@ -39,6 +39,9 @@ 模式不兼容 共享被禁用 + 监视… + %s(监视) + 管理系统共享… 若 VPN 共享无法使用,请尝试禁用“开发者选项”中的“网络共享硬件加速”。