Temporary Wi-Fi hotspot for bypassing tethering limits (#18)

* First draft of temporary hotspot
* Refactor with LocalOnlyInterfaceManager
* Refactor LocalOnlyHotspotService
* Localize
* Update strict summary
This commit is contained in:
Mygod
2018-05-02 17:53:06 -07:00
committed by GitHub
parent 0a47cfdf1c
commit 2fe7703d6d
15 changed files with 420 additions and 185 deletions

View File

@@ -30,6 +30,7 @@
<uses-permission android:name="android.permission.TETHER_PRIVILEGE"/> <uses-permission android:name="android.permission.TETHER_PRIVILEGE"/>
<uses-permission android:name="android.permission.WRITE_SETTINGS" <uses-permission android:name="android.permission.WRITE_SETTINGS"
tools:ignore="ProtectedPermissions"/> tools:ignore="ProtectedPermissions"/>
<uses-permission-sdk-23 android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<application <application
android:name=".App" android:name=".App"
@@ -53,6 +54,8 @@
</intent-filter> </intent-filter>
</activity> </activity>
<service android:name=".LocalOnlyHotspotService">
</service>
<service <service
android:name=".RepeaterService" android:name=".RepeaterService"
android:directBootAware="true"> android:directBootAware="true">

View File

@@ -6,6 +6,7 @@ import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.res.Configuration import android.content.res.Configuration
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.wifi.WifiManager
import android.os.Build import android.os.Build
import android.os.Handler import android.os.Handler
import android.preference.PreferenceManager import android.preference.PreferenceManager
@@ -43,6 +44,7 @@ class App : Application() {
val handler = Handler() val handler = Handler()
val pref: SharedPreferences by lazy { PreferenceManager.getDefaultSharedPreferences(deviceContext) } val pref: SharedPreferences by lazy { PreferenceManager.getDefaultSharedPreferences(deviceContext) }
val connectivity by lazy { getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager } val connectivity by lazy { getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager }
val wifi by lazy { app.getSystemService(Context.WIFI_SERVICE) as WifiManager }
val operatingChannel: Int get() { val operatingChannel: Int get() {
val result = pref.getString(KEY_OPERATING_CHANNEL, null)?.toIntOrNull() ?: 0 val result = pref.getString(KEY_OPERATING_CHANNEL, null)?.toIntOrNull() ?: 0

View File

@@ -0,0 +1,24 @@
package be.mygod.vpnhotspot
import android.app.Service
import be.mygod.vpnhotspot.net.IpNeighbour
import be.mygod.vpnhotspot.net.IpNeighbourMonitor
abstract class IpNeighbourMonitoringService : Service(), IpNeighbourMonitor.Callback {
private var neighbours = emptyList<IpNeighbour>()
protected abstract val activeIfaces: List<String>
override fun onIpNeighbourAvailable(neighbours: Map<String, IpNeighbour>) {
this.neighbours = neighbours.values.toList()
}
override fun postIpNeighbourAvailable() {
val sizeLookup = neighbours.groupBy { it.dev }.mapValues { (_, neighbours) ->
neighbours
.filter { it.state != IpNeighbour.State.FAILED }
.distinctBy { it.lladdr }
.size
}
ServiceNotification.startForeground(this, activeIfaces.associate { Pair(it, sizeLookup[it] ?: 0) })
}
}

View File

@@ -0,0 +1,109 @@
package be.mygod.vpnhotspot
import android.content.Intent
import android.net.wifi.WifiManager
import android.os.Binder
import android.support.annotation.RequiresApi
import android.widget.Toast
import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.net.IpNeighbourMonitor
import be.mygod.vpnhotspot.net.TetheringManager
@RequiresApi(26)
class LocalOnlyHotspotService : IpNeighbourMonitoringService() {
companion object {
private const val TAG = "LocalOnlyHotspotService"
}
inner class HotspotBinder : Binder() {
var fragment: TetheringFragment? = null
var iface: String? = null
val configuration get() = reservation?.wifiConfiguration
fun stop() = reservation?.close()
}
private val binder = HotspotBinder()
private var reservation: WifiManager.LocalOnlyHotspotReservation? = null
private var routingManager: LocalOnlyInterfaceManager? = null
private var receiverRegistered = false
private val receiver = broadcastReceiver { _, intent ->
val ifaces = TetheringManager.getLocalOnlyTetheredIfaces(intent.extras)
debugLog(TAG, "onTetherStateChangedLocked: $ifaces")
check(ifaces.size <= 1)
val iface = ifaces.singleOrNull()
binder.iface = iface
if (iface == null) {
routingManager?.stop()
routingManager = null
unregisterReceiver()
ServiceNotification.stopForeground(this)
stopSelf()
} else {
val routingManager = routingManager
if (routingManager == null) {
this.routingManager = LocalOnlyInterfaceManager(iface)
IpNeighbourMonitor.registerCallback(this)
} else check(iface == routingManager.downstream)
}
app.handler.post { binder.fragment?.adapter?.updateLocalOnlyViewHolder() }
}
override val activeIfaces get() = listOfNotNull(binder.iface)
override fun onBind(intent: Intent?) = binder
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// throws IllegalStateException if the caller attempts to start the LocalOnlyHotspot while they
// have an outstanding request.
// https://android.googlesource.com/platform/frameworks/opt/net/wifi/+/53e0284/service/java/com/android/server/wifi/WifiServiceImpl.java#1192
try {
app.wifi.startLocalOnlyHotspot(object : WifiManager.LocalOnlyHotspotCallback() {
override fun onStarted(reservation: WifiManager.LocalOnlyHotspotReservation?) {
if (reservation == null) onFailed(-2) else {
this@LocalOnlyHotspotService.reservation = reservation
if (!receiverRegistered) {
registerReceiver(receiver, intentFilter(TetheringManager.ACTION_TETHER_STATE_CHANGED))
receiverRegistered = true
}
}
}
override fun onStopped() {
debugLog(TAG, "LOHCallback.onStopped")
reservation = null
}
override fun onFailed(reason: Int) {
Toast.makeText(this@LocalOnlyHotspotService, getString(R.string.tethering_temp_hotspot_failure,
when (reason) {
WifiManager.LocalOnlyHotspotCallback.ERROR_NO_CHANNEL ->
getString(R.string.tethering_temp_hotspot_failure_no_channel)
WifiManager.LocalOnlyHotspotCallback.ERROR_GENERIC ->
getString(R.string.tethering_temp_hotspot_failure_generic)
WifiManager.LocalOnlyHotspotCallback.ERROR_INCOMPATIBLE_MODE ->
getString(R.string.tethering_temp_hotspot_failure_incompatible_mode)
WifiManager.LocalOnlyHotspotCallback.ERROR_TETHERING_DISALLOWED ->
getString(R.string.tethering_temp_hotspot_failure_tethering_disallowed)
else -> getString(R.string.failure_reason_unknown, reason)
}), Toast.LENGTH_SHORT).show()
}
}, app.handler)
} catch (e: IllegalStateException) {
e.printStackTrace()
}
return START_STICKY
}
override fun onDestroy() {
unregisterReceiver()
super.onDestroy()
}
private fun unregisterReceiver() {
if (receiverRegistered) {
unregisterReceiver(receiver)
IpNeighbourMonitor.unregisterCallback(this)
receiverRegistered = false
}
}
}

View File

@@ -0,0 +1,65 @@
package be.mygod.vpnhotspot
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.support.v4.content.LocalBroadcastManager
import android.widget.Toast
import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.net.Routing
import be.mygod.vpnhotspot.net.VpnMonitor
import java.net.InetAddress
import java.net.SocketException
class LocalOnlyInterfaceManager(val downstream: String, private val owner: InetAddress? = null) :
BroadcastReceiver(), VpnMonitor.Callback {
private var routing: Routing? = null
private var dns = emptyList<InetAddress>()
init {
LocalBroadcastManager.getInstance(app).registerReceiver(this, intentFilter(App.ACTION_CLEAN_ROUTINGS))
VpnMonitor.registerCallback(this) { initRouting() }
}
override fun onAvailable(ifname: String, dns: List<InetAddress>) {
val routing = routing
initRouting(ifname, if (routing == null) owner else {
routing.stop()
check(routing.upstream == null)
routing.hostAddress
}, dns)
}
override fun onLost(ifname: String) {
val routing = routing ?: return
if (!routing.stop()) app.toast(R.string.noisy_su_failure)
initRouting(null, routing.hostAddress, emptyList())
}
override fun onReceive(context: Context?, intent: Intent?) {
val routing = routing ?: return
routing.started = false
initRouting(routing.upstream, routing.hostAddress, dns)
}
private fun initRouting(upstream: String? = null, owner: InetAddress? = this.owner,
dns: List<InetAddress> = this.dns) {
try {
val routing = Routing(upstream, downstream, owner)
this.routing = routing
this.dns = dns
val strict = app.pref.getBoolean("service.repeater.strict", false)
if (strict && upstream == null) return // in this case, nothing to be done
if (routing.ipForward() // local only interfaces may not enable ip_forward
.rule().forward(strict).masquerade(strict).dnsRedirect(dns).start()) return
app.toast(R.string.noisy_su_failure)
} catch (e: SocketException) {
Toast.makeText(app, e.message, Toast.LENGTH_SHORT).show()
routing = null
}
}
fun stop() {
VpnMonitor.unregisterCallback(this)
LocalBroadcastManager.getInstance(app).unregisterReceiver(this)
if (routing?.stop() == false) app.toast(R.string.noisy_su_failure)
}
}

View File

@@ -61,7 +61,7 @@ class RepeaterFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClickL
} }
} }
val ssid @Bindable get() = binder?.ssid ?: getText(R.string.repeater_inactive) val ssid @Bindable get() = binder?.ssid ?: getText(R.string.service_inactive)
val addresses @Bindable get(): String { val addresses @Bindable get(): String {
return try { return try {
NetworkInterface.getByName(p2pInterface ?: return "")?.formatAddresses() ?: "" NetworkInterface.getByName(p2pInterface ?: return "")?.formatAddresses() ?: ""
@@ -160,7 +160,8 @@ class RepeaterFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClickL
private var p2pInterface: String? = null private var p2pInterface: String? = null
private var tetheredInterfaces = emptySet<String>() private var tetheredInterfaces = emptySet<String>()
private val receiver = broadcastReceiver { _, intent -> private val receiver = broadcastReceiver { _, intent ->
tetheredInterfaces = TetheringManager.getTetheredIfaces(intent.extras).toSet() tetheredInterfaces = TetheringManager.getTetheredIfaces(intent.extras).toSet() +
TetheringManager.getLocalOnlyTetheredIfaces(intent.extras)
adapter.recreate() adapter.recreate()
} }

View File

@@ -16,8 +16,6 @@ import android.support.v4.content.LocalBroadcastManager
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import be.mygod.vpnhotspot.App.Companion.app import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.net.Routing
import be.mygod.vpnhotspot.net.VpnMonitor
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.deletePersistentGroup import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.deletePersistentGroup
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.netId import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.netId
@@ -26,10 +24,8 @@ import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.setWifiP2pChannels
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.startWps import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.startWps
import java.lang.reflect.InvocationTargetException import java.lang.reflect.InvocationTargetException
import java.net.InetAddress import java.net.InetAddress
import java.net.SocketException
class RepeaterService : Service(), WifiP2pManager.ChannelListener, VpnMonitor.Callback, class RepeaterService : Service(), WifiP2pManager.ChannelListener, SharedPreferences.OnSharedPreferenceChangeListener {
SharedPreferences.OnSharedPreferenceChangeListener {
companion object { companion object {
const val ACTION_STATUS_CHANGED = "be.mygod.vpnhotspot.RepeaterService.STATUS_CHANGED" const val ACTION_STATUS_CHANGED = "be.mygod.vpnhotspot.RepeaterService.STATUS_CHANGED"
private const val TAG = "RepeaterService" private const val TAG = "RepeaterService"
@@ -110,17 +106,9 @@ class RepeaterService : Service(), WifiP2pManager.ChannelListener, VpnMonitor.Ca
intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_INFO), intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_INFO),
intent.getParcelableExtra(WifiP2pManager.EXTRA_NETWORK_INFO), intent.getParcelableExtra(WifiP2pManager.EXTRA_NETWORK_INFO),
intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_GROUP)) intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_GROUP))
App.ACTION_CLEAN_ROUTINGS -> if (status == Status.ACTIVE) {
val routing = routing
routing!!.started = false
resetup(routing, upstream, dns)
}
} }
} }
private var routingManager: LocalOnlyInterfaceManager? = null
private var upstream: String? = null
private var dns: List<InetAddress> = emptyList()
private var routing: Routing? = null
var status = Status.IDLE var status = Status.IDLE
private set(value) { private set(value) {
@@ -134,7 +122,7 @@ class RepeaterService : Service(), WifiP2pManager.ChannelListener, VpnMonitor.Ca
WifiP2pManager.P2P_UNSUPPORTED -> getString(R.string.repeater_failure_reason_p2p_unsupported) WifiP2pManager.P2P_UNSUPPORTED -> getString(R.string.repeater_failure_reason_p2p_unsupported)
WifiP2pManager.BUSY -> getString(R.string.repeater_failure_reason_busy) WifiP2pManager.BUSY -> getString(R.string.repeater_failure_reason_busy)
WifiP2pManager.NO_SERVICE_REQUESTS -> getString(R.string.repeater_failure_reason_no_service_requests) WifiP2pManager.NO_SERVICE_REQUESTS -> getString(R.string.repeater_failure_reason_no_service_requests)
else -> getString(R.string.repeater_failure_reason_unknown, reason) else -> getString(R.string.failure_reason_unknown, reason)
}) })
override fun onCreate() { override fun onCreate() {
@@ -176,36 +164,19 @@ class RepeaterService : Service(), WifiP2pManager.ChannelListener, VpnMonitor.Ca
} }
/** /**
* startService 1st stop * startService Step 1
*/ */
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (status != Status.IDLE) return START_NOT_STICKY if (status != Status.IDLE) return START_NOT_STICKY
status = Status.STARTING status = Status.STARTING
VpnMonitor.registerCallback(this) { setup() }
return START_NOT_STICKY
}
private fun startFailure(msg: CharSequence?, group: WifiP2pGroup? = null) {
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
showNotification()
if (group != null) removeGroup() else clean()
}
/**
* startService 2nd stop
*/
private fun setup(ifname: String? = null, dns: List<InetAddress> = emptyList()) {
val matcher = WifiP2pManagerHelper.patternNetworkInfo.matcher( val matcher = WifiP2pManagerHelper.patternNetworkInfo.matcher(
loggerSu("dumpsys ${Context.WIFI_P2P_SERVICE}") ?: "") loggerSu("dumpsys ${Context.WIFI_P2P_SERVICE}") ?: "")
when { when {
!matcher.find() -> startFailure(getString(R.string.root_unavailable)) !matcher.find() -> startFailure(getString(R.string.root_unavailable))
matcher.group(2) == "true" -> { matcher.group(2) == "true" -> {
unregisterReceiver() unregisterReceiver()
upstream = ifname
this.dns = dns
registerReceiver(receiver, intentFilter(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION, registerReceiver(receiver, intentFilter(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION,
WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION)) WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION))
LocalBroadcastManager.getInstance(this)
.registerReceiver(receiver, intentFilter(App.ACTION_CLEAN_ROUTINGS))
receiverRegistered = true receiverRegistered = true
p2pManager.requestGroupInfo(channel, { p2pManager.requestGroupInfo(channel, {
when { when {
@@ -227,72 +198,40 @@ class RepeaterService : Service(), WifiP2pManager.ChannelListener, VpnMonitor.Ca
} }
else -> startFailure(getString(R.string.repeater_p2p_unavailable)) else -> startFailure(getString(R.string.repeater_p2p_unavailable))
} }
return START_NOT_STICKY
} }
/**
private fun resetup(routing: Routing, ifname: String? = null, dns: List<InetAddress> = emptyList()) = * startService Step 2 (if a group isn't already available)
initRouting(ifname, routing.downstream, routing.hostAddress, dns) */
override fun onAvailable(ifname: String, dns: List<InetAddress>) = when (status) {
Status.STARTING -> setup(ifname, dns)
Status.ACTIVE -> {
val routing = routing!!
if (routing.started) {
routing.stop()
check(routing.upstream == null)
}
resetup(routing, ifname, dns)
while (false) { }
}
else -> throw IllegalStateException("RepeaterService is in unexpected state when receiving onAvailable")
}
override fun onLost(ifname: String) {
if (routing?.stop() == false)
Toast.makeText(this, getText(R.string.noisy_su_failure), Toast.LENGTH_SHORT).show()
upstream = null
if (status == Status.ACTIVE) resetup(routing!!)
}
private fun doStart() = p2pManager.createGroup(channel, object : WifiP2pManager.ActionListener { private fun doStart() = p2pManager.createGroup(channel, object : WifiP2pManager.ActionListener {
override fun onFailure(reason: Int) = startFailure(formatReason(R.string.repeater_create_group_failure, reason)) override fun onFailure(reason: Int) = startFailure(formatReason(R.string.repeater_create_group_failure, reason))
override fun onSuccess() { } // wait for WIFI_P2P_CONNECTION_CHANGED_ACTION to fire override fun onSuccess() { } // wait for WIFI_P2P_CONNECTION_CHANGED_ACTION to fire to go to step 3
}) })
private fun doStart(group: WifiP2pGroup) {
this.group = group
status = Status.ACTIVE
showNotification(group)
}
/** /**
* startService 3rd stop (if a group isn't already available), also called when connection changed * Used during step 2, also called when connection changed
*/ */
private fun onP2pConnectionChanged(info: WifiP2pInfo, net: NetworkInfo?, group: WifiP2pGroup) { private fun onP2pConnectionChanged(info: WifiP2pInfo, net: NetworkInfo?, group: WifiP2pGroup) {
debugLog(TAG, "P2P connection changed: $info\n$net\n$group") debugLog(TAG, "P2P connection changed: $info\n$net\n$group")
if (!info.groupFormed || !info.isGroupOwner || !group.isGroupOwner) { if (!info.groupFormed || !info.isGroupOwner || !group.isGroupOwner) {
if (routing != null) clean() // P2P shutdown if (routingManager != null) clean() // P2P shutdown
return } else if (routingManager != null) {
} this.group = group
if (routing == null) try { showNotification(group)
if (initRouting(upstream, group.`interface` ?: return, info.groupOwnerAddress ?: return, dns)) } else doStart(group, info.groupOwnerAddress)
doStart(group)
} catch (e: SocketException) {
startFailure(e.message, group)
return
} else showNotification(group)
this.group = group
} }
private fun initRouting(upstream: String?, downstream: String, /**
owner: InetAddress, dns: List<InetAddress>): Boolean { * startService Step 3
val routing = Routing(upstream, downstream, owner) */
this.routing = routing private fun doStart(group: WifiP2pGroup, ownerAddress: InetAddress? = null) {
this.dns = dns this.group = group
val strict = app.pref.getBoolean("service.repeater.strict", false) routingManager = LocalOnlyInterfaceManager(group.`interface`!!, ownerAddress)
return if (strict && upstream == null || // in this case, nothing to be done status = Status.ACTIVE
routing.ipForward() // Wi-Fi direct doesn't enable ip_forward showNotification(group)
.rule().forward(strict).masquerade(strict).dnsRedirect(dns).start()) true else { }
routing.stop() private fun startFailure(msg: CharSequence?, group: WifiP2pGroup? = null) {
Toast.makeText(this, getText(R.string.noisy_su_failure), Toast.LENGTH_SHORT).show() Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
false showNotification()
} if (group != null) removeGroup() else clean()
} }
private fun showNotification(group: WifiP2pGroup? = null) = ServiceNotification.startForeground(this, private fun showNotification(group: WifiP2pGroup? = null) = ServiceNotification.startForeground(this,
@@ -314,16 +253,13 @@ class RepeaterService : Service(), WifiP2pManager.ChannelListener, VpnMonitor.Ca
private fun unregisterReceiver() { private fun unregisterReceiver() {
if (receiverRegistered) { if (receiverRegistered) {
unregisterReceiver(receiver) unregisterReceiver(receiver)
LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver)
receiverRegistered = false receiverRegistered = false
} }
} }
private fun clean() { private fun clean() {
VpnMonitor.unregisterCallback(this)
unregisterReceiver() unregisterReceiver()
if (routing?.stop() == false) routingManager?.stop()
Toast.makeText(this, getText(R.string.noisy_su_failure), Toast.LENGTH_SHORT).show() routingManager = null
routing = null
status = Status.IDLE status = Status.IDLE
ServiceNotification.stopForeground(this) ServiceNotification.stopForeground(this)
stopSelf() stopSelf()

View File

@@ -1,9 +1,12 @@
package be.mygod.vpnhotspot package be.mygod.vpnhotspot
import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.annotation.TargetApi
import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothProfile import android.bluetooth.BluetoothProfile
import android.content.* import android.content.*
import android.content.pm.PackageManager
import android.databinding.BaseObservable import android.databinding.BaseObservable
import android.databinding.Bindable import android.databinding.Bindable
import android.databinding.DataBindingUtil import android.databinding.DataBindingUtil
@@ -39,12 +42,15 @@ import java.util.*
class TetheringFragment : Fragment(), ServiceConnection { class TetheringFragment : Fragment(), ServiceConnection {
companion object { companion object {
private const val VIEW_TYPE_INTERFACE = 0 private const val VIEW_TYPE_INTERFACE = 0
private const val VIEW_TYPE_LOCAL_ONLY_HOTSPOT = 6
private const val VIEW_TYPE_MANAGE = 1 private const val VIEW_TYPE_MANAGE = 1
private const val VIEW_TYPE_WIFI = 2 private const val VIEW_TYPE_WIFI = 2
private const val VIEW_TYPE_USB = 3 private const val VIEW_TYPE_USB = 3
private const val VIEW_TYPE_BLUETOOTH = 4 private const val VIEW_TYPE_BLUETOOTH = 4
private const val VIEW_TYPE_WIFI_LEGACY = 5 private const val VIEW_TYPE_WIFI_LEGACY = 5
private const val START_LOCAL_ONLY_HOTSPOT = 1
/** /**
* PAN Profile * PAN Profile
* From BluetoothProfile.java. * From BluetoothProfile.java.
@@ -55,26 +61,63 @@ class TetheringFragment : Fragment(), ServiceConnection {
} }
} }
inner class Data(val iface: TetheredInterface) : BaseObservable() { interface Data {
val icon: Int get() = TetherType.ofInterface(iface.name).icon val icon: Int
val active = binder?.isActive(iface.name) == true val title: CharSequence
val text: CharSequence
val active: Boolean
}
inner class TetheredData(val iface: TetheredInterface) : Data {
override val icon: Int get() = TetherType.ofInterface(iface.name).icon
override val title get() = iface.name
override val text get() = iface.addresses
override val active = tetheringBinder?.isActive(iface.name) == true
}
inner class LocalHotspotData(private val lookup: Map<String, NetworkInterface>) : Data {
override val icon: Int get() {
val iface = hotspotBinder?.iface ?: return TetherType.WIFI.icon
return TetherType.ofInterface(iface).icon
}
override val title get() = getString(R.string.tethering_temp_hotspot)
override val text by lazy {
val binder = hotspotBinder
val configuration = binder?.configuration ?: return@lazy getText(R.string.service_inactive)
val iface = binder.iface ?: return@lazy getText(R.string.service_inactive)
"${configuration.SSID} - ${configuration.preSharedKey}\n${TetheredInterface(iface, lookup).addresses}"
}
override val active = hotspotBinder?.iface != null
} }
private class InterfaceViewHolder(val binding: ListitemInterfaceBinding) : RecyclerView.ViewHolder(binding.root), private open class InterfaceViewHolder(val binding: ListitemInterfaceBinding) :
View.OnClickListener { RecyclerView.ViewHolder(binding.root), View.OnClickListener {
init { init {
itemView.setOnClickListener(this) itemView.setOnClickListener(this)
} }
override fun onClick(view: View) { override fun onClick(view: View) {
val context = itemView.context val context = itemView.context
val data = binding.data!! val data = binding.data as TetheredData
if (data.active) context.startService(Intent(context, TetheringService::class.java) if (data.active) context.startService(Intent(context, TetheringService::class.java)
.putExtra(TetheringService.EXTRA_REMOVE_INTERFACE, data.iface.name)) .putExtra(TetheringService.EXTRA_REMOVE_INTERFACE, data.iface.name))
else ContextCompat.startForegroundService(context, Intent(context, TetheringService::class.java) else ContextCompat.startForegroundService(context, Intent(context, TetheringService::class.java)
.putExtra(TetheringService.EXTRA_ADD_INTERFACE, data.iface.name)) .putExtra(TetheringService.EXTRA_ADD_INTERFACE, data.iface.name))
} }
} }
@RequiresApi(26)
private inner class LocalOnlyHotspotViewHolder(binding: ListitemInterfaceBinding) : InterfaceViewHolder(binding) {
override fun onClick(view: View) {
val binder = hotspotBinder
if (binder?.iface != null) binder.stop() else {
val context = requireContext()
if (context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) ==
PackageManager.PERMISSION_GRANTED) {
context.startForegroundService(Intent(context, LocalOnlyHotspotService::class.java))
} else {
requestPermissions(arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION), START_LOCAL_ONLY_HOTSPOT)
}
}
}
}
private class ManageViewHolder(view: View) : RecyclerView.ViewHolder(view), View.OnClickListener { private class ManageViewHolder(view: View) : RecyclerView.ViewHolder(view), View.OnClickListener {
init { init {
view.setOnClickListener(this) view.setOnClickListener(this)
@@ -160,7 +203,7 @@ class TetheringFragment : Fragment(), ServiceConnection {
} }
} }
class TetherListener : BaseObservable(), BluetoothProfile.ServiceListener { inner class TetherListener : BaseObservable(), BluetoothProfile.ServiceListener {
var enabledTypes = emptySet<TetherType>() var enabledTypes = emptySet<TetherType>()
@Bindable get @Bindable get
set(value) { set(value) {
@@ -208,29 +251,40 @@ class TetheringFragment : Fragment(), ServiceConnection {
} }
inner class TetheringAdapter : inner class TetheringAdapter :
ListAdapter<TetheredInterface, RecyclerView.ViewHolder>(TetheredInterface.DiffCallback) { ListAdapter<TetheredInterface, RecyclerView.ViewHolder>(TetheredInterface.DiffCallback) {
fun update(data: Set<String>) { private var lookup: Map<String, NetworkInterface> = emptyMap()
val lookup = try {
fun update(activeIfaces: List<String>, localOnlyIfaces: List<String>) {
lookup = try {
NetworkInterface.getNetworkInterfaces().asSequence().associateBy { it.name } NetworkInterface.getNetworkInterfaces().asSequence().associateBy { it.name }
} catch (e: SocketException) { } catch (e: SocketException) {
e.printStackTrace() e.printStackTrace()
emptyMap<String, NetworkInterface>() emptyMap()
} }
this@TetheringFragment.tetherListener.enabledTypes = data.map { TetherType.ofInterface(it) }.toSet() this@TetheringFragment.tetherListener.enabledTypes =
submitList(data.map { TetheredInterface(it, lookup) }.sorted()) (activeIfaces + localOnlyIfaces).map { TetherType.ofInterface(it) }.toSet()
submitList(activeIfaces.map { TetheredInterface(it, lookup) }.sorted())
if (Build.VERSION.SDK_INT >= 26) updateLocalOnlyViewHolder()
} }
override fun getItemCount() = super.getItemCount() + when (Build.VERSION.SDK_INT) { override fun getItemCount() = super.getItemCount() + if (Build.VERSION.SDK_INT < 24) 2 else 5
in 0 until 24 -> 2 override fun getItemViewType(position: Int) = if (Build.VERSION.SDK_INT < 26) {
in 24..25 -> 5 when (position - super.getItemCount()) {
else -> 4 0 -> VIEW_TYPE_MANAGE
} 1 -> if (Build.VERSION.SDK_INT >= 24) VIEW_TYPE_USB else VIEW_TYPE_WIFI_LEGACY
override fun getItemViewType(position: Int) = when (position - super.getItemCount()) { 2 -> VIEW_TYPE_WIFI
0 -> VIEW_TYPE_MANAGE 3 -> VIEW_TYPE_BLUETOOTH
1 -> if (Build.VERSION.SDK_INT >= 24) VIEW_TYPE_USB else VIEW_TYPE_WIFI_LEGACY 4 -> VIEW_TYPE_WIFI_LEGACY
2 -> VIEW_TYPE_WIFI else -> VIEW_TYPE_INTERFACE
3 -> VIEW_TYPE_BLUETOOTH }
4 -> VIEW_TYPE_WIFI_LEGACY } else {
else -> VIEW_TYPE_INTERFACE when (position - super.getItemCount()) {
0 -> VIEW_TYPE_LOCAL_ONLY_HOTSPOT
1 -> VIEW_TYPE_MANAGE
2 -> VIEW_TYPE_USB
3 -> VIEW_TYPE_WIFI
4 -> VIEW_TYPE_BLUETOOTH
else -> VIEW_TYPE_INTERFACE
}
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context) val inflater = LayoutInflater.from(parent.context)
@@ -239,22 +293,33 @@ class TetheringFragment : Fragment(), ServiceConnection {
VIEW_TYPE_MANAGE -> ManageViewHolder(inflater.inflate(R.layout.listitem_manage, parent, false)) VIEW_TYPE_MANAGE -> ManageViewHolder(inflater.inflate(R.layout.listitem_manage, parent, false))
VIEW_TYPE_WIFI, VIEW_TYPE_USB, VIEW_TYPE_BLUETOOTH, VIEW_TYPE_WIFI_LEGACY -> VIEW_TYPE_WIFI, VIEW_TYPE_USB, VIEW_TYPE_BLUETOOTH, VIEW_TYPE_WIFI_LEGACY ->
ManageItemHolder(ListitemManageTetherBinding.inflate(inflater, parent, false), viewType) ManageItemHolder(ListitemManageTetherBinding.inflate(inflater, parent, false), viewType)
VIEW_TYPE_LOCAL_ONLY_HOTSPOT -> @TargetApi(26) {
LocalOnlyHotspotViewHolder(ListitemInterfaceBinding.inflate(inflater, parent, false))
}
else -> throw IllegalArgumentException("Invalid view type") else -> throw IllegalArgumentException("Invalid view type")
} }
} }
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) { when (holder) {
is InterfaceViewHolder -> holder.binding.data = Data(getItem(position)) is LocalOnlyHotspotViewHolder -> holder.binding.data = LocalHotspotData(lookup)
is InterfaceViewHolder -> holder.binding.data = TetheredData(getItem(position))
} }
} }
@RequiresApi(26)
fun updateLocalOnlyViewHolder() {
notifyItemChanged(super.getItemCount())
notifyItemChanged(super.getItemCount() + 3)
}
} }
private val tetherListener = TetherListener() private val tetherListener = TetherListener()
private lateinit var binding: FragmentTetheringBinding private lateinit var binding: FragmentTetheringBinding
private var binder: TetheringService.TetheringBinder? = null private var hotspotBinder: LocalOnlyHotspotService.HotspotBinder? = null
private var tetheringBinder: TetheringService.TetheringBinder? = null
val adapter = TetheringAdapter() val adapter = TetheringAdapter()
private val receiver = broadcastReceiver { _, intent -> private val receiver = broadcastReceiver { _, intent ->
adapter.update(TetheringManager.getTetheredIfaces(intent.extras)) adapter.update(TetheringManager.getTetheredIfaces(intent.extras),
TetheringManager.getLocalOnlyTetheredIfaces(intent.extras))
} }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
@@ -269,14 +334,23 @@ class TetheringFragment : Fragment(), ServiceConnection {
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
val context = requireContext() val context = requireContext()
context.registerReceiver(receiver, intentFilter(TetheringManager.ACTION_TETHER_STATE_CHANGED))
context.bindService(Intent(context, TetheringService::class.java), this, Context.BIND_AUTO_CREATE) context.bindService(Intent(context, TetheringService::class.java), this, Context.BIND_AUTO_CREATE)
if (Build.VERSION.SDK_INT >= 26) {
context.bindService(Intent(context, LocalOnlyHotspotService::class.java), this, Context.BIND_AUTO_CREATE)
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
if (requestCode == START_LOCAL_ONLY_HOTSPOT) @TargetApi(26) {
if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
val context = requireContext()
context.startForegroundService(Intent(context, LocalOnlyHotspotService::class.java))
}
} else super.onRequestPermissionsResult(requestCode, permissions, grantResults)
} }
override fun onStop() { override fun onStop() {
val context = requireContext() requireContext().unbindService(this)
context.unbindService(this)
context.unregisterReceiver(receiver)
super.onStop() super.onStop()
} }
@@ -285,14 +359,34 @@ class TetheringFragment : Fragment(), ServiceConnection {
super.onDestroy() super.onDestroy()
} }
override fun onServiceConnected(name: ComponentName?, service: IBinder?) { override fun onServiceConnected(name: ComponentName?, service: IBinder?) = when (service) {
val binder = service as TetheringService.TetheringBinder is TetheringService.TetheringBinder -> {
this.binder = binder tetheringBinder = service
binder.fragment = this service.fragment = this
requireContext().registerReceiver(receiver, intentFilter(TetheringManager.ACTION_TETHER_STATE_CHANGED))
while (false) { }
}
is LocalOnlyHotspotService.HotspotBinder -> @TargetApi(26) {
hotspotBinder = service
service.fragment = this
adapter.updateLocalOnlyViewHolder()
}
else -> throw IllegalArgumentException("service")
} }
override fun onServiceDisconnected(name: ComponentName?) { override fun onServiceDisconnected(name: ComponentName?) {
binder?.fragment = null val context = requireContext()
binder = null when (name) {
ComponentName(context, TetheringService::class.java) -> {
tetheringBinder?.fragment = null
tetheringBinder = null
context.unregisterReceiver(receiver)
}
ComponentName(context, LocalOnlyHotspotService::class.java) -> {
hotspotBinder?.fragment = null
hotspotBinder = null
}
else -> throw IllegalArgumentException("name")
}
} }
} }

View File

@@ -1,16 +1,18 @@
package be.mygod.vpnhotspot package be.mygod.vpnhotspot
import android.app.Service
import android.content.Intent import android.content.Intent
import android.os.Binder import android.os.Binder
import android.support.v4.content.LocalBroadcastManager import android.support.v4.content.LocalBroadcastManager
import android.widget.Toast import android.widget.Toast
import be.mygod.vpnhotspot.App.Companion.app import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.net.* import be.mygod.vpnhotspot.net.IpNeighbourMonitor
import be.mygod.vpnhotspot.net.Routing
import be.mygod.vpnhotspot.net.TetheringManager
import be.mygod.vpnhotspot.net.VpnMonitor
import java.net.InetAddress import java.net.InetAddress
import java.net.SocketException import java.net.SocketException
class TetheringService : Service(), VpnMonitor.Callback, IpNeighbourMonitor.Callback { class TetheringService : IpNeighbourMonitoringService(), VpnMonitor.Callback {
companion object { companion object {
const val EXTRA_ADD_INTERFACE = "interface.add" const val EXTRA_ADD_INTERFACE = "interface.add"
const val EXTRA_REMOVE_INTERFACE = "interface.remove" const val EXTRA_REMOVE_INTERFACE = "interface.remove"
@@ -24,7 +26,6 @@ class TetheringService : Service(), VpnMonitor.Callback, IpNeighbourMonitor.Call
private val binder = TetheringBinder() private val binder = TetheringBinder()
private val routings = HashMap<String, Routing?>() private val routings = HashMap<String, Routing?>()
private var neighbours = emptyList<IpNeighbour>()
private var upstream: String? = null private var upstream: String? = null
private var dns: List<InetAddress> = emptyList() private var dns: List<InetAddress> = emptyList()
private var receiverRegistered = false private var receiverRegistered = false
@@ -32,9 +33,8 @@ class TetheringService : Service(), VpnMonitor.Callback, IpNeighbourMonitor.Call
synchronized(routings) { synchronized(routings) {
when (intent.action) { when (intent.action) {
TetheringManager.ACTION_TETHER_STATE_CHANGED -> { TetheringManager.ACTION_TETHER_STATE_CHANGED -> {
val remove = routings.keys - TetheringManager.getTetheredIfaces(intent.extras) val failed = (routings.keys - TetheringManager.getTetheredIfaces(intent.extras))
if (remove.isEmpty()) return@broadcastReceiver .any { routings.remove(it)?.stop() == false }
val failed = remove.any { routings.remove(it)?.stop() == false }
if (failed) Toast.makeText(this, getText(R.string.noisy_su_failure), Toast.LENGTH_SHORT).show() if (failed) Toast.makeText(this, getText(R.string.noisy_su_failure), Toast.LENGTH_SHORT).show()
} }
App.ACTION_CLEAN_ROUTINGS -> for (iface in routings.keys) routings[iface] = null App.ACTION_CLEAN_ROUTINGS -> for (iface in routings.keys) routings[iface] = null
@@ -42,13 +42,10 @@ class TetheringService : Service(), VpnMonitor.Callback, IpNeighbourMonitor.Call
updateRoutingsLocked() updateRoutingsLocked()
} }
} }
override val activeIfaces get() = synchronized(routings) { routings.keys.toList() }
private fun updateRoutingsLocked() { fun updateRoutingsLocked() {
if (routings.isEmpty()) { if (routings.isNotEmpty()) {
unregisterReceiver()
ServiceNotification.stopForeground(this)
stopSelf()
} else {
val upstream = upstream val upstream = upstream
if (upstream != null) { if (upstream != null) {
var failed = false var failed = false
@@ -56,13 +53,11 @@ class TetheringService : Service(), VpnMonitor.Callback, IpNeighbourMonitor.Call
// system tethering already has working forwarding rules // system tethering already has working forwarding rules
// so it doesn't make sense to add additional forwarding rules // so it doesn't make sense to add additional forwarding rules
val routing = Routing(upstream, downstream).rule().forward().masquerade().dnsRedirect(dns) val routing = Routing(upstream, downstream).rule().forward().masquerade().dnsRedirect(dns)
if (routing.start()) routings[downstream] = routing else { routings[downstream] = routing
failed = true if (!routing.start()) failed = true
routing.stop()
routings.remove(downstream)
}
} catch (e: SocketException) { } catch (e: SocketException) {
e.printStackTrace() e.printStackTrace()
routings.remove(downstream)
failed = true failed = true
} }
if (failed) Toast.makeText(this, getText(R.string.noisy_su_failure), Toast.LENGTH_SHORT).show() if (failed) Toast.makeText(this, getText(R.string.noisy_su_failure), Toast.LENGTH_SHORT).show()
@@ -76,6 +71,11 @@ class TetheringService : Service(), VpnMonitor.Callback, IpNeighbourMonitor.Call
} }
postIpNeighbourAvailable() postIpNeighbourAvailable()
} }
if (routings.isEmpty()) {
unregisterReceiver()
ServiceNotification.stopForeground(this)
stopSelf()
}
app.handler.post { binder.fragment?.adapter?.notifyDataSetChanged() } app.handler.post { binder.fragment?.adapter?.notifyDataSetChanged() }
} }
@@ -113,21 +113,6 @@ class TetheringService : Service(), VpnMonitor.Callback, IpNeighbourMonitor.Call
if (failed) Toast.makeText(this, getText(R.string.noisy_su_failure), Toast.LENGTH_SHORT).show() if (failed) Toast.makeText(this, getText(R.string.noisy_su_failure), Toast.LENGTH_SHORT).show()
} }
override fun onIpNeighbourAvailable(neighbours: Map<String, IpNeighbour>) {
this.neighbours = neighbours.values.toList()
}
override fun postIpNeighbourAvailable() {
val sizeLookup = neighbours.groupBy { it.dev }.mapValues { (_, neighbours) ->
neighbours
.filter { it.state != IpNeighbour.State.FAILED }
.distinctBy { it.lladdr }
.size
}
ServiceNotification.startForeground(this, synchronized(routings) {
routings.keys.associate { Pair(it, sizeLookup[it] ?: 0) }
})
}
override fun onDestroy() { override fun onDestroy() {
unregisterReceiver() unregisterReceiver()
super.onDestroy() super.onDestroy()

View File

@@ -113,7 +113,8 @@ class Routing(val upstream: String?, val downstream: String, ownerAddress: InetA
fun start(): Boolean { fun start(): Boolean {
if (started) return true if (started) return true
started = true started = true
return noisySu(startScript) == true if (noisySu(startScript) != true) stop()
return started
} }
fun stop(): Boolean { fun stop(): Boolean {
if (!started) return true if (!started) return true

View File

@@ -125,7 +125,8 @@ object TetheringManager {
stopTethering.invoke(app.connectivity, type) stopTethering.invoke(app.connectivity, type)
} }
fun getTetheredIfaces(extras: Bundle) = if (Build.VERSION.SDK_INT >= 26) fun getTetheredIfaces(extras: Bundle) = extras.getStringArrayList(
extras.getStringArrayList(EXTRA_ACTIVE_TETHER).toSet() + extras.getStringArrayList(EXTRA_ACTIVE_LOCAL_ONLY) if (Build.VERSION.SDK_INT >= 26) EXTRA_ACTIVE_TETHER else EXTRA_ACTIVE_TETHER_LEGACY)
else extras.getStringArrayList(EXTRA_ACTIVE_TETHER_LEGACY).toSet() fun getLocalOnlyTetheredIfaces(extras: Bundle) =
if (Build.VERSION.SDK_INT >= 26) extras.getStringArrayList(EXTRA_ACTIVE_LOCAL_ONLY) else emptyList<String>()
} }

View File

@@ -1,13 +1,10 @@
package be.mygod.vpnhotspot.net.wifi package be.mygod.vpnhotspot.net.wifi
import android.content.Context
import android.net.wifi.WifiConfiguration import android.net.wifi.WifiConfiguration
import android.net.wifi.WifiManager import android.net.wifi.WifiManager
import be.mygod.vpnhotspot.App.Companion.app import be.mygod.vpnhotspot.App.Companion.app
@Deprecated("No longer usable since API 26.")
object WifiApManager { object WifiApManager {
private val wifi = app.getSystemService(Context.WIFI_SERVICE) as WifiManager
private val setWifiApEnabled = WifiManager::class.java.getDeclaredMethod("setWifiApEnabled", private val setWifiApEnabled = WifiManager::class.java.getDeclaredMethod("setWifiApEnabled",
WifiConfiguration::class.java, Boolean::class.java) WifiConfiguration::class.java, Boolean::class.java)
/** /**
@@ -23,12 +20,14 @@ object WifiApManager {
private fun WifiManager.setWifiApEnabled(wifiConfig: WifiConfiguration?, enabled: Boolean) = private fun WifiManager.setWifiApEnabled(wifiConfig: WifiConfiguration?, enabled: Boolean) =
setWifiApEnabled.invoke(this, wifiConfig, enabled) as Boolean setWifiApEnabled.invoke(this, wifiConfig, enabled) as Boolean
@Deprecated("No longer usable since API 26.")
fun start(wifiConfig: WifiConfiguration? = null) { fun start(wifiConfig: WifiConfiguration? = null) {
wifi.isWifiEnabled = false app.wifi.isWifiEnabled = false
wifi.setWifiApEnabled(wifiConfig, true) app.wifi.setWifiApEnabled(wifiConfig, true)
} }
@Deprecated("No longer usable since API 26.")
fun stop() { fun stop() {
wifi.setWifiApEnabled(null, false) app.wifi.setWifiApEnabled(null, false)
wifi.isWifiEnabled = true app.wifi.isWifiEnabled = true
} }
} }

View File

@@ -35,7 +35,7 @@
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@{data.iface.name}" android:text="@{data.title}"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" android:textAppearance="@style/TextAppearance.AppCompat.Subhead"
android:textIsSelectable="true" android:textIsSelectable="true"
tools:text="wlan0"/> tools:text="wlan0"/>
@@ -43,7 +43,7 @@
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@{data.iface.addresses}" android:text="@{data.text}"
tools:text="192.168.43.1/24\n01:23:45:ab:cd:ef"/> tools:text="192.168.43.1/24\n01:23:45:ab:cd:ef"/>
</LinearLayout> </LinearLayout>

View File

@@ -4,6 +4,7 @@
<string name="title_repeater">无线中继</string> <string name="title_repeater">无线中继</string>
<string name="title_tethering">系统共享</string> <string name="title_tethering">系统共享</string>
<string name="title_settings">设置选项</string> <string name="title_settings">设置选项</string>
<string name="service_inactive">未打开</string>
<string name="repeater_addresses">中继地址</string> <string name="repeater_addresses">中继地址</string>
<string name="repeater_wps_dialog_title">输入 PIN</string> <string name="repeater_wps_dialog_title">输入 PIN</string>
@@ -17,7 +18,6 @@
<string name="repeater_reset_credentials_success">凭据已重置。</string> <string name="repeater_reset_credentials_success">凭据已重置。</string>
<string name="repeater_reset_credentials_failure">重置凭据失败(原因:%s</string> <string name="repeater_reset_credentials_failure">重置凭据失败(原因:%s</string>
<string name="repeater_inactive">未打开</string>
<string name="repeater_p2p_unavailable">Wi\u2011Fi 直连不可用</string> <string name="repeater_p2p_unavailable">Wi\u2011Fi 直连不可用</string>
<string name="repeater_create_group_failure">创建 P2P 群组失败(原因:%s</string> <string name="repeater_create_group_failure">创建 P2P 群组失败(原因:%s</string>
<string name="repeater_remove_group_failure">关闭已有 P2P 群组失败(原因:%s</string> <string name="repeater_remove_group_failure">关闭已有 P2P 群组失败(原因:%s</string>
@@ -28,7 +28,13 @@
<string name="repeater_failure_reason_p2p_unsupported">设备不支持 Wi\u2011Fi 直连</string> <string name="repeater_failure_reason_p2p_unsupported">设备不支持 Wi\u2011Fi 直连</string>
<string name="repeater_failure_reason_busy">系统忙</string> <string name="repeater_failure_reason_busy">系统忙</string>
<string name="repeater_failure_reason_no_service_requests">未添加服务请求</string> <string name="repeater_failure_reason_no_service_requests">未添加服务请求</string>
<string name="repeater_failure_reason_unknown">未知 #%d</string>
<string name="tethering_temp_hotspot">临时 WLAN 热点</string>
<string name="tethering_temp_hotspot_failure">打开热点失败 (原因:%s)</string>
<string name="tethering_temp_hotspot_failure_no_channel">无频段</string>
<string name="tethering_temp_hotspot_failure_generic">通用错误</string>
<string name="tethering_temp_hotspot_failure_incompatible_mode">模式不兼容</string>
<string name="tethering_temp_hotspot_failure_tethering_disallowed">共享被禁用</string>
<string name="tethering_manage">管理…</string> <string name="tethering_manage">管理…</string>
<!-- <!--
@@ -54,7 +60,7 @@
<string name="settings_service_repeater_oc">Wi\u2011Fi 运行频段 (不稳定)</string> <string name="settings_service_repeater_oc">Wi\u2011Fi 运行频段 (不稳定)</string>
<string name="settings_service_repeater_oc_summary">"自动 (1\u201114 = 2.4GHz, 15\u2011165 = 5GHz)"</string> <string name="settings_service_repeater_oc_summary">"自动 (1\u201114 = 2.4GHz, 15\u2011165 = 5GHz)"</string>
<string name="settings_service_repeater_strict">严格模式</string> <string name="settings_service_repeater_strict">严格模式</string>
<string name="settings_service_repeater_strict_summary">只允许通过 VPN 隧道的包通过</string> <string name="settings_service_repeater_strict_summary">只允许通过 VPN 隧道的包通过,也适用于临时热点</string>
<string name="settings_service_dns">备用 DNS 服务器[:端口]</string> <string name="settings_service_dns">备用 DNS 服务器[:端口]</string>
<string name="settings_service_clean">清理/重新应用路由规则</string> <string name="settings_service_clean">清理/重新应用路由规则</string>
<string name="settings_misc">杂项</string> <string name="settings_misc">杂项</string>
@@ -72,6 +78,7 @@
<item quantity="other">%d 个接口</item> <item quantity="other">%d 个接口</item>
</plurals> </plurals>
<string name="failure_reason_unknown">未知 #%d</string>
<string name="exception_interface_not_found">错误:未找到下游接口</string> <string name="exception_interface_not_found">错误:未找到下游接口</string>
<string name="root_unavailable">似乎没有 root</string> <string name="root_unavailable">似乎没有 root</string>
<string name="noisy_su_failure">发生异常,详情请查看调试信息。</string> <string name="noisy_su_failure">发生异常,详情请查看调试信息。</string>

View File

@@ -13,6 +13,7 @@
<string name="title_repeater">Repeater</string> <string name="title_repeater">Repeater</string>
<string name="title_tethering">Tethering</string> <string name="title_tethering">Tethering</string>
<string name="title_settings">Settings</string> <string name="title_settings">Settings</string>
<string name="service_inactive">Service inactive</string>
<string name="repeater_addresses">Addresses</string> <string name="repeater_addresses">Addresses</string>
<string name="repeater_wps">WPS</string> <string name="repeater_wps">WPS</string>
@@ -28,7 +29,6 @@
<string name="repeater_reset_credentials_success">Credentials reset.</string> <string name="repeater_reset_credentials_success">Credentials reset.</string>
<string name="repeater_reset_credentials_failure">Failed to reset credentials (reason: %s)</string> <string name="repeater_reset_credentials_failure">Failed to reset credentials (reason: %s)</string>
<string name="repeater_inactive">Service inactive</string>
<string name="repeater_p2p_unavailable">Wi\u2011Fi direct unavailable</string> <string name="repeater_p2p_unavailable">Wi\u2011Fi direct unavailable</string>
<string name="repeater_create_group_failure">Failed to create P2P group (reason: %s)</string> <string name="repeater_create_group_failure">Failed to create P2P group (reason: %s)</string>
<string name="repeater_remove_group_failure">Failed to remove P2P group (reason: %s)</string> <string name="repeater_remove_group_failure">Failed to remove P2P group (reason: %s)</string>
@@ -39,7 +39,13 @@
<string name="repeater_failure_reason_p2p_unsupported">Wi\u2011Fi direct unsupported</string> <string name="repeater_failure_reason_p2p_unsupported">Wi\u2011Fi direct unsupported</string>
<string name="repeater_failure_reason_busy">framework is busy</string> <string name="repeater_failure_reason_busy">framework is busy</string>
<string name="repeater_failure_reason_no_service_requests">no service requests added</string> <string name="repeater_failure_reason_no_service_requests">no service requests added</string>
<string name="repeater_failure_reason_unknown">unknown #%d</string>
<string name="tethering_temp_hotspot">Temporary Wi\u2011Fi hotspot</string>
<string name="tethering_temp_hotspot_failure">Failed to start hotspot (reason: %s)</string>
<string name="tethering_temp_hotspot_failure_no_channel">no channel</string>
<string name="tethering_temp_hotspot_failure_generic">generic error</string>
<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_manage">Manage…</string> <string name="tethering_manage">Manage…</string>
<string name="tethering_manage_usb">USB tethering</string> <string name="tethering_manage_usb">USB tethering</string>
@@ -57,7 +63,8 @@
<string name="settings_service_repeater_oc">Operating Wi\u2011Fi channel (unstable)</string> <string name="settings_service_repeater_oc">Operating Wi\u2011Fi channel (unstable)</string>
<string name="settings_service_repeater_oc_summary">Auto (1\u201114 = 2.4GHz, 15\u2011165 = 5GHz)</string> <string name="settings_service_repeater_oc_summary">Auto (1\u201114 = 2.4GHz, 15\u2011165 = 5GHz)</string>
<string name="settings_service_repeater_strict">Strict mode</string> <string name="settings_service_repeater_strict">Strict mode</string>
<string name="settings_service_repeater_strict_summary">Only allow packets that goes through VPN tunnel</string> <string name="settings_service_repeater_strict_summary">Only allow packets that goes through VPN tunnel, also
applies to temporary Wi\u2011Fi hotspot.</string>
<string name="settings_service_dns">Fallback DNS server[:port]</string> <string name="settings_service_dns">Fallback DNS server[:port]</string>
<string name="settings_service_clean">Clean/reapply routing rules</string> <string name="settings_service_clean">Clean/reapply routing rules</string>
<string name="settings_misc">Misc</string> <string name="settings_misc">Misc</string>
@@ -77,6 +84,7 @@
<item quantity="other">%d interfaces</item> <item quantity="other">%d interfaces</item>
</plurals> </plurals>
<string name="failure_reason_unknown">unknown #%d</string>
<string name="exception_interface_not_found">Fatal: Downstream interface not found</string> <string name="exception_interface_not_found">Fatal: Downstream interface not found</string>
<string name="root_unavailable">Root unavailable</string> <string name="root_unavailable">Root unavailable</string>
<string name="noisy_su_failure">Something went wrong, please check the debug information.</string> <string name="noisy_su_failure">Something went wrong, please check the debug information.</string>