Support showing connected devices from system tethering

Also fixes IP address not showing up.
This commit is contained in:
Mygod
2018-01-20 22:52:54 -08:00
parent 6bffe54e58
commit 0660a20fcb
11 changed files with 324 additions and 107 deletions

View File

@@ -8,6 +8,7 @@ import android.databinding.BaseObservable
import android.databinding.Bindable
import android.databinding.DataBindingUtil
import android.net.wifi.p2p.WifiP2pDevice
import android.net.wifi.p2p.WifiP2pGroup
import android.os.Bundle
import android.os.IBinder
import android.support.v4.app.Fragment
@@ -23,9 +24,12 @@ import android.view.*
import android.widget.EditText
import be.mygod.vpnhotspot.databinding.FragmentRepeaterBinding
import be.mygod.vpnhotspot.databinding.ListitemClientBinding
import be.mygod.vpnhotspot.net.IpNeighbour
import be.mygod.vpnhotspot.net.IpNeighbourMonitor
import be.mygod.vpnhotspot.net.NetUtils
import be.mygod.vpnhotspot.net.TetherType
class RepeaterFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClickListener {
class RepeaterFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClickListener, IpNeighbourMonitor.Callback {
inner class Data : BaseObservable() {
val switchEnabled: Boolean
@Bindable get() = when (binder?.service?.status) {
@@ -55,30 +59,53 @@ class RepeaterFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClickL
fun onStatusChanged() {
notifyPropertyChanged(BR.switchEnabled)
notifyPropertyChanged(BR.serviceStarted)
onGroupChanged()
val binder = binder
onGroupChanged(if (binder?.active == true) binder.service.group else null)
}
fun onGroupChanged() {
fun onGroupChanged(group: WifiP2pGroup?) {
notifyPropertyChanged(BR.ssid)
notifyPropertyChanged(BR.password)
adapter.fetchClients()
p2pInterface = group?.`interface`
adapter.p2p = group?.clientList ?: emptyList()
adapter.recreate()
}
val statusListener = broadcastReceiver { _, _ -> onStatusChanged() }
}
class ClientViewHolder(val binding: ListitemClientBinding) : RecyclerView.ViewHolder(binding.root)
inner class ClientAdapter : RecyclerView.Adapter<ClientViewHolder>() {
private var owner: WifiP2pDevice? = null
private lateinit var clients: Collection<WifiP2pDevice>
private lateinit var arpCache: Map<String, String>
inner class Client(p2p: WifiP2pDevice? = null,
private val pair: Map.Entry<IpNeighbour, IpNeighbour.State>? = null) {
val iface = pair?.key?.dev ?: p2pInterface!!
val mac = pair?.key?.lladdr ?: p2p!!.deviceAddress
val ip = pair?.key?.ip
fun fetchClients() {
val binder = binder
if (binder?.active == true) {
owner = binder.service.group?.owner
clients = binder.service.group?.clientList ?: emptyList()
arpCache = NetUtils.arp(binder.service.routing?.downstream)
} else owner = null
val icon get() = TetherType.ofInterface(iface, p2pInterface).icon
val title: CharSequence get() = listOf(ip, mac).filter { !it.isNullOrEmpty() }.joinToString(", ")
val description: CharSequence get() = when (pair?.value) {
IpNeighbour.State.INCOMPLETE, null -> "Connecting to $iface"
IpNeighbour.State.VALID -> "Connected to $iface"
IpNeighbour.State.VALID_DELAY -> "Connected to $iface (losing)"
IpNeighbour.State.FAILED -> "Failed to connect to $iface"
else -> throw IllegalStateException()
}
}
private class ClientViewHolder(val binding: ListitemClientBinding) : RecyclerView.ViewHolder(binding.root)
private inner class ClientAdapter : RecyclerView.Adapter<ClientViewHolder>() {
private val clients = ArrayList<Client>()
var p2p: Collection<WifiP2pDevice> = emptyList()
var neighbours = emptyMap<IpNeighbour, IpNeighbour.State>()
fun recreate() {
clients.clear()
val map = HashMap(p2p.associateBy { it.deviceAddress })
val tethered = (tetheredInterfaces + p2pInterface).filterNotNull()
for (pair in neighbours) {
val client = map.remove(pair.key.lladdr)
if (client != null) clients.add(Client(client, pair))
else if (tethered.contains(pair.key.dev)) clients.add(Client(pair = pair))
}
clients.addAll(map.map { Client(it.value) })
clients.sortWith(compareBy<Client> { it.ip }.thenBy { it.mac })
notifyDataSetChanged() // recreate everything
binding.swipeRefresher.isRefreshing = false
}
@@ -87,25 +114,23 @@ class RepeaterFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClickL
ClientViewHolder(ListitemClientBinding.inflate(LayoutInflater.from(parent.context)))
override fun onBindViewHolder(holder: ClientViewHolder, position: Int) {
val device = when (position) {
0 -> owner
else -> clients.elementAt(position - 1)
}
holder.binding.device = device
holder.binding.ipAddress = when (position) {
0 -> binder?.service?.routing?.hostAddress?.hostAddress
else -> arpCache[device?.deviceAddress]
}
holder.binding.client = clients[position]
holder.binding.executePendingBindings()
}
override fun getItemCount() = if (owner == null) 0 else 1 + clients.size
override fun getItemCount() = clients.size
}
private lateinit var binding: FragmentRepeaterBinding
private val data = Data()
private val adapter = ClientAdapter()
private var binder: RepeaterService.HotspotBinder? = null
private var p2pInterface: String? = null
private var tetheredInterfaces = emptySet<String>()
private val receiver = broadcastReceiver { _, intent ->
tetheredInterfaces = NetUtils.getTetheredIfaces(intent.extras).toSet()
adapter.recreate()
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_repeater, container, false)
@@ -115,7 +140,10 @@ class RepeaterFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClickL
animator.supportsChangeAnimations = false // prevent fading-in/out when rebinding
binding.clients.itemAnimator = animator
binding.clients.adapter = adapter
binding.swipeRefresher.setOnRefreshListener { adapter.fetchClients() }
binding.swipeRefresher.setOnRefreshListener {
IpNeighbourMonitor.instance?.flush()
adapter.recreate()
}
binding.toolbar.inflateMenu(R.menu.repeater)
binding.toolbar.setOnMenuItemClickListener(this)
return binding.root
@@ -125,11 +153,16 @@ class RepeaterFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClickL
super.onStart()
val context = context!!
context.bindService(Intent(context, RepeaterService::class.java), this, Context.BIND_AUTO_CREATE)
IpNeighbourMonitor.registerCallback(this)
context.registerReceiver(receiver, intentFilter(NetUtils.ACTION_TETHER_STATE_CHANGED))
}
override fun onStop() {
val context = context!!
context.unregisterReceiver(receiver)
IpNeighbourMonitor.unregisterCallback(this)
onServiceDisconnected(null)
context!!.unbindService(this)
context.unbindService(this)
super.onStop()
}
@@ -175,4 +208,9 @@ class RepeaterFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClickL
}
else -> false
}
override fun onIpNeighbourAvailable(neighbours: Map<IpNeighbour, IpNeighbour.State>) {
adapter.neighbours = neighbours.toMap()
}
override fun postIpNeighbourAvailable() = adapter.recreate()
}

View File

@@ -273,7 +273,7 @@ class RepeaterService : Service(), WifiP2pManager.ChannelListener, VpnMonitor.Ca
return
}
this.group = group
binder.data?.onGroupChanged()
binder.data?.onGroupChanged(group)
showNotification(group)
debugLog(TAG, "P2P connection changed: $info\n$net\n$group")
}

