diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterFragment.kt b/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterFragment.kt index 6038a22c..ecca02d3 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterFragment.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterFragment.kt @@ -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() { - private var owner: WifiP2pDevice? = null - private lateinit var clients: Collection - private lateinit var arpCache: Map + inner class Client(p2p: WifiP2pDevice? = null, + private val pair: Map.Entry? = 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() { + private val clients = ArrayList() + var p2p: Collection = emptyList() + var neighbours = emptyMap() + + 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 { 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() + 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) { + adapter.neighbours = neighbours.toMap() + } + override fun postIpNeighbourAvailable() = 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 536e7a90..f629a907 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt @@ -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") } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/SettingsPreferenceFragment.kt b/mobile/src/main/java/be/mygod/vpnhotspot/SettingsPreferenceFragment.kt index 5c19e5b8..c0f37098 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/SettingsPreferenceFragment.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/SettingsPreferenceFragment.kt @@ -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() diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/TetheringFragment.kt b/mobile/src/main/java/be/mygod/vpnhotspot/TetheringFragment.kt index 65737815..9002c935 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/TetheringFragment.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/TetheringFragment.kt @@ -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 : SortedList.Callback() { 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() 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() } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/Utils.kt b/mobile/src/main/java/be/mygod/vpnhotspot/Utils.kt index ca339191..2d74cc42 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/Utils.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/Utils.kt @@ -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): Boolean { var out = loggerSu("""function noisy() { "$@" || echo "$@" exited with $?; } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbour.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbour.kt new file mode 100644 index 00000000..5073bb17 --- /dev/null +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbour.kt @@ -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? { + 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) + } + } +} diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbourMonitor.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbourMonitor.kt new file mode 100644 index 00000000..16e5d08f --- /dev/null +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbourMonitor.kt @@ -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() + 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) + fun postIpNeighbourAvailable() { } + } + + private val handler = Handler() + private var updatePosted = false + val neighbours = HashMap() + /** + * 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 + } +} diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/TetherType.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/TetherType.kt new file mode 100644 index 00000000..02768228 --- /dev/null +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/TetherType.kt @@ -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 + } + } +} diff --git a/mobile/src/main/res/layout/fragment_repeater.xml b/mobile/src/main/res/layout/fragment_repeater.xml index 4619967c..24f99a7d 100644 --- a/mobile/src/main/res/layout/fragment_repeater.xml +++ b/mobile/src/main/res/layout/fragment_repeater.xml @@ -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"/> diff --git a/mobile/src/main/res/layout/listitem_client.xml b/mobile/src/main/res/layout/listitem_client.xml index 00e71e85..055f85c8 100644 --- a/mobile/src/main/res/layout/listitem_client.xml +++ b/mobile/src/main/res/layout/listitem_client.xml @@ -3,33 +3,51 @@ xmlns:tools="http://schemas.android.com/tools" xmlns:android="http://schemas.android.com/apk/res/android"> - - + name="client" + type="be.mygod.vpnhotspot.RepeaterFragment.Client"/> + + android:focusable="true" + android:background="?android:attr/selectableItemBackground" + android:paddingBottom="4dp" + android:paddingEnd="16dp" + android:paddingStart="16dp" + android:paddingTop="4dp"> - + + + + + android:orientation="vertical"> - + + + + diff --git a/mobile/src/main/res/layout/listitem_interface.xml b/mobile/src/main/res/layout/listitem_interface.xml index 544548a8..cfdd37d2 100644 --- a/mobile/src/main/res/layout/listitem_interface.xml +++ b/mobile/src/main/res/layout/listitem_interface.xml @@ -25,7 +25,7 @@ tools:src="@drawable/ic_device_network_wifi"/>