diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml index 498d070e..176c47c2 100644 --- a/mobile/src/main/AndroidManifest.xml +++ b/mobile/src/main/AndroidManifest.xml @@ -30,6 +30,7 @@ + + + diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/App.kt b/mobile/src/main/java/be/mygod/vpnhotspot/App.kt index 12d387e5..34fde065 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/App.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/App.kt @@ -6,6 +6,7 @@ import android.content.Context import android.content.SharedPreferences import android.content.res.Configuration import android.net.ConnectivityManager +import android.net.wifi.WifiManager import android.os.Build import android.os.Handler import android.preference.PreferenceManager @@ -43,6 +44,7 @@ class App : Application() { val handler = Handler() val pref: SharedPreferences by lazy { PreferenceManager.getDefaultSharedPreferences(deviceContext) } 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 result = pref.getString(KEY_OPERATING_CHANNEL, null)?.toIntOrNull() ?: 0 diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/IpNeighbourMonitoringService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/IpNeighbourMonitoringService.kt new file mode 100644 index 00000000..9d227c06 --- /dev/null +++ b/mobile/src/main/java/be/mygod/vpnhotspot/IpNeighbourMonitoringService.kt @@ -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() + + protected abstract val activeIfaces: List + + override fun onIpNeighbourAvailable(neighbours: Map) { + 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) }) + } +} diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt new file mode 100644 index 00000000..4713c07d --- /dev/null +++ b/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt @@ -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 + } + } +} diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyInterfaceManager.kt b/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyInterfaceManager.kt new file mode 100644 index 00000000..d3dee1a8 --- /dev/null +++ b/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyInterfaceManager.kt @@ -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() + + init { + LocalBroadcastManager.getInstance(app).registerReceiver(this, intentFilter(App.ACTION_CLEAN_ROUTINGS)) + VpnMonitor.registerCallback(this) { initRouting() } + } + + override fun onAvailable(ifname: String, dns: List) { + 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 = 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) + } +} diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterFragment.kt b/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterFragment.kt index 60d66959..78a446d8 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterFragment.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterFragment.kt @@ -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 { return try { NetworkInterface.getByName(p2pInterface ?: return "")?.formatAddresses() ?: "" @@ -160,7 +160,8 @@ class RepeaterFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClickL private var p2pInterface: String? = null private var tetheredInterfaces = emptySet() private val receiver = broadcastReceiver { _, intent -> - tetheredInterfaces = TetheringManager.getTetheredIfaces(intent.extras).toSet() + tetheredInterfaces = TetheringManager.getTetheredIfaces(intent.extras).toSet() + + TetheringManager.getLocalOnlyTetheredIfaces(intent.extras) adapter.recreate() } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt index 42707233..1e9fb4cf 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt @@ -16,8 +16,6 @@ import android.support.v4.content.LocalBroadcastManager import android.util.Log import android.widget.Toast 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.deletePersistentGroup 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 java.lang.reflect.InvocationTargetException import java.net.InetAddress -import java.net.SocketException -class RepeaterService : Service(), WifiP2pManager.ChannelListener, VpnMonitor.Callback, - SharedPreferences.OnSharedPreferenceChangeListener { +class RepeaterService : Service(), WifiP2pManager.ChannelListener, SharedPreferences.OnSharedPreferenceChangeListener { companion object { const val ACTION_STATUS_CHANGED = "be.mygod.vpnhotspot.RepeaterService.STATUS_CHANGED" 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_NETWORK_INFO), 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 upstream: String? = null - private var dns: List = emptyList() - private var routing: Routing? = null + private var routingManager: LocalOnlyInterfaceManager? = null var status = Status.IDLE 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.BUSY -> getString(R.string.repeater_failure_reason_busy) 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() { @@ -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 { if (status != Status.IDLE) return START_NOT_STICKY 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 = emptyList()) { val matcher = WifiP2pManagerHelper.patternNetworkInfo.matcher( loggerSu("dumpsys ${Context.WIFI_P2P_SERVICE}") ?: "") when { !matcher.find() -> startFailure(getString(R.string.root_unavailable)) matcher.group(2) == "true" -> { unregisterReceiver() - upstream = ifname - this.dns = dns registerReceiver(receiver, intentFilter(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION, WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION)) - LocalBroadcastManager.getInstance(this) - .registerReceiver(receiver, intentFilter(App.ACTION_CLEAN_ROUTINGS)) receiverRegistered = true p2pManager.requestGroupInfo(channel, { when { @@ -227,72 +198,40 @@ class RepeaterService : Service(), WifiP2pManager.ChannelListener, VpnMonitor.Ca } else -> startFailure(getString(R.string.repeater_p2p_unavailable)) } + return START_NOT_STICKY } - - private fun resetup(routing: Routing, ifname: String? = null, dns: List = emptyList()) = - initRouting(ifname, routing.downstream, routing.hostAddress, dns) - - override fun onAvailable(ifname: String, dns: List) = 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!!) - } - + /** + * startService Step 2 (if a group isn't already available) + */ 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 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) { debugLog(TAG, "P2P connection changed: $info\n$net\n$group") if (!info.groupFormed || !info.isGroupOwner || !group.isGroupOwner) { - if (routing != null) clean() // P2P shutdown - return - } - if (routing == null) try { - if (initRouting(upstream, group.`interface` ?: return, info.groupOwnerAddress ?: return, dns)) - doStart(group) - } catch (e: SocketException) { - startFailure(e.message, group) - return - } else showNotification(group) - this.group = group + if (routingManager != null) clean() // P2P shutdown + } else if (routingManager != null) { + this.group = group + showNotification(group) + } else doStart(group, info.groupOwnerAddress) } - private fun initRouting(upstream: String?, downstream: String, - owner: InetAddress, dns: List): Boolean { - val routing = Routing(upstream, downstream, owner) - this.routing = routing - this.dns = dns - val strict = app.pref.getBoolean("service.repeater.strict", false) - return if (strict && upstream == null || // in this case, nothing to be done - routing.ipForward() // Wi-Fi direct doesn't enable ip_forward - .rule().forward(strict).masquerade(strict).dnsRedirect(dns).start()) true else { - routing.stop() - Toast.makeText(this, getText(R.string.noisy_su_failure), Toast.LENGTH_SHORT).show() - false - } + /** + * startService Step 3 + */ + private fun doStart(group: WifiP2pGroup, ownerAddress: InetAddress? = null) { + this.group = group + routingManager = LocalOnlyInterfaceManager(group.`interface`!!, ownerAddress) + status = Status.ACTIVE + showNotification(group) + } + private fun startFailure(msg: CharSequence?, group: WifiP2pGroup? = null) { + Toast.makeText(this, msg, Toast.LENGTH_SHORT).show() + showNotification() + if (group != null) removeGroup() else clean() } private fun showNotification(group: WifiP2pGroup? = null) = ServiceNotification.startForeground(this, @@ -314,16 +253,13 @@ class RepeaterService : Service(), WifiP2pManager.ChannelListener, VpnMonitor.Ca private fun unregisterReceiver() { if (receiverRegistered) { unregisterReceiver(receiver) - LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver) receiverRegistered = false } } private fun clean() { - VpnMonitor.unregisterCallback(this) unregisterReceiver() - if (routing?.stop() == false) - Toast.makeText(this, getText(R.string.noisy_su_failure), Toast.LENGTH_SHORT).show() - routing = null + routingManager?.stop() + routingManager = null status = Status.IDLE ServiceNotification.stopForeground(this) stopSelf() diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/TetheringFragment.kt b/mobile/src/main/java/be/mygod/vpnhotspot/TetheringFragment.kt index 63f03073..f3bf910a 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/TetheringFragment.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/TetheringFragment.kt @@ -1,9 +1,12 @@ package be.mygod.vpnhotspot +import android.Manifest import android.annotation.SuppressLint +import android.annotation.TargetApi import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothProfile import android.content.* +import android.content.pm.PackageManager import android.databinding.BaseObservable import android.databinding.Bindable import android.databinding.DataBindingUtil @@ -39,12 +42,15 @@ import java.util.* class TetheringFragment : Fragment(), ServiceConnection { companion object { 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_WIFI = 2 private const val VIEW_TYPE_USB = 3 private const val VIEW_TYPE_BLUETOOTH = 4 private const val VIEW_TYPE_WIFI_LEGACY = 5 + private const val START_LOCAL_ONLY_HOTSPOT = 1 + /** * PAN Profile * From BluetoothProfile.java. @@ -55,26 +61,63 @@ class TetheringFragment : Fragment(), ServiceConnection { } } - inner class Data(val iface: TetheredInterface) : BaseObservable() { - val icon: Int get() = TetherType.ofInterface(iface.name).icon - val active = binder?.isActive(iface.name) == true + interface Data { + val icon: Int + 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) : 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), - View.OnClickListener { + private open class InterfaceViewHolder(val binding: ListitemInterfaceBinding) : + RecyclerView.ViewHolder(binding.root), View.OnClickListener { init { itemView.setOnClickListener(this) } override fun onClick(view: View) { val context = itemView.context - val data = binding.data!! + val data = binding.data as TetheredData if (data.active) context.startService(Intent(context, TetheringService::class.java) .putExtra(TetheringService.EXTRA_REMOVE_INTERFACE, data.iface.name)) else ContextCompat.startForegroundService(context, Intent(context, TetheringService::class.java) .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 { init { 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() @Bindable get set(value) { @@ -208,29 +251,40 @@ class TetheringFragment : Fragment(), ServiceConnection { } inner class TetheringAdapter : ListAdapter(TetheredInterface.DiffCallback) { - fun update(data: Set) { - val lookup = try { + private var lookup: Map = emptyMap() + + fun update(activeIfaces: List, localOnlyIfaces: List) { + lookup = try { NetworkInterface.getNetworkInterfaces().asSequence().associateBy { it.name } } catch (e: SocketException) { e.printStackTrace() - emptyMap() + emptyMap() } - this@TetheringFragment.tetherListener.enabledTypes = data.map { TetherType.ofInterface(it) }.toSet() - submitList(data.map { TetheredInterface(it, lookup) }.sorted()) + this@TetheringFragment.tetherListener.enabledTypes = + (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) { - in 0 until 24 -> 2 - in 24..25 -> 5 - else -> 4 - } - override fun getItemViewType(position: Int) = when (position - super.getItemCount()) { - 0 -> VIEW_TYPE_MANAGE - 1 -> if (Build.VERSION.SDK_INT >= 24) VIEW_TYPE_USB else VIEW_TYPE_WIFI_LEGACY - 2 -> VIEW_TYPE_WIFI - 3 -> VIEW_TYPE_BLUETOOTH - 4 -> VIEW_TYPE_WIFI_LEGACY - else -> VIEW_TYPE_INTERFACE + override fun getItemCount() = super.getItemCount() + if (Build.VERSION.SDK_INT < 24) 2 else 5 + override fun getItemViewType(position: Int) = if (Build.VERSION.SDK_INT < 26) { + when (position - super.getItemCount()) { + 0 -> VIEW_TYPE_MANAGE + 1 -> if (Build.VERSION.SDK_INT >= 24) VIEW_TYPE_USB else VIEW_TYPE_WIFI_LEGACY + 2 -> VIEW_TYPE_WIFI + 3 -> VIEW_TYPE_BLUETOOTH + 4 -> VIEW_TYPE_WIFI_LEGACY + else -> VIEW_TYPE_INTERFACE + } + } else { + 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 { 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_WIFI, VIEW_TYPE_USB, VIEW_TYPE_BLUETOOTH, VIEW_TYPE_WIFI_LEGACY -> 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") } } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { 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 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() 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? { @@ -269,14 +334,23 @@ class TetheringFragment : Fragment(), ServiceConnection { override fun onStart() { super.onStart() val context = requireContext() - context.registerReceiver(receiver, intentFilter(TetheringManager.ACTION_TETHER_STATE_CHANGED)) 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, 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() { - val context = requireContext() - context.unbindService(this) - context.unregisterReceiver(receiver) + requireContext().unbindService(this) super.onStop() } @@ -285,14 +359,34 @@ class TetheringFragment : Fragment(), ServiceConnection { super.onDestroy() } - override fun onServiceConnected(name: ComponentName?, service: IBinder?) { - val binder = service as TetheringService.TetheringBinder - this.binder = binder - binder.fragment = this + override fun onServiceConnected(name: ComponentName?, service: IBinder?) = when (service) { + is TetheringService.TetheringBinder -> { + tetheringBinder = service + 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?) { - binder?.fragment = null - binder = null + val context = requireContext() + 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") + } } } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt index ce9c6263..f002babc 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt @@ -1,16 +1,18 @@ package be.mygod.vpnhotspot -import android.app.Service import android.content.Intent import android.os.Binder import android.support.v4.content.LocalBroadcastManager import android.widget.Toast 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.SocketException -class TetheringService : Service(), VpnMonitor.Callback, IpNeighbourMonitor.Callback { +class TetheringService : IpNeighbourMonitoringService(), VpnMonitor.Callback { companion object { const val EXTRA_ADD_INTERFACE = "interface.add" const val EXTRA_REMOVE_INTERFACE = "interface.remove" @@ -24,7 +26,6 @@ class TetheringService : Service(), VpnMonitor.Callback, IpNeighbourMonitor.Call private val binder = TetheringBinder() private val routings = HashMap() - private var neighbours = emptyList() private var upstream: String? = null private var dns: List = emptyList() private var receiverRegistered = false @@ -32,9 +33,8 @@ class TetheringService : Service(), VpnMonitor.Callback, IpNeighbourMonitor.Call synchronized(routings) { when (intent.action) { TetheringManager.ACTION_TETHER_STATE_CHANGED -> { - val remove = routings.keys - TetheringManager.getTetheredIfaces(intent.extras) - if (remove.isEmpty()) return@broadcastReceiver - val failed = remove.any { routings.remove(it)?.stop() == false } + val failed = (routings.keys - TetheringManager.getTetheredIfaces(intent.extras)) + .any { routings.remove(it)?.stop() == false } 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 @@ -42,13 +42,10 @@ class TetheringService : Service(), VpnMonitor.Callback, IpNeighbourMonitor.Call updateRoutingsLocked() } } + override val activeIfaces get() = synchronized(routings) { routings.keys.toList() } - private fun updateRoutingsLocked() { - if (routings.isEmpty()) { - unregisterReceiver() - ServiceNotification.stopForeground(this) - stopSelf() - } else { + fun updateRoutingsLocked() { + if (routings.isNotEmpty()) { val upstream = upstream if (upstream != null) { var failed = false @@ -56,13 +53,11 @@ class TetheringService : Service(), VpnMonitor.Callback, IpNeighbourMonitor.Call // system tethering already has working forwarding rules // so it doesn't make sense to add additional forwarding rules val routing = Routing(upstream, downstream).rule().forward().masquerade().dnsRedirect(dns) - if (routing.start()) routings[downstream] = routing else { - failed = true - routing.stop() - routings.remove(downstream) - } + routings[downstream] = routing + if (!routing.start()) failed = true } catch (e: SocketException) { e.printStackTrace() + routings.remove(downstream) failed = true } 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() } + if (routings.isEmpty()) { + unregisterReceiver() + ServiceNotification.stopForeground(this) + stopSelf() + } 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() } - override fun onIpNeighbourAvailable(neighbours: Map) { - 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() { unregisterReceiver() super.onDestroy() diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/Routing.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/Routing.kt index f0e4bec5..daa60ef2 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/Routing.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/Routing.kt @@ -113,7 +113,8 @@ class Routing(val upstream: String?, val downstream: String, ownerAddress: InetA fun start(): Boolean { if (started) return true started = true - return noisySu(startScript) == true + if (noisySu(startScript) != true) stop() + return started } fun stop(): Boolean { if (!started) return true diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/TetheringManager.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/TetheringManager.kt index ae3126f7..6cc910dc 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/TetheringManager.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/TetheringManager.kt @@ -125,7 +125,8 @@ object TetheringManager { stopTethering.invoke(app.connectivity, type) } - fun getTetheredIfaces(extras: Bundle) = if (Build.VERSION.SDK_INT >= 26) - extras.getStringArrayList(EXTRA_ACTIVE_TETHER).toSet() + extras.getStringArrayList(EXTRA_ACTIVE_LOCAL_ONLY) - else extras.getStringArrayList(EXTRA_ACTIVE_TETHER_LEGACY).toSet() + fun getTetheredIfaces(extras: Bundle) = extras.getStringArrayList( + if (Build.VERSION.SDK_INT >= 26) EXTRA_ACTIVE_TETHER else EXTRA_ACTIVE_TETHER_LEGACY) + fun getLocalOnlyTetheredIfaces(extras: Bundle) = + if (Build.VERSION.SDK_INT >= 26) extras.getStringArrayList(EXTRA_ACTIVE_LOCAL_ONLY) else emptyList() } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiApManager.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiApManager.kt index 62238251..27e62a47 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiApManager.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiApManager.kt @@ -1,13 +1,10 @@ package be.mygod.vpnhotspot.net.wifi -import android.content.Context import android.net.wifi.WifiConfiguration import android.net.wifi.WifiManager import be.mygod.vpnhotspot.App.Companion.app -@Deprecated("No longer usable since API 26.") object WifiApManager { - private val wifi = app.getSystemService(Context.WIFI_SERVICE) as WifiManager private val setWifiApEnabled = WifiManager::class.java.getDeclaredMethod("setWifiApEnabled", WifiConfiguration::class.java, Boolean::class.java) /** @@ -23,12 +20,14 @@ object WifiApManager { private fun WifiManager.setWifiApEnabled(wifiConfig: WifiConfiguration?, enabled: Boolean) = setWifiApEnabled.invoke(this, wifiConfig, enabled) as Boolean + @Deprecated("No longer usable since API 26.") fun start(wifiConfig: WifiConfiguration? = null) { - wifi.isWifiEnabled = false - wifi.setWifiApEnabled(wifiConfig, true) + app.wifi.isWifiEnabled = false + app.wifi.setWifiApEnabled(wifiConfig, true) } + @Deprecated("No longer usable since API 26.") fun stop() { - wifi.setWifiApEnabled(null, false) - wifi.isWifiEnabled = true + app.wifi.setWifiApEnabled(null, false) + app.wifi.isWifiEnabled = true } } diff --git a/mobile/src/main/res/layout/listitem_interface.xml b/mobile/src/main/res/layout/listitem_interface.xml index 5e0f6146..f43d21c5 100644 --- a/mobile/src/main/res/layout/listitem_interface.xml +++ b/mobile/src/main/res/layout/listitem_interface.xml @@ -35,7 +35,7 @@ @@ -43,7 +43,7 @@ diff --git a/mobile/src/main/res/values-zh-rCN/strings.xml b/mobile/src/main/res/values-zh-rCN/strings.xml index 01c236c4..da9f64c7 100644 --- a/mobile/src/main/res/values-zh-rCN/strings.xml +++ b/mobile/src/main/res/values-zh-rCN/strings.xml @@ -4,6 +4,7 @@ 无线中继 系统共享 设置选项 + 未打开 中继地址 输入 PIN @@ -17,7 +18,6 @@ 凭据已重置。 重置凭据失败(原因:%s) - 未打开 Wi\u2011Fi 直连不可用 创建 P2P 群组失败(原因:%s) 关闭已有 P2P 群组失败(原因:%s) @@ -28,7 +28,13 @@ 设备不支持 Wi\u2011Fi 直连 系统忙 未添加服务请求 - 未知 #%d + + 临时 WLAN 热点 + 打开热点失败 (原因:%s) + 无频段 + 通用错误 + 模式不兼容 + 共享被禁用 管理…