Support monitoring tethered interface
This would be useful to be used in together with Instant Tethering + Turn off hotspot automatically. Refine #26, #53.
This commit is contained in:
@@ -8,6 +8,7 @@ abstract class IpNeighbourMonitoringService : Service(), IpNeighbourMonitor.Call
|
||||
private var neighbours = emptyList<IpNeighbour>()
|
||||
|
||||
protected abstract val activeIfaces: List<String>
|
||||
protected open val inactiveIfaces get() = emptyList<String>()
|
||||
|
||||
override fun onIpNeighbourAvailable(neighbours: List<IpNeighbour>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Service, Map<String, Int>>()
|
||||
private val deviceCountsMap = WeakHashMap<Service, Map<String, Int>>()
|
||||
private val inactiveMap = WeakHashMap<Service, List<String>>()
|
||||
private val manager = app.getSystemService<NotificationManager>()!!
|
||||
|
||||
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<String, Int>) {
|
||||
fun startForeground(service: Service, deviceCounts: Map<String, Int>, inactive: List<String> = emptyList()) {
|
||||
deviceCountsMap[service] = deviceCounts
|
||||
if (inactive.isEmpty()) inactiveMap.remove(service) else inactiveMap[service] = inactive
|
||||
service.startForeground(CHANNEL_ID, buildNotification(service))
|
||||
}
|
||||
fun stopForeground(service: Service) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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<Manager>()
|
||||
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<String> = 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>()
|
||||
|
||||
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" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
|
||||
10
mobile/src/main/res/drawable/ic_image_remove_red_eye.xml
Normal file
10
mobile/src/main/res/drawable/ic_image_remove_red_eye.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z"/>
|
||||
</vector>
|
||||
10
mobile/src/main/res/menu/toolbar_monitor.xml
Normal file
10
mobile/src/main/res/menu/toolbar_monitor.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
<item android:id="@+id/monitor"
|
||||
android:icon="@drawable/ic_image_remove_red_eye"
|
||||
android:title="@string/tethering_monitor"
|
||||
app:showAsAction="always">
|
||||
<menu/>
|
||||
</item>
|
||||
</menu>
|
||||
@@ -39,6 +39,9 @@
|
||||
<string name="tethering_temp_hotspot_failure_incompatible_mode">模式不兼容</string>
|
||||
<string name="tethering_temp_hotspot_failure_tethering_disallowed">共享被禁用</string>
|
||||
|
||||
<string name="tethering_monitor">监视…</string>
|
||||
<string name="tethering_state_monitored">%s(监视)</string>
|
||||
|
||||
<string name="tethering_manage">管理系统共享…</string>
|
||||
<string name="tethering_manage_offload_enabled">若 VPN 共享无法使用,请尝试禁用“开发者选项”中的“网络共享硬件加速”。</string>
|
||||
<!--
|
||||
@@ -122,6 +125,7 @@
|
||||
<plurals name="notification_interfaces">
|
||||
<item quantity="other">%d 个接口</item>
|
||||
</plurals>
|
||||
<string name="notification_interfaces_inactive">不活跃:</string>
|
||||
|
||||
<string name="failure_reason_unknown">未知 #%d</string>
|
||||
<string name="exception_interface_not_found">错误:未找到下游接口</string>
|
||||
|
||||
@@ -49,6 +49,9 @@
|
||||
<string name="tethering_temp_hotspot_failure_incompatible_mode">incompatible mode</string>
|
||||
<string name="tethering_temp_hotspot_failure_tethering_disallowed">tethering disallowed</string>
|
||||
|
||||
<string name="tethering_monitor">Monitor…</string>
|
||||
<string name="tethering_state_monitored">%s (monitored)</string>
|
||||
|
||||
<string name="tethering_manage">Manage system tethering…</string>
|
||||
<string name="tethering_manage_offload_enabled">Please disable Tethering hardware acceleration in Developer options
|
||||
if VPN tethering does not work.</string>
|
||||
@@ -131,6 +134,7 @@
|
||||
<item quantity="one">%d interface</item>
|
||||
<item quantity="other">%d interfaces</item>
|
||||
</plurals>
|
||||
<string name="notification_interfaces_inactive">"Inactive: "</string>
|
||||
|
||||
<string name="failure_reason_unknown">unknown #%d</string>
|
||||
<string name="exception_interface_not_found">Fatal: Downstream interface not found</string>
|
||||
|
||||
Reference in New Issue
Block a user