View File

@@ -28,7 +28,7 @@ class SettingsPreferenceFragment : PreferenceFragmentCompatDividers() {
val intent = Intent(Intent.ACTION_SEND)
.setType("text/plain")
.putExtra(Intent.EXTRA_TEXT, Runtime.getRuntime().exec(arrayOf("logcat", "-d"))
.inputStream.bufferedReader().use { it.readText() })
.inputStream.bufferedReader().readText())
startActivity(Intent.createChooser(intent, getString(R.string.abc_shareactionprovider_share_with)))
} catch (e: IOException) {
Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show()

View File

@@ -3,7 +3,6 @@ package be.mygod.vpnhotspot
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.content.Intent
import android.content.res.Resources
import android.databinding.BaseObservable
import android.databinding.DataBindingUtil
import android.os.Bundle
@@ -18,30 +17,12 @@ import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.databinding.FragmentTetheringBinding
import be.mygod.vpnhotspot.databinding.ListitemInterfaceBinding
import be.mygod.vpnhotspot.net.NetUtils
import be.mygod.vpnhotspot.net.TetherType
class TetheringFragment : Fragment(), Toolbar.OnMenuItemClickListener {
companion object {
/**
* Source: https://android.googlesource.com/platform/frameworks/base/+/61fa313/core/res/res/values/config.xml#328
*/
private val usbRegexes = app.resources.getStringArray(Resources.getSystem()
.getIdentifier("config_tether_usb_regexs", "array", "android"))
.map { it.toPattern() }
private val wifiRegexes = app.resources.getStringArray(Resources.getSystem()
.getIdentifier("config_tether_wifi_regexs", "array", "android"))
.map { it.toPattern() }
private val wimaxRegexes = app.resources.getStringArray(Resources.getSystem()
.getIdentifier("config_tether_wimax_regexs", "array", "android"))
.map { it.toPattern() }
private val bluetoothRegexes = app.resources.getStringArray(Resources.getSystem()
.getIdentifier("config_tether_bluetooth_regexs", "array", "android"))
.map { it.toPattern() }
}
private abstract class BaseSorter<T> : SortedList.Callback<T>() {
override fun onInserted(position: Int, count: Int) { }
override fun areContentsTheSame(oldItem: T?, newItem: T?): Boolean = oldItem == newItem
@@ -59,13 +40,7 @@ class TetheringFragment : Fragment(), Toolbar.OnMenuItemClickListener {
private object StringSorter : DefaultSorter<String>()
class Data(val iface: String) : BaseObservable() {
val icon: Int get() = when {
usbRegexes.any { it.matcher(iface).matches() } -> R.drawable.ic_device_usb
wifiRegexes.any { it.matcher(iface).matches() } -> R.drawable.ic_device_network_wifi
wimaxRegexes.any { it.matcher(iface).matches() } -> R.drawable.ic_device_network_wifi
bluetoothRegexes.any { it.matcher(iface).matches() } -> R.drawable.ic_device_bluetooth
else -> R.drawable.ic_device_wifi_tethering
}
val icon: Int get() = TetherType.ofInterface(iface).icon
var active = TetheringService.active.contains(iface)
}
@@ -112,7 +87,6 @@ class TetheringFragment : Fragment(), Toolbar.OnMenuItemClickListener {
NetUtils.ACTION_TETHER_STATE_CHANGED -> adapter.update(NetUtils.getTetheredIfaces(intent.extras).toSet())
}
}
private var receiverRegistered = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_tethering, container, false)
@@ -128,22 +102,16 @@ class TetheringFragment : Fragment(), Toolbar.OnMenuItemClickListener {
override fun onStart() {
super.onStart()
if (!receiverRegistered) {
val context = context!!
context.registerReceiver(receiver, intentFilter(NetUtils.ACTION_TETHER_STATE_CHANGED))
LocalBroadcastManager.getInstance(context)
.registerReceiver(receiver, intentFilter(TetheringService.ACTION_ACTIVE_INTERFACES_CHANGED))
receiverRegistered = true
}
val context = context!!
context.registerReceiver(receiver, intentFilter(NetUtils.ACTION_TETHER_STATE_CHANGED))
LocalBroadcastManager.getInstance(context)
.registerReceiver(receiver, intentFilter(TetheringService.ACTION_ACTIVE_INTERFACES_CHANGED))
}
override fun onStop() {
if (receiverRegistered) {
val context = context!!
context.unregisterReceiver(receiver)
LocalBroadcastManager.getInstance(context).unregisterReceiver(receiver)
receiverRegistered = false
}
val context = context!!
context.unregisterReceiver(receiver)
LocalBroadcastManager.getInstance(context).unregisterReceiver(receiver)
super.onStop()
}

View File

@@ -9,7 +9,6 @@ import android.support.annotation.DrawableRes
import android.util.Log
import android.widget.ImageView
import java.io.IOException
import java.io.InputStream
fun debugLog(tag: String?, message: String?) {
if (BuildConfig.DEBUG) Log.d(tag, message)
@@ -30,25 +29,23 @@ fun setImageResource(imageView: ImageView, @DrawableRes resource: Int) = imageVi
private const val NOISYSU_TAG = "NoisySU"
private const val NOISYSU_SUFFIX = "SUCCESS\n"
fun loggerSuStream(command: String): InputStream {
fun loggerSu(command: String): String? {
val process = ProcessBuilder("su", "-c", command)
.redirectErrorStream(true)
.start()
process.waitFor()
val err = try {
process.errorStream.bufferedReader().use { it.readText() }
try {
val err = process.errorStream.bufferedReader().readText()
if (err.isNotBlank()) Log.e(NOISYSU_TAG, err)
} catch (e: IOException) {
e.printStackTrace()
}
return try {
process.inputStream.bufferedReader().readText()
} catch (e: IOException) {
e.printStackTrace()
null
}
if (!err.isNullOrBlank()) Log.e(NOISYSU_TAG, err)
return process.inputStream
}
fun loggerSu(command: String): String? = try {
loggerSuStream(command).bufferedReader().use { it.readText() }
} catch (e: IOException) {
e.printStackTrace()
null
}
fun noisySu(commands: Iterable<String>): Boolean {
var out = loggerSu("""function noisy() { "$@" || echo "$@" exited with $?; }

View File

@@ -0,0 +1,43 @@
package be.mygod.vpnhotspot.net
import android.util.Log
import be.mygod.vpnhotspot.debugLog
data class IpNeighbour(val ip: String, val dev: String, val lladdr: String) {
enum class State {
INCOMPLETE, VALID, VALID_DELAY, FAILED, DELETING
}
companion object {
private const val TAG = "IpNeighbour"
/**
* Parser based on:
* https://android.googlesource.com/platform/external/iproute2/+/ad0a6a2/ip/ipneigh.c#194
* https://people.cs.clemson.edu/~westall/853/notes/arpstate.pdf
* Assumptions: IPv4 only, RTM_GETNEIGH is never used and show_stats = 0
*/
private val parser =
"^(Deleted )?((.+?) )?(dev (.+?) )?(lladdr (.+?))?( proxy)?( ([INCOMPLET,RAHBSDYF]+))?\$".toRegex()
fun parse(line: String): Pair<IpNeighbour, State>? {
val match = parser.matchEntire(line)
if (match == null) {
if (!line.isBlank()) Log.w(TAG, line)
return null
}
val neighbour = IpNeighbour(match.groupValues[3], match.groupValues[5], match.groupValues[7])
val state = if (match.groupValues[1].isNotEmpty()) State.DELETING else when (match.groupValues[10]) {
"", "INCOMPLETE" -> State.INCOMPLETE
"REACHABLE", "STALE", "PROBE", "PERMANENT" -> State.VALID
"DELAY" -> State.VALID_DELAY
"FAILED" -> State.FAILED
"NOARP" -> return null // skip
else -> {
Log.w(TAG, "Unknown state encountered: ${match.groupValues[10]}")
return null
}
}
return Pair(neighbour, state)
}
}
}

View File

@@ -0,0 +1,114 @@
package be.mygod.vpnhotspot.net
import android.os.Build
import android.os.Handler
import android.util.Log
import be.mygod.vpnhotspot.debugLog
import java.io.InterruptedIOException
class IpNeighbourMonitor private constructor() {
companion object {
private const val TAG = "IpNeighbourMonitor"
private val callbacks = HashSet<Callback>()
var instance: IpNeighbourMonitor? = null
fun registerCallback(callback: Callback) {
if (!callbacks.add(callback)) return
var monitor = instance
if (monitor == null) {
monitor = IpNeighbourMonitor()
instance = monitor
monitor.flush()
} else synchronized(monitor.neighbours) { callback.onIpNeighbourAvailable(monitor.neighbours) }
}
fun unregisterCallback(callback: Callback) {
if (!callbacks.remove(callback) || callbacks.isNotEmpty()) return
val monitor = instance ?: return
instance = null
monitor.monitor?.destroy()
}
/**
* Wrapper for kotlin.concurrent.thread that silences uncaught exceptions.
*/
private fun thread(name: String? = null, start: Boolean = true, isDaemon: Boolean = false,
contextClassLoader: ClassLoader? = null, priority: Int = -1, block: () -> Unit): Thread {
val thread = kotlin.concurrent.thread(false, isDaemon, contextClassLoader, name, priority, block)
thread.setUncaughtExceptionHandler { _, _ -> }
if (start) thread.start()
return thread
}
}
interface Callback {
fun onIpNeighbourAvailable(neighbours: Map<IpNeighbour, IpNeighbour.State>)
fun postIpNeighbourAvailable() { }
}
private val handler = Handler()
private var updatePosted = false
val neighbours = HashMap<IpNeighbour, IpNeighbour.State>()
/**
* Using monitor requires using /proc/self/ns/net which would be problematic on Android 6.0+.
*
* Source: https://source.android.com/security/enhancements/enhancements60
*/
private var monitor: Process? = null
init {
thread(name = TAG + "-input") {
val monitor = (if (Build.VERSION.SDK_INT >= 23)
ProcessBuilder("su", "-c", "ip", "-4", "monitor", "neigh") else
ProcessBuilder("ip", "-4", "monitor", "neigh"))
.redirectErrorStream(true)
.start()
this.monitor = monitor
thread(name = TAG + "-error") {
try {
monitor.errorStream.bufferedReader().forEachLine { Log.e(TAG, it) }
} catch (ignore: InterruptedIOException) { }
}
try {
monitor.inputStream.bufferedReader().forEachLine {
debugLog(TAG, it)
synchronized(neighbours) {
val (neighbour, state) = IpNeighbour.parse(it) ?: return@forEachLine
val changed = if (state == IpNeighbour.State.DELETING) neighbours.remove(neighbour) != null else
neighbours.put(neighbour, state) != state
if (changed) postUpdateLocked()
}
}
Log.w(TAG, if (Build.VERSION.SDK_INT >= 26 && monitor.isAlive) "monitor closed stdout" else
"monitor died unexpectedly")
} catch (ignore: InterruptedIOException) { }
}
}
fun flush() = thread(name = TAG + "-flush") {
val process = ProcessBuilder("ip", "-4", "neigh")
.redirectErrorStream(true)
.start()
process.waitFor()
val err = process.errorStream.bufferedReader().readText()
if (err.isNotBlank()) Log.e(TAG, err)
process.inputStream.bufferedReader().useLines {
synchronized(neighbours) {
neighbours.clear()
neighbours.putAll(it.map(IpNeighbour.Companion::parse).filterNotNull().toMap())
postUpdateLocked()
}
}
}
private fun postUpdateLocked() {
if (updatePosted || instance != this) return
handler.post {
synchronized(neighbours) {
for (callback in callbacks) callback.onIpNeighbourAvailable(neighbours)
updatePosted = false
}
for (callback in callbacks) callback.postIpNeighbourAvailable()
}
updatePosted = true
}
}

View File

@@ -0,0 +1,43 @@
package be.mygod.vpnhotspot.net
import android.content.res.Resources
import be.mygod.vpnhotspot.App
import be.mygod.vpnhotspot.R
enum class TetherType {
NONE, WIFI_P2P, USB, WIFI, WIMAX, BLUETOOTH;
val icon get() = when (this) {
USB -> R.drawable.ic_device_usb
WIFI_P2P, WIFI, WIMAX -> R.drawable.ic_device_network_wifi
BLUETOOTH -> R.drawable.ic_device_bluetooth
else -> R.drawable.ic_device_wifi_tethering
}
companion object {
/**
* Source: https://android.googlesource.com/platform/frameworks/base/+/61fa313/core/res/res/values/config.xml#328
*/
private val usbRegexes = App.app.resources.getStringArray(Resources.getSystem()
.getIdentifier("config_tether_usb_regexs", "array", "android"))
.map { it.toPattern() }
private val wifiRegexes = App.app.resources.getStringArray(Resources.getSystem()
.getIdentifier("config_tether_wifi_regexs", "array", "android"))
.map { it.toPattern() }
private val wimaxRegexes = App.app.resources.getStringArray(Resources.getSystem()
.getIdentifier("config_tether_wimax_regexs", "array", "android"))
.map { it.toPattern() }
private val bluetoothRegexes = App.app.resources.getStringArray(Resources.getSystem()
.getIdentifier("config_tether_bluetooth_regexs", "array", "android"))
.map { it.toPattern() }
fun ofInterface(iface: String, p2pDev: String? = null) = when {
iface == p2pDev -> WIFI_P2P
usbRegexes.any { it.matcher(iface).matches() } -> USB
wifiRegexes.any { it.matcher(iface).matches() } -> WIFI
wimaxRegexes.any { it.matcher(iface).matches() } -> WIMAX
bluetoothRegexes.any { it.matcher(iface).matches() } -> BLUETOOTH
else -> NONE
}
}
}

View File

@@ -102,10 +102,6 @@
android:id="@+id/clients"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingBottom="8dp"
android:paddingEnd="16dp"
android:paddingStart="16dp"
android:paddingTop="8dp"
android:clipToPadding="false"
android:scrollbars="vertical"
tools:listitem="@layout/listitem_client"/>

View File

@@ -3,33 +3,51 @@
xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<import type="android.view.View"/>
<variable
name="device"
type="android.net.wifi.p2p.WifiP2pDevice"/>
<variable
name="ipAddress"
type="String"/>
name="client"
type="be.mygod.vpnhotspot.RepeaterFragment.Client"/>
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
android:focusable="true"
android:background="?android:attr/selectableItemBackground"
android:paddingBottom="4dp"
android:paddingEnd="16dp"
android:paddingStart="16dp"
android:paddingTop="4dp">
<TextView
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:src="@{client.icon}"
android:tint="?android:attr/textColorPrimary"
tools:src="@drawable/ic_device_network_wifi"/>
<Space
android:layout_width="16dp"
android:layout_height="0dp"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@{device.deviceAddress}"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead"
android:textIsSelectable="true"
tools:text="xx:xx:xx:xx:xx:xx"/>
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="@{ipAddress == null ? View.GONE : View.VISIBLE}"
android:text="@{ipAddress}"
android:textIsSelectable="true"
tools:text="192.168.49.123"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@{client.title}"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead"
android:textIsSelectable="true"
tools:text="192.168.49.123, 01:23:45:ab:cd:ef"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@{client.description}"
tools:text="Connected to p2p0"/>
</LinearLayout>
</LinearLayout>
</layout>

View File

@@ -25,7 +25,7 @@
tools:src="@drawable/ic_device_network_wifi"/>
<Space
android:layout_width="8dp"
android:layout_width="16dp"
android:layout_height="0dp"/>
<Switch