diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml
index 2cf697ed..f3a697bd 100644
--- a/mobile/src/main/AndroidManifest.xml
+++ b/mobile/src/main/AndroidManifest.xml
@@ -3,7 +3,10 @@
package="be.mygod.vpnhotspot">
-
+
+
@@ -17,8 +20,8 @@
android:theme="@style/AppTheme">
+ android:label="@string/app_name"
+ android:launchMode="singleInstance">
@@ -26,13 +29,10 @@
-
+
+
+
-
-
\ No newline at end of file
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/App.kt b/mobile/src/main/java/be/mygod/vpnhotspot/App.kt
index 2dbc1529..db59715b 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/App.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/App.kt
@@ -4,10 +4,8 @@ import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.SharedPreferences
-import android.content.res.Resources
import android.os.Build
import android.preference.PreferenceManager
-import java.net.NetworkInterface
class App : Application() {
companion object {
@@ -18,21 +16,10 @@ class App : Application() {
super.onCreate()
app = this
if (Build.VERSION.SDK_INT >= 26) getSystemService(NotificationManager::class.java)
- .createNotificationChannel(NotificationChannel(HotspotService.CHANNEL,
+ .createNotificationChannel(NotificationChannel(RepeaterService.CHANNEL,
"Hotspot Service", NotificationManager.IMPORTANCE_LOW))
- pref = PreferenceManager.getDefaultSharedPreferences(this)
- val wifiRegexes = resources.getStringArray(Resources.getSystem()
- .getIdentifier("config_tether_wifi_regexs", "array", "android"))
- .map { it.toPattern() }
- wifiInterfaces = NetworkInterface.getNetworkInterfaces().asSequence()
- .map { it.name }
- .filter { ifname -> wifiRegexes.any { it.matcher(ifname).matches() } }
- .sorted().toList().toTypedArray()
- val wifiInterface = wifiInterfaces.singleOrNull()
- if (wifiInterface != null && pref.getString(HotspotService.KEY_WIFI, null) == null)
- pref.edit().putString(HotspotService.KEY_WIFI, wifiInterface).apply()
}
- lateinit var pref: SharedPreferences
- lateinit var wifiInterfaces: Array
+ val pref: SharedPreferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) }
+ val dns: String get() = app.pref.getString("service.dns", "8.8.8.8:53")
}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/DataBindingAdapters.kt b/mobile/src/main/java/be/mygod/vpnhotspot/DataBindingAdapters.kt
new file mode 100644
index 00000000..ce51fc92
--- /dev/null
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/DataBindingAdapters.kt
@@ -0,0 +1,11 @@
+package be.mygod.vpnhotspot
+
+import android.databinding.BindingAdapter
+import android.support.annotation.DrawableRes
+import android.widget.ImageView
+
+object DataBindingAdapters {
+ @JvmStatic
+ @BindingAdapter("android:src")
+ fun setImageResource(imageView: ImageView, @DrawableRes resource: Int) = imageView.setImageResource(resource)
+}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/HotspotService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/HotspotService.kt
deleted file mode 100644
index 3a193503..00000000
--- a/mobile/src/main/java/be/mygod/vpnhotspot/HotspotService.kt
+++ /dev/null
@@ -1,360 +0,0 @@
-package be.mygod.vpnhotspot
-
-import android.app.PendingIntent
-import android.app.Service
-import android.content.Context
-import android.content.Intent
-import android.net.*
-import android.net.wifi.WifiConfiguration
-import android.net.wifi.WifiManager
-import android.net.wifi.p2p.WifiP2pGroup
-import android.net.wifi.p2p.WifiP2pInfo
-import android.net.wifi.p2p.WifiP2pManager
-import android.os.Binder
-import android.os.Looper
-import android.support.v4.app.NotificationCompat
-import android.support.v4.content.ContextCompat
-import android.support.v4.content.LocalBroadcastManager
-import android.util.Log
-import android.widget.Toast
-import be.mygod.vpnhotspot.App.Companion.app
-import java.net.InetAddress
-import java.util.regex.Pattern
-
-class HotspotService : Service(), WifiP2pManager.ChannelListener {
- companion object {
- const val CHANNEL = "hotspot"
- const val STATUS_CHANGED = "be.mygod.vpnhotspot.HotspotService.STATUS_CHANGED"
- const val KEY_UPSTREAM = "service.upstream"
- const val KEY_WIFI = "service.wifi"
- private const val TAG = "HotspotService"
- // constants from WifiManager
- private const val WIFI_AP_STATE_CHANGED_ACTION = "android.net.wifi.WIFI_AP_STATE_CHANGED"
- private const val WIFI_AP_STATE_ENABLED = 13
-
- private val upstream get() = app.pref.getString(KEY_UPSTREAM, "tun0")
- private val wifi get() = app.pref.getString(KEY_WIFI, "wlan0")
- private val dns get() = app.pref.getString("service.dns", "8.8.8.8:53")
-
- /**
- * Matches the output of dumpsys wifip2p. This part is available since Android 4.2.
- *
- * Related sources:
- * https://android.googlesource.com/platform/frameworks/base/+/f0afe4144d09aa9b980cffd444911ab118fa9cbe%5E%21/wifi/java/android/net/wifi/p2p/WifiP2pService.java
- * https://android.googlesource.com/platform/frameworks/opt/net/wifi/+/a8d5e40/service/java/com/android/server/wifi/p2p/WifiP2pServiceImpl.java#639
- *
- * https://android.googlesource.com/platform/frameworks/base.git/+/android-5.0.0_r1/core/java/android/net/NetworkInfo.java#433
- * https://android.googlesource.com/platform/frameworks/base.git/+/220871a/core/java/android/net/NetworkInfo.java#415
- */
- private val patternNetworkInfo = "^mNetworkInfo .* (isA|a)vailable: (true|false)".toPattern(Pattern.MULTILINE)
-
- private val isWifiApEnabledMethod = WifiManager::class.java.getDeclaredMethod("isWifiApEnabled")
- val WifiManager.isWifiApEnabled get() = isWifiApEnabledMethod.invoke(this) as Boolean
-
- private val request by lazy {
- /* We don't know how to specify the interface we're interested in, so we will listen for everything.
- * However, we need to remove all default capabilities defined in NetworkCapabilities constructor.
- * Also this unfortunately doesn't work for P2P/AP connectivity changes.
- */
- NetworkRequest.Builder()
- .removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
- .removeCapability(NetworkCapabilities.NET_CAPABILITY_TRUSTED)
- .removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
- .build()
- }
-
- init {
- isWifiApEnabledMethod.isAccessible = true
- }
- }
-
- enum class Status {
- IDLE, STARTING, ACTIVE_P2P, ACTIVE_AP
- }
-
- inner class HotspotBinder : Binder() {
- val service get() = this@HotspotService
- var data: MainActivity.Data? = null
-
- fun shutdown() = when (status) {
- Status.ACTIVE_P2P -> removeGroup()
- else -> clean()
- }
-
- fun reapplyRouting() {
- val routing = routing
- routing?.stop()
- try {
- if (!when (status) {
- Status.ACTIVE_P2P -> initP2pRouting(routing!!.downstream, routing.hostAddress)
- Status.ACTIVE_AP -> initApRouting(routing!!.hostAddress)
- else -> false
- }) Toast.makeText(this@HotspotService, "Something went wrong, please check logcat.",
- Toast.LENGTH_SHORT).show()
- } catch (e: Exception) {
- Toast.makeText(this@HotspotService, e.message, Toast.LENGTH_SHORT).show()
- return
- }
- }
- }
-
- private val connectivityManager by lazy { getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager }
- private val wifiManager by lazy { getSystemService(Context.WIFI_SERVICE) as WifiManager }
- private val p2pManager by lazy { getSystemService(Context.WIFI_P2P_SERVICE) as WifiP2pManager }
- private var _channel: WifiP2pManager.Channel? = null
- private val channel: WifiP2pManager.Channel get() {
- if (_channel == null) onChannelDisconnected()
- return _channel!!
- }
- lateinit var group: WifiP2pGroup
- private set
- private var apConfiguration: WifiConfiguration? = null
- private val binder = HotspotBinder()
- private var receiverRegistered = false
- private val receiver = broadcastReceiver { _, intent ->
- when (intent.action) {
- WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION ->
- if (intent.getIntExtra(WifiP2pManager.EXTRA_WIFI_STATE, 0) ==
- WifiP2pManager.WIFI_P2P_STATE_DISABLED) clean() // ignore P2P enabled
- WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> {
- onP2pConnectionChanged(intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_INFO),
- intent.getParcelableExtra(WifiP2pManager.EXTRA_NETWORK_INFO),
- intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_GROUP))
- }
- WIFI_AP_STATE_CHANGED_ACTION ->
- if (intent.getIntExtra(WifiManager.EXTRA_WIFI_STATE, 0) != WIFI_AP_STATE_ENABLED) clean()
- }
- }
- private var netListenerRegistered = false
- private val netListener = object : ConnectivityManager.NetworkCallback() {
- /**
- * Obtaining ifname in onLost doesn't work so we need to cache it in onAvailable.
- */
- private val ifnameCache = HashMap()
- private val Network.ifname: String? get() {
- var result = ifnameCache[this]
- if (result == null) {
- result = connectivityManager.getLinkProperties(this)?.interfaceName
- if (result != null) ifnameCache.put(this, result)
- }
- return result
- }
-
- override fun onAvailable(network: Network?) {
- val routing = routing ?: return
- val ifname = network?.ifname
- debugLog(TAG, "onAvailable: $ifname")
- if (ifname == routing.upstream) routing.start()
- }
-
- override fun onLost(network: Network?) {
- val routing = routing ?: return
- val ifname = network?.ifname
- debugLog(TAG, "onLost: $ifname")
- when (ifname) {
- routing.downstream -> clean()
- routing.upstream -> routing.stop()
- }
- }
- }
-
- val ssid get() = when (status) {
- HotspotService.Status.ACTIVE_P2P -> group.networkName
- HotspotService.Status.ACTIVE_AP -> apConfiguration?.SSID ?: "Unknown"
- else -> null
- }
- val password get() = when (status) {
- HotspotService.Status.ACTIVE_P2P -> group.passphrase
- HotspotService.Status.ACTIVE_AP -> apConfiguration?.preSharedKey
- else -> null
- }
-
- var routing: Routing? = null
- private set
-
- var status = Status.IDLE
- private set(value) {
- if (field == value) return
- field = value
- LocalBroadcastManager.getInstance(this).sendBroadcast(Intent(STATUS_CHANGED))
- }
-
- private fun formatReason(reason: Int) = when (reason) {
- WifiP2pManager.ERROR -> "ERROR"
- WifiP2pManager.P2P_UNSUPPORTED -> "P2P_UNSUPPORTED"
- WifiP2pManager.BUSY -> "BUSY"
- WifiP2pManager.NO_SERVICE_REQUESTS -> "NO_SERVICE_REQUESTS"
- else -> "unknown reason: $reason"
- }
-
- override fun onBind(intent: Intent) = binder
-
- override fun onChannelDisconnected() {
- _channel = p2pManager.initialize(this, Looper.getMainLooper(), this)
- }
-
- override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
- if (status != Status.IDLE) return START_NOT_STICKY
- status = Status.STARTING
- val matcher = patternNetworkInfo.matcher(loggerSu("dumpsys ${Context.WIFI_P2P_SERVICE}"))
- when {
- !matcher.find() -> startFailure("Root unavailable")
- matcher.group(2) == "true" -> {
- unregisterReceiver()
- registerReceiver(receiver, intentFilter(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION,
- WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION))
- receiverRegistered = true
- p2pManager.requestGroupInfo(channel, {
- when {
- it == null -> doStart()
- it.isGroupOwner -> doStart(it)
- else -> {
- Log.i(TAG, "Removing old group ($it)")
- p2pManager.removeGroup(channel, object : WifiP2pManager.ActionListener {
- override fun onSuccess() = doStart()
- override fun onFailure(reason: Int) {
- Toast.makeText(this@HotspotService,
- "Failed to remove old P2P group (${formatReason(reason)})",
- Toast.LENGTH_SHORT).show()
- }
- })
- }
- }
- })
- }
- wifiManager.isWifiApEnabled -> {
- unregisterReceiver()
- registerReceiver(receiver, intentFilter(WIFI_AP_STATE_CHANGED_ACTION))
- receiverRegistered = true
- try {
- if (initApRouting()) {
- connectivityManager.registerNetworkCallback(request, netListener)
- netListenerRegistered = true
- apConfiguration = NetUtils.loadApConfiguration()
- status = Status.ACTIVE_AP
- showNotification()
- } else startFailure("Something went wrong, please check logcat.", group)
- } catch (e: Routing.InterfaceNotFoundException) {
- startFailure(e.message, group)
- }
- }
- else -> startFailure("Wi-Fi direct unavailable and hotspot disabled, please enable either")
- }
- return START_NOT_STICKY
- }
- private fun initApRouting(owner: InetAddress? = null): Boolean {
- val routing = Routing(upstream, wifi, owner).rule().forward().dnsRedirect(dns)
- return if (routing.start()) {
- this.routing = routing
- true
- } else {
- routing.stop()
- this.routing = null
- false
- }
- }
-
- private fun startFailure(msg: String?, group: WifiP2pGroup? = null) {
- Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
- showNotification()
- if (group != null) removeGroup() else clean()
- }
- private fun doStart() = p2pManager.createGroup(channel, object : WifiP2pManager.ActionListener {
- override fun onFailure(reason: Int) = startFailure("Failed to create P2P group (${formatReason(reason)})")
- override fun onSuccess() { } // wait for WIFI_P2P_CONNECTION_CHANGED_ACTION to fire
- })
- private fun doStart(group: WifiP2pGroup) {
- this.group = group
- status = Status.ACTIVE_P2P
- showNotification(group)
- }
- private fun showNotification(group: WifiP2pGroup? = null) {
- val builder = NotificationCompat.Builder(this, CHANNEL)
- .setWhen(0)
- .setColor(ContextCompat.getColor(this, R.color.colorPrimary))
- .setContentTitle(group?.networkName ?: ssid ?: "Connecting...")
- .setSmallIcon(R.drawable.ic_device_wifi_tethering)
- .setContentIntent(PendingIntent.getActivity(this, 0,
- Intent(this, MainActivity::class.java), PendingIntent.FLAG_UPDATE_CURRENT))
- if (group != null) builder.setContentText(resources.getQuantityString(R.plurals.notification_connected_devices,
- group.clientList.size, group.clientList.size))
- startForeground(1, builder.build())
- }
-
- private fun onP2pConnectionChanged(info: WifiP2pInfo, net: NetworkInfo?, group: WifiP2pGroup) {
- if (routing == null) onGroupCreated(info, group) else if (!group.isGroupOwner) { // P2P shutdown
- clean()
- return
- }
- this.group = group
- binder.data?.onGroupChanged()
- showNotification(group)
- Log.d(TAG, "P2P connection changed: $info\n$net\n$group")
- }
- private fun onGroupCreated(info: WifiP2pInfo, group: WifiP2pGroup) {
- val owner = info.groupOwnerAddress
- val downstream = group.`interface`
- if (!info.groupFormed || !info.isGroupOwner || downstream == null || owner == null) return
- receiverRegistered = true
- try {
- if (initP2pRouting(downstream, owner)) {
- connectivityManager.registerNetworkCallback(request, netListener)
- netListenerRegistered = true
- doStart(group)
- } else startFailure("Something went wrong, please check logcat.", group)
- } catch (e: Routing.InterfaceNotFoundException) {
- startFailure(e.message, group)
- return
- }
- }
- private fun initP2pRouting(downstream: String, owner: InetAddress): Boolean {
- val routing = Routing(upstream, downstream, owner)
- .ipForward() // Wi-Fi direct doesn't enable ip_forward
- .rule().forward().dnsRedirect(dns)
- return if (routing.start()) {
- this.routing = routing
- true
- } else {
- routing.stop()
- this.routing = null
- false
- }
- }
-
- private fun removeGroup() {
- p2pManager.removeGroup(channel, object : WifiP2pManager.ActionListener {
- override fun onSuccess() = clean()
- override fun onFailure(reason: Int) {
- if (reason == WifiP2pManager.BUSY) clean() else { // assuming it's already gone
- Toast.makeText(this@HotspotService, "Failed to remove P2P group (${formatReason(reason)})",
- Toast.LENGTH_SHORT).show()
- status = Status.ACTIVE_P2P
- LocalBroadcastManager.getInstance(this@HotspotService).sendBroadcast(Intent(STATUS_CHANGED))
- }
- }
- })
- }
- private fun unregisterReceiver() {
- if (netListenerRegistered) {
- connectivityManager.unregisterNetworkCallback(netListener)
- netListenerRegistered = false
- }
- if (receiverRegistered) {
- unregisterReceiver(receiver)
- receiverRegistered = false
- }
- }
- private fun clean() {
- unregisterReceiver()
- if (routing?.stop() == false)
- Toast.makeText(this, "Something went wrong, please check logcat.", Toast.LENGTH_SHORT).show()
- routing = null
- status = Status.IDLE
- stopForeground(true)
- }
-
- override fun onDestroy() {
- if (status != Status.IDLE) binder.shutdown()
- super.onDestroy()
- }
-}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/MainActivity.kt b/mobile/src/main/java/be/mygod/vpnhotspot/MainActivity.kt
index a647a373..dd030196 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/MainActivity.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/MainActivity.kt
@@ -1,160 +1,42 @@
package be.mygod.vpnhotspot
-import android.content.*
-import android.databinding.BaseObservable
-import android.databinding.Bindable
import android.databinding.DataBindingUtil
-import android.net.wifi.p2p.WifiP2pDevice
import android.os.Bundle
-import android.os.IBinder
-import android.support.v4.content.ContextCompat
-import android.support.v4.content.LocalBroadcastManager
+import android.support.design.widget.BottomNavigationView
+import android.support.v4.app.Fragment
import android.support.v7.app.AppCompatActivity
-import android.support.v7.widget.DefaultItemAnimator
-import android.support.v7.widget.LinearLayoutManager
-import android.support.v7.widget.RecyclerView
-import android.support.v7.widget.Toolbar
-import android.view.LayoutInflater
import android.view.MenuItem
-import android.view.ViewGroup
import be.mygod.vpnhotspot.databinding.ActivityMainBinding
-import be.mygod.vpnhotspot.databinding.ListitemClientBinding
-
-class MainActivity : AppCompatActivity(), ServiceConnection, Toolbar.OnMenuItemClickListener {
- inner class Data : BaseObservable() {
- val switchEnabled: Boolean
- @Bindable get() = when (binder?.service?.status) {
- HotspotService.Status.IDLE, HotspotService.Status.ACTIVE_P2P, HotspotService.Status.ACTIVE_AP -> true
- else -> false
- }
- var serviceStarted: Boolean
- @Bindable get() = when (binder?.service?.status) {
- HotspotService.Status.STARTING, HotspotService.Status.ACTIVE_P2P, HotspotService.Status.ACTIVE_AP ->
- true
- else -> false
- }
- set(value) {
- val binder = binder
- when (binder?.service?.status) {
- HotspotService.Status.IDLE ->
- if (value) ContextCompat.startForegroundService(this@MainActivity,
- Intent(this@MainActivity, HotspotService::class.java))
- HotspotService.Status.ACTIVE_P2P, HotspotService.Status.ACTIVE_AP -> if (!value) binder.shutdown()
- }
- }
-
- val ssid @Bindable get() = binder?.service?.ssid ?: "Service inactive"
- val password @Bindable get() = binder?.service?.password ?: ""
-
- fun onStatusChanged() {
- notifyPropertyChanged(BR.switchEnabled)
- notifyPropertyChanged(BR.serviceStarted)
- onGroupChanged()
- }
- fun onGroupChanged() {
- notifyPropertyChanged(BR.ssid)
- notifyPropertyChanged(BR.password)
- adapter.fetchClients()
- }
-
- 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: MutableCollection
- private lateinit var arpCache: Map
-
- fun fetchClients() {
- val binder = binder
- if (binder?.service?.status == HotspotService.Status.ACTIVE_P2P) {
- owner = binder.service.group.owner
- clients = binder.service.group.clientList
- arpCache = NetUtils.arp(binder.service.routing?.downstream)
- } else owner = null
- notifyDataSetChanged() // recreate everything
- binding.swipeRefresher.isRefreshing = false
- }
-
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
- 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.executePendingBindings()
- }
-
- override fun getItemCount() = if (owner == null) 0 else 1 + clients.size
- }
+class MainActivity : AppCompatActivity(), BottomNavigationView.OnNavigationItemSelectedListener {
private lateinit var binding: ActivityMainBinding
- private val data = Data()
- private val adapter = ClientAdapter()
- private var binder: HotspotService.HotspotBinder? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
- binding.data = data
- binding.clients.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
- val animator = DefaultItemAnimator()
- animator.supportsChangeAnimations = false // prevent fading-in/out when rebinding
- binding.clients.itemAnimator = animator
- binding.clients.adapter = adapter
- binding.toolbar.inflateMenu(R.menu.main)
- binding.toolbar.setOnMenuItemClickListener(this)
- binding.swipeRefresher.setOnRefreshListener { adapter.fetchClients() }
+ binding.navigation.setOnNavigationItemSelectedListener(this)
+ onNavigationItemSelected(binding.navigation.menu.getItem(0))
}
- override fun onMenuItemClick(item: MenuItem): Boolean = when (item.itemId) {
- R.id.reapply -> {
- val binder = binder
- when (binder?.service?.status) {
- HotspotService.Status.IDLE -> Routing.clean()
- HotspotService.Status.ACTIVE_P2P, HotspotService.Status.ACTIVE_AP -> binder.reapplyRouting()
- }
+ override fun onNavigationItemSelected(item: MenuItem) = when (item.itemId) {
+ R.id.navigation_repeater -> {
+ item.isChecked = true
+ displayFragment(RepeaterFragment())
true
}
- R.id.settings -> {
- startActivity(Intent(this, SettingsActivity::class.java))
+ R.id.navigation_tethering -> {
+ item.isChecked = true
+ displayFragment(TetheringFragment())
+ true
+ }
+ R.id.navigation_settings -> {
+ item.isChecked = true
+ displayFragment(SettingsFragment())
true
}
else -> false
}
- override fun onStart() {
- super.onStart()
- bindService(Intent(this, HotspotService::class.java), this, Context.BIND_AUTO_CREATE)
- }
-
- override fun onStop() {
- onServiceDisconnected(null)
- unbindService(this)
- super.onStop()
- }
-
- override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
- val binder = service as HotspotService.HotspotBinder
- binder.data = data
- this.binder = binder
- data.onStatusChanged()
- LocalBroadcastManager.getInstance(this)
- .registerReceiver(data.statusListener, intentFilter(HotspotService.STATUS_CHANGED))
- }
-
- override fun onServiceDisconnected(name: ComponentName?) {
- binder?.data = null
- binder = null
- LocalBroadcastManager.getInstance(this).unregisterReceiver(data.statusListener)
- data.onStatusChanged()
- }
+ private fun displayFragment(fragment: Fragment) =
+ supportFragmentManager.beginTransaction().replace(R.id.fragmentHolder, fragment).commit()
}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/NetUtils.kt b/mobile/src/main/java/be/mygod/vpnhotspot/NetUtils.kt
index 5f28b2e5..f368e20b 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/NetUtils.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/NetUtils.kt
@@ -1,16 +1,20 @@
package be.mygod.vpnhotspot
-import android.net.wifi.WifiConfiguration
-import android.util.Log
-import java.io.DataInputStream
+import android.net.ConnectivityManager
import java.io.File
-import java.io.IOException
object NetUtils {
- private const val TAG = "NetUtils"
+ // hidden constants from ConnectivityManager
+ const val ACTION_TETHER_STATE_CHANGED = "android.net.conn.TETHER_STATE_CHANGED"
+ const val EXTRA_ACTIVE_TETHER = "tetherArray"
+
private val spaces = " +".toPattern()
private val mac = "^([0-9a-f]{2}:){5}[0-9a-f]{2}$".toPattern()
+ private val getTetheredIfaces = ConnectivityManager::class.java.getDeclaredMethod("getTetheredIfaces")
+ @Suppress("UNCHECKED_CAST")
+ val ConnectivityManager.tetheredIfaces get() = getTetheredIfaces.invoke(this) as Array
+
fun arp(iface: String? = null) = File("/proc/net/arp").bufferedReader().useLines {
// IP address HW type Flags HW address Mask Device
it.map { it.split(spaces) }
@@ -19,34 +23,4 @@ object NetUtils {
mac.matcher(it[3]).matches() }
.associateBy({ it[3] }, { it[0] })
}
-
- /**
- * Load AP configuration from persistent storage.
- *
- * Based on: https://android.googlesource.com/platform/frameworks/opt/net/wifi/+/0cafbe0/service/java/com/android/server/wifi/WifiApConfigStore.java#138
- */
- fun loadApConfiguration(): WifiConfiguration? = try {
- loggerSuStream("cat /data/misc/wifi/softap.conf").buffered().use {
- val data = DataInputStream(it)
- val version = data.readInt()
- when (version) {
- 1, 2 -> {
- val config = WifiConfiguration()
- config.SSID = data.readUTF()
- if (version >= 2) data.readLong() // apBand and apChannel
- val authType = data.readInt()
- config.allowedKeyManagement.set(authType)
- if (authType != WifiConfiguration.KeyMgmt.NONE) config.preSharedKey = data.readUTF()
- config
- }
- else -> {
- Log.e(TAG, "Bad version on hotspot configuration file $version")
- null
- }
- }
- }
- } catch (e: IOException) {
- Log.e(TAG, "Error reading hotspot configuration $e")
- null
- }
}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterFragment.kt b/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterFragment.kt
new file mode 100644
index 00000000..26406260
--- /dev/null
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterFragment.kt
@@ -0,0 +1,146 @@
+package be.mygod.vpnhotspot
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.ServiceConnection
+import android.databinding.BaseObservable
+import android.databinding.Bindable
+import android.databinding.DataBindingUtil
+import android.net.wifi.p2p.WifiP2pDevice
+import android.os.Bundle
+import android.os.IBinder
+import android.support.v4.app.Fragment
+import android.support.v4.content.ContextCompat
+import android.support.v4.content.LocalBroadcastManager
+import android.support.v7.widget.DefaultItemAnimator
+import android.support.v7.widget.LinearLayoutManager
+import android.support.v7.widget.RecyclerView
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import be.mygod.vpnhotspot.databinding.FragmentRepeaterBinding
+import be.mygod.vpnhotspot.databinding.ListitemClientBinding
+
+class RepeaterFragment : Fragment(), ServiceConnection {
+ inner class Data : BaseObservable() {
+ val switchEnabled: Boolean
+ @Bindable get() = when (binder?.service?.status) {
+ RepeaterService.Status.IDLE, RepeaterService.Status.ACTIVE -> true
+ else -> false
+ }
+ var serviceStarted: Boolean
+ @Bindable get() = when (binder?.service?.status) {
+ RepeaterService.Status.STARTING, RepeaterService.Status.ACTIVE -> true
+ else -> false
+ }
+ set(value) {
+ val binder = binder
+ when (binder?.service?.status) {
+ RepeaterService.Status.IDLE ->
+ if (value) {
+ val context = context!!
+ ContextCompat.startForegroundService(context, Intent(context, RepeaterService::class.java))
+ }
+ RepeaterService.Status.ACTIVE -> if (!value) binder.shutdown()
+ }
+ }
+
+ val ssid @Bindable get() = binder?.service?.ssid ?: "Service inactive"
+ val password @Bindable get() = binder?.service?.password ?: ""
+
+ fun onStatusChanged() {
+ notifyPropertyChanged(BR.switchEnabled)
+ notifyPropertyChanged(BR.serviceStarted)
+ onGroupChanged()
+ }
+ fun onGroupChanged() {
+ notifyPropertyChanged(BR.ssid)
+ notifyPropertyChanged(BR.password)
+ adapter.fetchClients()
+ }
+
+ 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: MutableCollection
+ private lateinit var arpCache: Map
+
+ fun fetchClients() {
+ val binder = binder
+ if (binder?.service?.status == RepeaterService.Status.ACTIVE) {
+ owner = binder.service.group.owner
+ clients = binder.service.group.clientList
+ arpCache = NetUtils.arp(binder.service.routing?.downstream)
+ } else owner = null
+ notifyDataSetChanged() // recreate everything
+ binding.swipeRefresher.isRefreshing = false
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
+ 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.executePendingBindings()
+ }
+
+ override fun getItemCount() = if (owner == null) 0 else 1 + clients.size
+ }
+
+ private lateinit var binding: FragmentRepeaterBinding
+ private val data = Data()
+ private val adapter = ClientAdapter()
+ private var binder: RepeaterService.HotspotBinder? = null
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+ binding = DataBindingUtil.inflate(inflater, R.layout.fragment_repeater, container, false)
+ binding.data = data
+ binding.clients.layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
+ val animator = DefaultItemAnimator()
+ animator.supportsChangeAnimations = false // prevent fading-in/out when rebinding
+ binding.clients.itemAnimator = animator
+ binding.clients.adapter = adapter
+ binding.swipeRefresher.setOnRefreshListener { adapter.fetchClients() }
+ return binding.root
+ }
+
+ override fun onStart() {
+ super.onStart()
+ val context = context!!
+ context.bindService(Intent(context, RepeaterService::class.java), this, Context.BIND_AUTO_CREATE)
+ }
+
+ override fun onStop() {
+ onServiceDisconnected(null)
+ context!!.unbindService(this)
+ super.onStop()
+ }
+
+ override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
+ val binder = service as RepeaterService.HotspotBinder
+ binder.data = data
+ this.binder = binder
+ data.onStatusChanged()
+ LocalBroadcastManager.getInstance(context!!)
+ .registerReceiver(data.statusListener, intentFilter(RepeaterService.STATUS_CHANGED))
+ }
+
+ override fun onServiceDisconnected(name: ComponentName?) {
+ binder?.data = null
+ binder = null
+ LocalBroadcastManager.getInstance(context!!).unregisterReceiver(data.statusListener)
+ data.onStatusChanged()
+ }
+}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt
new file mode 100644
index 00000000..8bbae49f
--- /dev/null
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt
@@ -0,0 +1,270 @@
+package be.mygod.vpnhotspot
+
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.net.NetworkInfo
+import android.net.wifi.p2p.WifiP2pGroup
+import android.net.wifi.p2p.WifiP2pInfo
+import android.net.wifi.p2p.WifiP2pManager
+import android.os.Binder
+import android.os.Handler
+import android.os.Looper
+import android.support.v4.app.NotificationCompat
+import android.support.v4.content.ContextCompat
+import android.support.v4.content.LocalBroadcastManager
+import android.util.Log
+import android.widget.Toast
+import be.mygod.vpnhotspot.App.Companion.app
+import java.net.InetAddress
+import java.util.regex.Pattern
+
+class RepeaterService : Service(), WifiP2pManager.ChannelListener, VpnListener.Callback {
+ companion object {
+ const val CHANNEL = "hotspot"
+ const val STATUS_CHANGED = "be.mygod.vpnhotspot.RepeaterService.STATUS_CHANGED"
+ private const val TAG = "RepeaterService"
+
+ /**
+ * Matches the output of dumpsys wifip2p. This part is available since Android 4.2.
+ *
+ * Related sources:
+ * https://android.googlesource.com/platform/frameworks/base/+/f0afe4144d09aa9b980cffd444911ab118fa9cbe%5E%21/wifi/java/android/net/wifi/p2p/WifiP2pService.java
+ * https://android.googlesource.com/platform/frameworks/opt/net/wifi/+/a8d5e40/service/java/com/android/server/wifi/p2p/WifiP2pServiceImpl.java#639
+ *
+ * https://android.googlesource.com/platform/frameworks/base.git/+/android-5.0.0_r1/core/java/android/net/NetworkInfo.java#433
+ * https://android.googlesource.com/platform/frameworks/base.git/+/220871a/core/java/android/net/NetworkInfo.java#415
+ */
+ private val patternNetworkInfo = "^mNetworkInfo .* (isA|a)vailable: (true|false)".toPattern(Pattern.MULTILINE)
+ }
+
+ enum class Status {
+ IDLE, STARTING, ACTIVE
+ }
+
+ inner class HotspotBinder : Binder() {
+ val service get() = this@RepeaterService
+ var data: RepeaterFragment.Data? = null
+
+ fun shutdown() {
+ if (status == Status.ACTIVE) removeGroup()
+ }
+ }
+
+ private val handler = Handler()
+ private val p2pManager by lazy { getSystemService(Context.WIFI_P2P_SERVICE) as WifiP2pManager }
+ private var _channel: WifiP2pManager.Channel? = null
+ private val channel: WifiP2pManager.Channel get() {
+ if (_channel == null) onChannelDisconnected()
+ return _channel!!
+ }
+ lateinit var group: WifiP2pGroup
+ private set
+ private val binder = HotspotBinder()
+ private var receiverRegistered = false
+ private val receiver = broadcastReceiver { _, intent ->
+ when (intent.action) {
+ WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION ->
+ if (intent.getIntExtra(WifiP2pManager.EXTRA_WIFI_STATE, 0) ==
+ WifiP2pManager.WIFI_P2P_STATE_DISABLED) clean() // ignore P2P enabled
+ WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> {
+ onP2pConnectionChanged(intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_INFO),
+ intent.getParcelableExtra(WifiP2pManager.EXTRA_NETWORK_INFO),
+ intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_GROUP))
+ }
+ }
+ }
+ private val onVpnUnavailable = Runnable { startFailure("VPN unavailable") }
+
+ val ssid get() = if (status == Status.ACTIVE) group.networkName else null
+ val password get() = if (status == Status.ACTIVE) group.passphrase else null
+
+ private var upstream: String? = null
+ var routing: Routing? = null
+ private set
+
+ var status = Status.IDLE
+ private set(value) {
+ if (field == value) return
+ field = value
+ LocalBroadcastManager.getInstance(this).sendBroadcast(Intent(STATUS_CHANGED))
+ }
+
+ private fun formatReason(reason: Int) = when (reason) {
+ WifiP2pManager.ERROR -> "ERROR"
+ WifiP2pManager.P2P_UNSUPPORTED -> "P2P_UNSUPPORTED"
+ WifiP2pManager.BUSY -> "BUSY"
+ WifiP2pManager.NO_SERVICE_REQUESTS -> "NO_SERVICE_REQUESTS"
+ else -> "unknown reason: $reason"
+ }
+
+ override fun onBind(intent: Intent) = binder
+
+ override fun onChannelDisconnected() {
+ _channel = p2pManager.initialize(this, Looper.getMainLooper(), this)
+ }
+
+ /**
+ * startService 1st stop
+ */
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (status != Status.IDLE) return START_NOT_STICKY
+ status = Status.STARTING
+ handler.postDelayed(onVpnUnavailable, 4000)
+ VpnListener.registerCallback(this)
+ return START_NOT_STICKY
+ }
+ private fun startFailure(msg: String?, group: WifiP2pGroup? = null) {
+ Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
+ showNotification()
+ if (group != null) removeGroup() else clean()
+ }
+
+ /**
+ * startService 2nd stop, also called when VPN re-established
+ */
+ override fun onAvailable(ifname: String) {
+ handler.removeCallbacks(onVpnUnavailable)
+ when (status) {
+ Status.STARTING -> {
+ val matcher = patternNetworkInfo.matcher(loggerSu("dumpsys ${Context.WIFI_P2P_SERVICE}"))
+ when {
+ !matcher.find() -> startFailure("Root unavailable")
+ matcher.group(2) == "true" -> {
+ unregisterReceiver()
+ registerReceiver(receiver, intentFilter(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION,
+ WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION))
+ receiverRegistered = true
+ upstream = ifname
+ p2pManager.requestGroupInfo(channel, {
+ when {
+ it == null -> doStart()
+ it.isGroupOwner -> doStart(it)
+ else -> {
+ Log.i(TAG, "Removing old group ($it)")
+ p2pManager.removeGroup(channel, object : WifiP2pManager.ActionListener {
+ override fun onSuccess() = doStart()
+ override fun onFailure(reason: Int) {
+ Toast.makeText(this@RepeaterService,
+ "Failed to remove old P2P group (${formatReason(reason)})",
+ Toast.LENGTH_SHORT).show()
+ }
+ })
+ }
+ }
+ })
+ }
+ else -> startFailure("Wi-Fi direct unavailable")
+ }
+ }
+ Status.ACTIVE -> {
+ val routing = routing
+ assert(!routing!!.started)
+ initRouting(ifname, routing.downstream, routing.hostAddress)
+ }
+ else -> throw RuntimeException("RepeaterService is in unexpected state when receiving onAvailable")
+ }
+ }
+ override fun onLost(ifname: String) {
+ routing?.stop()
+ upstream = null
+ }
+
+ private fun doStart() = p2pManager.createGroup(channel, object : WifiP2pManager.ActionListener {
+ override fun onFailure(reason: Int) = startFailure("Failed to create P2P group (${formatReason(reason)})")
+ override fun onSuccess() { } // wait for WIFI_P2P_CONNECTION_CHANGED_ACTION to fire
+ })
+ 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
+ */
+ private fun onP2pConnectionChanged(info: WifiP2pInfo, net: NetworkInfo?, group: WifiP2pGroup) {
+ if (routing == null) onGroupCreated(info, group) else if (!group.isGroupOwner) { // P2P shutdown
+ clean()
+ return
+ }
+ this.group = group
+ binder.data?.onGroupChanged()
+ showNotification(group)
+ Log.d(TAG, "P2P connection changed: $info\n$net\n$group")
+ }
+ private fun onGroupCreated(info: WifiP2pInfo, group: WifiP2pGroup) {
+ val owner = info.groupOwnerAddress
+ val downstream = group.`interface`
+ if (!info.groupFormed || !info.isGroupOwner || downstream == null || owner == null) return
+ receiverRegistered = true
+ try {
+ if (initRouting(upstream!!, downstream, owner)) doStart(group)
+ else startFailure("Something went wrong, please check logcat.", group)
+ } catch (e: Routing.InterfaceNotFoundException) {
+ startFailure(e.message, group)
+ return
+ }
+ }
+ private fun initRouting(upstream: String, downstream: String, owner: InetAddress): Boolean {
+ val routing = Routing(upstream, downstream, owner)
+ .ipForward() // Wi-Fi direct doesn't enable ip_forward
+ .rule().forward().dnsRedirect(app.dns)
+ return if (routing.start()) {
+ this.routing = routing
+ true
+ } else {
+ routing.stop()
+ this.routing = null
+ false
+ }
+ }
+
+ private fun showNotification(group: WifiP2pGroup? = null) {
+ val builder = NotificationCompat.Builder(this, CHANNEL)
+ .setWhen(0)
+ .setColor(ContextCompat.getColor(this, R.color.colorPrimary))
+ .setContentTitle(group?.networkName ?: ssid ?: "Connecting...")
+ .setSmallIcon(R.drawable.ic_device_wifi_tethering)
+ .setContentIntent(PendingIntent.getActivity(this, 0,
+ Intent(this, MainActivity::class.java), PendingIntent.FLAG_UPDATE_CURRENT))
+ if (group != null) builder.setContentText(resources.getQuantityString(R.plurals.notification_connected_devices,
+ group.clientList.size, group.clientList.size))
+ startForeground(1, builder.build())
+ }
+
+ private fun removeGroup() {
+ p2pManager.removeGroup(channel, object : WifiP2pManager.ActionListener {
+ override fun onSuccess() = clean()
+ override fun onFailure(reason: Int) {
+ if (reason == WifiP2pManager.BUSY) clean() else { // assuming it's already gone
+ Toast.makeText(this@RepeaterService, "Failed to remove P2P group (${formatReason(reason)})",
+ Toast.LENGTH_SHORT).show()
+ status = Status.ACTIVE
+ LocalBroadcastManager.getInstance(this@RepeaterService).sendBroadcast(Intent(STATUS_CHANGED))
+ }
+ }
+ })
+ }
+ private fun unregisterReceiver() {
+ VpnListener.unregisterCallback(this)
+ if (receiverRegistered) {
+ unregisterReceiver(receiver)
+ receiverRegistered = false
+ }
+ }
+ private fun clean() {
+ unregisterReceiver()
+ if (routing?.stop() == false)
+ Toast.makeText(this, "Something went wrong, please check logcat.", Toast.LENGTH_SHORT).show()
+ routing = null
+ status = Status.IDLE
+ stopForeground(true)
+ }
+
+ override fun onDestroy() {
+ if (status != Status.IDLE) binder.shutdown()
+ super.onDestroy()
+ }
+}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/Routing.kt b/mobile/src/main/java/be/mygod/vpnhotspot/Routing.kt
index 46381ffc..fd0e8a5a 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/Routing.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/Routing.kt
@@ -7,7 +7,7 @@ import java.net.InetAddress
import java.net.NetworkInterface
import java.util.*
-class Routing(val upstream: String, val downstream: String, ownerAddress: InetAddress? = null) {
+class Routing(private val upstream: String, val downstream: String, ownerAddress: InetAddress? = null) {
companion object {
fun clean() = noisySu(
"iptables -t nat -F PREROUTING",
@@ -25,7 +25,8 @@ class Routing(val upstream: String, val downstream: String, ownerAddress: InetAd
?.singleOrNull { it is Inet4Address } ?: throw InterfaceNotFoundException()
private val startScript = LinkedList()
private val stopScript = LinkedList()
- private var started = false
+ var started = false
+ private set
fun ipForward(): Routing {
startScript.add("echo 1 >/proc/sys/net/ipv4/ip_forward")
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/SettingsActivity.kt b/mobile/src/main/java/be/mygod/vpnhotspot/SettingsActivity.kt
deleted file mode 100644
index 7fb0cf85..00000000
--- a/mobile/src/main/java/be/mygod/vpnhotspot/SettingsActivity.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package be.mygod.vpnhotspot
-
-import android.databinding.DataBindingUtil
-import android.os.Bundle
-import android.support.v7.app.AppCompatActivity
-import be.mygod.vpnhotspot.databinding.ActivitySettingsBinding
-
-class SettingsActivity : AppCompatActivity() {
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- DataBindingUtil.setContentView(this, R.layout.activity_settings)
- .toolbar.setNavigationOnClickListener({ navigateUp() })
- }
-}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/SettingsFragment.kt b/mobile/src/main/java/be/mygod/vpnhotspot/SettingsFragment.kt
index 0a7b15e3..f468315e 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/SettingsFragment.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/SettingsFragment.kt
@@ -1,51 +1,12 @@
package be.mygod.vpnhotspot
-import android.content.Intent
-import android.net.Uri
import android.os.Bundle
-import android.support.customtabs.CustomTabsIntent
-import android.support.v4.content.ContextCompat
-import android.support.v7.preference.Preference
-import be.mygod.vpnhotspot.App.Companion.app
-import be.mygod.vpnhotspot.preference.AlwaysAutoCompleteEditTextPreferenceDialogFragmentCompat
-import com.takisoft.fix.support.v7.preference.PreferenceFragmentCompatDividers
-import java.net.NetworkInterface
+import android.support.v4.app.Fragment
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
-class SettingsFragment : PreferenceFragmentCompatDividers() {
- private val customTabsIntent by lazy {
- CustomTabsIntent.Builder().setToolbarColor(ContextCompat.getColor(activity!!, R.color.colorPrimary)).build()
- }
-
- override fun onCreatePreferencesFix(savedInstanceState: Bundle?, rootKey: String?) {
- addPreferencesFromResource(R.xml.pref_settings)
- findPreference("misc.logcat").setOnPreferenceClickListener {
- val intent = Intent(Intent.ACTION_SEND)
- .setType("text/plain")
- .putExtra(Intent.EXTRA_TEXT, Runtime.getRuntime().exec(arrayOf("logcat", "-d"))
- .inputStream.bufferedReader().use { it.readText() })
- startActivity(Intent.createChooser(intent, getString(R.string.abc_shareactionprovider_share_with)))
- true
- }
- findPreference("misc.source").setOnPreferenceClickListener {
- customTabsIntent.launchUrl(activity, Uri.parse("https://github.com/Mygod/VPNHotspot"))
- true
- }
- findPreference("misc.donate").setOnPreferenceClickListener {
- customTabsIntent.launchUrl(activity, Uri.parse("https://mygod.be/donate/"))
- true
- }
- }
-
- override fun onDisplayPreferenceDialog(preference: Preference) = when (preference.key) {
- HotspotService.KEY_UPSTREAM -> displayPreferenceDialog(
- AlwaysAutoCompleteEditTextPreferenceDialogFragmentCompat(), HotspotService.KEY_UPSTREAM,
- Bundle().put(AlwaysAutoCompleteEditTextPreferenceDialogFragmentCompat.KEY_SUGGESTIONS,
- NetworkInterface.getNetworkInterfaces().asSequence()
- .filter { it.isUp && !it.isLoopback && it.interfaceAddresses.isNotEmpty() }
- .map { it.name }.sorted().toList().toTypedArray()))
- HotspotService.KEY_WIFI -> displayPreferenceDialog(
- AlwaysAutoCompleteEditTextPreferenceDialogFragmentCompat(), HotspotService.KEY_WIFI, Bundle()
- .put(AlwaysAutoCompleteEditTextPreferenceDialogFragmentCompat.KEY_SUGGESTIONS, app.wifiInterfaces))
- else -> super.onDisplayPreferenceDialog(preference)
- }
+class SettingsFragment : Fragment() {
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? =
+ inflater.inflate(R.layout.fragment_settings, container, false)
}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/SettingsPreferenceFragment.kt b/mobile/src/main/java/be/mygod/vpnhotspot/SettingsPreferenceFragment.kt
new file mode 100644
index 00000000..eabccaa2
--- /dev/null
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/SettingsPreferenceFragment.kt
@@ -0,0 +1,38 @@
+package be.mygod.vpnhotspot
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.support.customtabs.CustomTabsIntent
+import android.support.v4.content.ContextCompat
+import com.takisoft.fix.support.v7.preference.PreferenceFragmentCompatDividers
+
+class SettingsPreferenceFragment : PreferenceFragmentCompatDividers() {
+ private val customTabsIntent by lazy {
+ CustomTabsIntent.Builder().setToolbarColor(ContextCompat.getColor(activity!!, R.color.colorPrimary)).build()
+ }
+
+ override fun onCreatePreferencesFix(savedInstanceState: Bundle?, rootKey: String?) {
+ addPreferencesFromResource(R.xml.pref_settings)
+ findPreference("service.clean").setOnPreferenceClickListener {
+ Routing.clean()
+ true
+ }
+ findPreference("misc.logcat").setOnPreferenceClickListener {
+ val intent = Intent(Intent.ACTION_SEND)
+ .setType("text/plain")
+ .putExtra(Intent.EXTRA_TEXT, Runtime.getRuntime().exec(arrayOf("logcat", "-d"))
+ .inputStream.bufferedReader().use { it.readText() })
+ startActivity(Intent.createChooser(intent, getString(R.string.abc_shareactionprovider_share_with)))
+ true
+ }
+ findPreference("misc.source").setOnPreferenceClickListener {
+ customTabsIntent.launchUrl(activity, Uri.parse("https://github.com/Mygod/VPNHotspot"))
+ true
+ }
+ findPreference("misc.donate").setOnPreferenceClickListener {
+ customTabsIntent.launchUrl(activity, Uri.parse("https://mygod.be/donate/"))
+ true
+ }
+ }
+}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/TetheringFragment.kt b/mobile/src/main/java/be/mygod/vpnhotspot/TetheringFragment.kt
new file mode 100644
index 00000000..fe228eb3
--- /dev/null
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/TetheringFragment.kt
@@ -0,0 +1,141 @@
+package be.mygod.vpnhotspot
+
+import android.content.Intent
+import android.content.res.Resources
+import android.databinding.BaseObservable
+import android.databinding.DataBindingUtil
+import android.os.Bundle
+import android.support.v4.app.Fragment
+import android.support.v4.content.LocalBroadcastManager
+import android.support.v7.util.SortedList
+import android.support.v7.widget.DefaultItemAnimator
+import android.support.v7.widget.LinearLayoutManager
+import android.support.v7.widget.RecyclerView
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import be.mygod.vpnhotspot.App.Companion.app
+import be.mygod.vpnhotspot.NetUtils.tetheredIfaces
+import be.mygod.vpnhotspot.databinding.FragmentTetheringBinding
+import be.mygod.vpnhotspot.databinding.ListitemInterfaceBinding
+
+class TetheringFragment : Fragment() {
+ 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
+ override fun onMoved(fromPosition: Int, toPosition: Int) { }
+ override fun onChanged(position: Int, count: Int) { }
+ override fun onRemoved(position: Int, count: Int) { }
+ override fun areItemsTheSame(item1: T?, item2: T?): Boolean = item1 == item2
+ override fun compare(o1: T?, o2: T?): Int =
+ if (o1 == null) if (o2 == null) 0 else 1 else if (o2 == null) -1 else compareNonNull(o1, o2)
+ abstract fun compareNonNull(o1: T, o2: T): Int
+ }
+ private open class DefaultSorter> : BaseSorter() {
+ override fun compareNonNull(o1: T, o2: T): Int = o1.compareTo(o2)
+ }
+ 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
+ }
+ var active = TetheringService.active?.contains(iface) == true
+ }
+
+ 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!!
+ context.startService(Intent(context, TetheringService::class.java).putExtra(if (data.active)
+ TetheringService.EXTRA_REMOVE_INTERFACE else TetheringService.EXTRA_ADD_INTERFACE, data.iface))
+ data.active = !data.active
+ }
+ }
+ inner class InterfaceAdapter : RecyclerView.Adapter() {
+ private val tethered = SortedList(String::class.java, StringSorter)
+
+ fun update(data: Set = VpnListener.connectivityManager.tetheredIfaces.toSet()) {
+ tethered.clear()
+ tethered.addAll(data)
+ notifyDataSetChanged()
+ }
+
+ override fun getItemCount() = tethered.size()
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = InterfaceViewHolder(
+ ListitemInterfaceBinding.inflate(LayoutInflater.from(parent.context), parent, false))
+ override fun onBindViewHolder(holder: InterfaceViewHolder, position: Int) {
+ holder.binding.data = Data(tethered[position])
+ }
+ }
+
+ private val adapter = InterfaceAdapter()
+ private val receiver = broadcastReceiver { _, intent ->
+ when (intent.action) {
+ TetheringService.ACTION_ACTIVE_INTERFACES_CHANGED -> adapter.notifyDataSetChanged()
+ NetUtils.ACTION_TETHER_STATE_CHANGED ->
+ adapter.update(intent.extras.getStringArrayList(NetUtils.EXTRA_ACTIVE_TETHER).toSet())
+ }
+ }
+ private var receiverRegistered = false
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+ val binding = DataBindingUtil.inflate(inflater, R.layout.fragment_tethering,
+ container, false)
+ binding.interfaces.layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
+ val animator = DefaultItemAnimator()
+ animator.supportsChangeAnimations = false // prevent fading-in/out when rebinding
+ binding.interfaces.itemAnimator = animator
+ binding.interfaces.adapter = adapter
+ return binding.root
+ }
+
+ override fun onStart() {
+ super.onStart()
+ if (!receiverRegistered) {
+ adapter.update()
+ 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
+ }
+ }
+
+ override fun onStop() {
+ if (receiverRegistered) {
+ val context = context!!
+ context.unregisterReceiver(receiver)
+ LocalBroadcastManager.getInstance(context).unregisterReceiver(receiver)
+ receiverRegistered = false
+ }
+ super.onStop()
+ }
+}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt
new file mode 100644
index 00000000..b4d68b75
--- /dev/null
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt
@@ -0,0 +1,87 @@
+package be.mygod.vpnhotspot
+
+import android.app.Service
+import android.content.Intent
+import android.support.v4.content.LocalBroadcastManager
+import be.mygod.vpnhotspot.App.Companion.app
+import be.mygod.vpnhotspot.NetUtils.tetheredIfaces
+
+class TetheringService : Service(), VpnListener.Callback {
+ companion object {
+ const val ACTION_ACTIVE_INTERFACES_CHANGED = "be.mygod.vpnhotspot.TetheringService.ACTIVE_INTERFACES_CHANGED"
+ const val EXTRA_ADD_INTERFACE = "interface.add"
+ const val EXTRA_REMOVE_INTERFACE = "interface.remove"
+ private const val KEY_ACTIVE = "persist.service.tether.active"
+
+ var active: Set?
+ get() = app.pref.getStringSet(KEY_ACTIVE, null)
+ private set(value) {
+ app.pref.edit().putStringSet(KEY_ACTIVE, value).apply()
+ LocalBroadcastManager.getInstance(app).sendBroadcast(Intent(ACTION_ACTIVE_INTERFACES_CHANGED))
+ }
+ }
+
+ private val routings = HashMap()
+ private var upstream: String? = null
+ private var receiverRegistered = false
+ private val receiver = broadcastReceiver { _, intent ->
+ val remove = routings - intent.extras.getStringArrayList(NetUtils.EXTRA_ACTIVE_TETHER).toSet()
+ if (remove.isEmpty()) return@broadcastReceiver
+ for ((iface, routing) in remove) {
+ routing?.stop()
+ routings.remove(iface)
+ }
+ val upstream = upstream
+ if (upstream == null) onLost("") else onAvailable(upstream)
+ active = routings.keys
+ if (routings.isEmpty()) terminate()
+ }
+
+ override fun onBind(intent: Intent?) = null
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (intent != null) { // otw service is recreated after being killed
+ var iface = intent.getStringExtra(EXTRA_ADD_INTERFACE)
+ if (iface != null && VpnListener.connectivityManager.tetheredIfaces.contains(iface))
+ routings.put(iface, null)
+ iface = intent.getStringExtra(EXTRA_REMOVE_INTERFACE)
+ if (iface != null) routings.remove(iface)?.stop()
+ active = routings.keys
+ } else active?.forEach { routings.put(it, null) }
+ if (routings.isEmpty()) terminate() else {
+ if (!receiverRegistered) {
+ registerReceiver(receiver, intentFilter(NetUtils.ACTION_TETHER_STATE_CHANGED))
+ VpnListener.registerCallback(this)
+ receiverRegistered = true
+ }
+ }
+ return START_STICKY
+ }
+
+ override fun onAvailable(ifname: String) {
+ assert(upstream == null || upstream == ifname)
+ upstream = ifname
+ for ((downstream, value) in routings) if (value == null) {
+ val routing = Routing(ifname, downstream).rule().forward().dnsRedirect(app.dns)
+ if (routing.start()) routings[downstream] = routing else routing.stop()
+ }
+ }
+
+ override fun onLost(ifname: String) {
+ assert(upstream == null || upstream == ifname)
+ upstream = null
+ for ((iface, routing) in routings) {
+ routing?.stop()
+ routings[iface] = null
+ }
+ }
+
+ private fun terminate() {
+ if (receiverRegistered) {
+ unregisterReceiver(receiver)
+ VpnListener.unregisterCallback(this)
+ receiverRegistered = false
+ }
+ stopSelf()
+ }
+}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/Utils.kt b/mobile/src/main/java/be/mygod/vpnhotspot/Utils.kt
index 9577dfc5..da7e8018 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/Utils.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/Utils.kt
@@ -4,9 +4,6 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
-import android.os.Bundle
-import android.support.v4.app.TaskStackBuilder
-import android.support.v7.app.AppCompatActivity
import android.util.Log
import java.io.InputStream
@@ -24,18 +21,6 @@ fun intentFilter(vararg actions: String): IntentFilter {
return result
}
-fun AppCompatActivity.navigateUp() {
- val intent = parentActivityIntent
- if (shouldUpRecreateTask(intent))
- TaskStackBuilder.create(this).addNextIntentWithParentStack(intent).startActivities()
- else navigateUpTo(intent)
-}
-
-fun Bundle.put(key: String, map: Array): Bundle {
- putStringArray(key, map)
- return this
-}
-
private const val NOISYSU_TAG = "NoisySU"
private const val NOISYSU_SUFFIX = "SUCCESS\n"
fun loggerSuStream(command: String): InputStream {
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/VpnListener.kt b/mobile/src/main/java/be/mygod/vpnhotspot/VpnListener.kt
new file mode 100644
index 00000000..60970766
--- /dev/null
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/VpnListener.kt
@@ -0,0 +1,58 @@
+package be.mygod.vpnhotspot
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import be.mygod.vpnhotspot.App.Companion.app
+
+object VpnListener : ConnectivityManager.NetworkCallback() {
+ interface Callback {
+ fun onAvailable(ifname: String)
+ fun onLost(ifname: String)
+ }
+
+ private const val TAG = "VpnListener"
+
+ val connectivityManager = app.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ private val request by lazy {
+ NetworkRequest.Builder()
+ .addTransportType(NetworkCapabilities.TRANSPORT_VPN)
+ .removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
+ .build()
+ }
+ private val callbacks = HashSet()
+ private var registered = false
+
+ /**
+ * Obtaining ifname in onLost doesn't work so we need to cache it in onAvailable.
+ */
+ private val available = HashMap()
+ override fun onAvailable(network: Network) {
+ val ifname = connectivityManager.getLinkProperties(network)?.interfaceName ?: return
+ available.put(network, ifname)
+ debugLog(TAG, "onAvailable: $ifname")
+ callbacks.forEach { it.onAvailable(ifname) }
+ }
+
+ override fun onLost(network: Network) {
+ val ifname = available.remove(network) ?: return
+ debugLog(TAG, "onLost: $ifname")
+ callbacks.forEach { it.onLost(ifname) }
+ }
+
+ fun registerCallback(callback: Callback) {
+ if (!callbacks.add(callback)) return
+ if (registered) available.forEach { callback.onAvailable(it.value) } else {
+ connectivityManager.registerNetworkCallback(request, this)
+ registered = false
+ }
+ }
+ fun unregisterCallback(callback: Callback) {
+ if (!callbacks.remove(callback) || callbacks.isNotEmpty() || !registered) return
+ connectivityManager.unregisterNetworkCallback(this)
+ registered = false
+ available.clear()
+ }
+}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/preference/AlwaysAutoCompleteEditText.kt b/mobile/src/main/java/be/mygod/vpnhotspot/preference/AlwaysAutoCompleteEditText.kt
deleted file mode 100644
index cb6c333d..00000000
--- a/mobile/src/main/java/be/mygod/vpnhotspot/preference/AlwaysAutoCompleteEditText.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-package be.mygod.vpnhotspot.preference
-
-import android.content.Context
-import android.graphics.Rect
-import android.support.v7.widget.AppCompatAutoCompleteTextView
-import android.util.AttributeSet
-import android.view.View
-import be.mygod.vpnhotspot.R
-
-/**
- * Based on: https://gist.github.com/furycomptuers/4961368
- */
-class AlwaysAutoCompleteEditText @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null,
- defStyleAttr: Int = R.attr.autoCompleteTextViewStyle) :
- AppCompatAutoCompleteTextView(context, attrs, defStyleAttr) {
- override fun enoughToFilter() = true
-
- override fun onFocusChanged(focused: Boolean, direction: Int, previouslyFocusedRect: Rect?) {
- super.onFocusChanged(focused, direction, previouslyFocusedRect)
- if (focused && windowVisibility != View.GONE) {
- performFiltering(text, 0)
- showDropDown()
- }
- }
-}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/preference/AlwaysAutoCompleteEditTextPreference.kt b/mobile/src/main/java/be/mygod/vpnhotspot/preference/AlwaysAutoCompleteEditTextPreference.kt
deleted file mode 100644
index e17d6c2b..00000000
--- a/mobile/src/main/java/be/mygod/vpnhotspot/preference/AlwaysAutoCompleteEditTextPreference.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-package be.mygod.vpnhotspot.preference
-
-import android.content.Context
-import android.text.TextUtils
-import android.util.AttributeSet
-import be.mygod.vpnhotspot.R
-import com.takisoft.fix.support.v7.preference.AutoSummaryEditTextPreference
-
-open class AlwaysAutoCompleteEditTextPreference @JvmOverloads constructor(
- context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = R.attr.editTextPreferenceStyle,
- defStyleRes: Int = 0) : AutoSummaryEditTextPreference(context, attrs, defStyleAttr, defStyleRes) {
- val editText = AlwaysAutoCompleteEditText(context, attrs)
-
- init {
- editText.id = android.R.id.edit
- }
-
- override fun setText(text: String) {
- val oldText = getText()
- super.setText(text)
- if (!TextUtils.equals(text, oldText)) notifyChanged()
- }
-}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/preference/AlwaysAutoCompleteEditTextPreferenceDialogFragmentCompat.kt b/mobile/src/main/java/be/mygod/vpnhotspot/preference/AlwaysAutoCompleteEditTextPreferenceDialogFragmentCompat.kt
deleted file mode 100644
index d5fae19b..00000000
--- a/mobile/src/main/java/be/mygod/vpnhotspot/preference/AlwaysAutoCompleteEditTextPreferenceDialogFragmentCompat.kt
+++ /dev/null
@@ -1,59 +0,0 @@
-package be.mygod.vpnhotspot.preference
-
-import android.support.v7.preference.PreferenceDialogFragmentCompat
-import android.support.v7.widget.AppCompatAutoCompleteTextView
-import android.view.View
-import android.view.ViewGroup
-import android.widget.ArrayAdapter
-import android.widget.EditText
-
-open class AlwaysAutoCompleteEditTextPreferenceDialogFragmentCompat : PreferenceDialogFragmentCompat() {
- companion object {
- const val KEY_SUGGESTIONS = "suggestions"
- }
-
- private lateinit var editText: AppCompatAutoCompleteTextView
- private val editTextPreference get() = this.preference as AlwaysAutoCompleteEditTextPreference
-
- override fun onBindDialogView(view: View) {
- super.onBindDialogView(view)
-
- editText = editTextPreference.editText
- editText.setText(this.editTextPreference.text)
-
- val text = editText.text
- if (text != null) editText.setSelection(text.length, text.length)
-
- val suggestions = arguments?.getStringArray(KEY_SUGGESTIONS)
- if (suggestions != null)
- editText.setAdapter(ArrayAdapter(view.context, android.R.layout.select_dialog_item, suggestions))
-
- val oldParent = editText.parent as? ViewGroup?
- if (oldParent !== view) {
- oldParent?.removeView(editText)
- onAddEditTextToDialogView(view, editText)
- }
- }
-
- override fun needInputMethod(): Boolean = true
-
- protected fun onAddEditTextToDialogView(dialogView: View, editText: EditText) {
- val oldEditText = dialogView.findViewById(android.R.id.edit)
- if (oldEditText != null) {
- val container = oldEditText.parent as? ViewGroup?
- if (container != null) {
- container.removeView(oldEditText)
- container.addView(editText, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
- }
- }
- }
-
- override fun onDialogClosed(positiveResult: Boolean) {
- if (positiveResult) {
- val value = this.editText.text.toString()
- if (this.editTextPreference.callChangeListener(value)) {
- this.editTextPreference.text = value
- }
- }
- }
-}
diff --git a/mobile/src/main/res/drawable/ic_device_bluetooth.xml b/mobile/src/main/res/drawable/ic_device_bluetooth.xml
new file mode 100644
index 00000000..1094756b
--- /dev/null
+++ b/mobile/src/main/res/drawable/ic_device_bluetooth.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/mobile/src/main/res/drawable/ic_device_network_wifi.xml b/mobile/src/main/res/drawable/ic_device_network_wifi.xml
new file mode 100644
index 00000000..caac288f
--- /dev/null
+++ b/mobile/src/main/res/drawable/ic_device_network_wifi.xml
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/mobile/src/main/res/drawable/ic_device_usb.xml b/mobile/src/main/res/drawable/ic_device_usb.xml
new file mode 100644
index 00000000..4ea66568
--- /dev/null
+++ b/mobile/src/main/res/drawable/ic_device_usb.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/mobile/src/main/res/drawable/ic_device_wifi_tethering.xml b/mobile/src/main/res/drawable/ic_device_wifi_tethering.xml
index 4e556352..c151e3ba 100644
--- a/mobile/src/main/res/drawable/ic_device_wifi_tethering.xml
+++ b/mobile/src/main/res/drawable/ic_device_wifi_tethering.xml
@@ -2,7 +2,8 @@
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
- android:viewportHeight="24.0">
+ android:viewportHeight="24.0"
+ android:tint="?attr/colorControlNormal">
diff --git a/mobile/src/main/res/layout/activity_main.xml b/mobile/src/main/res/layout/activity_main.xml
index eb732795..890758df 100644
--- a/mobile/src/main/res/layout/activity_main.xml
+++ b/mobile/src/main/res/layout/activity_main.xml
@@ -3,111 +3,34 @@
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
-
-
-
-
-
+ android:layout_height="match_parent"
+ tools:context="be.mygod.vpnhotspot.MainActivity">
-
-
+
-
+ android:layout_marginEnd="0dp"
+ android:layout_marginStart="0dp"
+ android:background="?android:attr/windowBackground"
+ app:layout_constraintBottom_toBottomOf="parent"
+ app:layout_constraintLeft_toLeftOf="parent"
+ app:layout_constraintRight_toRightOf="parent"
+ app:menu="@menu/navigation"/>
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/mobile/src/main/res/layout/fragment_repeater.xml b/mobile/src/main/res/layout/fragment_repeater.xml
new file mode 100644
index 00000000..9e2d4f5f
--- /dev/null
+++ b/mobile/src/main/res/layout/fragment_repeater.xml
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/src/main/res/layout/fragment_settings.xml b/mobile/src/main/res/layout/fragment_settings.xml
new file mode 100644
index 00000000..5d2ac8a3
--- /dev/null
+++ b/mobile/src/main/res/layout/fragment_settings.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
diff --git a/mobile/src/main/res/layout/activity_settings.xml b/mobile/src/main/res/layout/fragment_tethering.xml
similarity index 67%
rename from mobile/src/main/res/layout/activity_settings.xml
rename to mobile/src/main/res/layout/fragment_tethering.xml
index 131dd9c9..6b669658 100644
--- a/mobile/src/main/res/layout/activity_settings.xml
+++ b/mobile/src/main/res/layout/fragment_tethering.xml
@@ -1,7 +1,8 @@
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools">
-
+ android:clipToPadding="false"
+ android:scrollbars="vertical"
+ tools:listitem="@layout/listitem_interface"/>
-
\ No newline at end of file
+
diff --git a/mobile/src/main/res/layout/listitem_interface.xml b/mobile/src/main/res/layout/listitem_interface.xml
new file mode 100644
index 00000000..cfdc4868
--- /dev/null
+++ b/mobile/src/main/res/layout/listitem_interface.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/src/main/res/menu/main.xml b/mobile/src/main/res/menu/main.xml
deleted file mode 100644
index 09f9416b..00000000
--- a/mobile/src/main/res/menu/main.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
diff --git a/mobile/src/main/res/menu/navigation.xml b/mobile/src/main/res/menu/navigation.xml
new file mode 100644
index 00000000..00790df3
--- /dev/null
+++ b/mobile/src/main/res/menu/navigation.xml
@@ -0,0 +1,19 @@
+
+
diff --git a/mobile/src/main/res/xml/pref_settings.xml b/mobile/src/main/res/xml/pref_settings.xml
index 45ffaf34..2d75b1de 100644
--- a/mobile/src/main/res/xml/pref_settings.xml
+++ b/mobile/src/main/res/xml/pref_settings.xml
@@ -2,21 +2,15 @@
-
-
+