From eb165db86cb0e2491e2af8ff013d7e684c8c6d1d Mon Sep 17 00:00:00 2001 From: Mygod Date: Sat, 13 Jan 2018 00:41:55 +0800 Subject: [PATCH] Support VPN over any native tethering First big refactoring of this app. --- mobile/src/main/AndroidManifest.xml | 18 +- .../src/main/java/be/mygod/vpnhotspot/App.kt | 19 +- .../mygod/vpnhotspot/DataBindingAdapters.kt | 11 + .../be/mygod/vpnhotspot/HotspotService.kt | 360 ------------------ .../java/be/mygod/vpnhotspot/MainActivity.kt | 156 +------- .../main/java/be/mygod/vpnhotspot/NetUtils.kt | 44 +-- .../be/mygod/vpnhotspot/RepeaterFragment.kt | 146 +++++++ .../be/mygod/vpnhotspot/RepeaterService.kt | 270 +++++++++++++ .../main/java/be/mygod/vpnhotspot/Routing.kt | 5 +- .../be/mygod/vpnhotspot/SettingsActivity.kt | 14 - .../be/mygod/vpnhotspot/SettingsFragment.kt | 53 +-- .../vpnhotspot/SettingsPreferenceFragment.kt | 38 ++ .../be/mygod/vpnhotspot/TetheringFragment.kt | 141 +++++++ .../be/mygod/vpnhotspot/TetheringService.kt | 87 +++++ .../main/java/be/mygod/vpnhotspot/Utils.kt | 15 - .../java/be/mygod/vpnhotspot/VpnListener.kt | 58 +++ .../preference/AlwaysAutoCompleteEditText.kt | 25 -- .../AlwaysAutoCompleteEditTextPreference.kt | 23 -- ...eEditTextPreferenceDialogFragmentCompat.kt | 59 --- .../main/res/drawable/ic_device_bluetooth.xml | 9 + .../res/drawable/ic_device_network_wifi.xml | 13 + .../src/main/res/drawable/ic_device_usb.xml | 5 + .../res/drawable/ic_device_wifi_tethering.xml | 3 +- mobile/src/main/res/layout/activity_main.xml | 125 ++---- .../src/main/res/layout/fragment_repeater.xml | 115 ++++++ .../src/main/res/layout/fragment_settings.xml | 23 ++ ...ty_settings.xml => fragment_tethering.xml} | 16 +- .../main/res/layout/listitem_interface.xml | 44 +++ mobile/src/main/res/menu/main.xml | 14 - mobile/src/main/res/menu/navigation.xml | 19 + mobile/src/main/res/xml/pref_settings.xml | 14 +- 31 files changed, 1068 insertions(+), 874 deletions(-) create mode 100644 mobile/src/main/java/be/mygod/vpnhotspot/DataBindingAdapters.kt delete mode 100644 mobile/src/main/java/be/mygod/vpnhotspot/HotspotService.kt create mode 100644 mobile/src/main/java/be/mygod/vpnhotspot/RepeaterFragment.kt create mode 100644 mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt delete mode 100644 mobile/src/main/java/be/mygod/vpnhotspot/SettingsActivity.kt create mode 100644 mobile/src/main/java/be/mygod/vpnhotspot/SettingsPreferenceFragment.kt create mode 100644 mobile/src/main/java/be/mygod/vpnhotspot/TetheringFragment.kt create mode 100644 mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt create mode 100644 mobile/src/main/java/be/mygod/vpnhotspot/VpnListener.kt delete mode 100644 mobile/src/main/java/be/mygod/vpnhotspot/preference/AlwaysAutoCompleteEditText.kt delete mode 100644 mobile/src/main/java/be/mygod/vpnhotspot/preference/AlwaysAutoCompleteEditTextPreference.kt delete mode 100644 mobile/src/main/java/be/mygod/vpnhotspot/preference/AlwaysAutoCompleteEditTextPreferenceDialogFragmentCompat.kt create mode 100644 mobile/src/main/res/drawable/ic_device_bluetooth.xml create mode 100644 mobile/src/main/res/drawable/ic_device_network_wifi.xml create mode 100644 mobile/src/main/res/drawable/ic_device_usb.xml create mode 100644 mobile/src/main/res/layout/fragment_repeater.xml create mode 100644 mobile/src/main/res/layout/fragment_settings.xml rename mobile/src/main/res/layout/{activity_settings.xml => fragment_tethering.xml} (67%) create mode 100644 mobile/src/main/res/layout/listitem_interface.xml delete mode 100644 mobile/src/main/res/menu/main.xml create mode 100644 mobile/src/main/res/menu/navigation.xml 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 @@ - - +