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:
Mygod
2019-02-06 01:26:06 +08:00
parent 6d6418b8e0
commit cbc65f989c
14 changed files with 174 additions and 61 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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