Support VPN over any native tethering
First big refactoring of this app.
This commit is contained in:
@@ -3,7 +3,10 @@
|
|||||||
package="be.mygod.vpnhotspot">
|
package="be.mygod.vpnhotspot">
|
||||||
|
|
||||||
<uses-feature android:name="android.hardware.wifi"/>
|
<uses-feature android:name="android.hardware.wifi"/>
|
||||||
<uses-feature android:name="android.hardware.wifi.direct" android:required="false"/>
|
<uses-feature
|
||||||
|
android:name="android.hardware.wifi.direct"
|
||||||
|
android:required="false"/>
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
|
||||||
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/>
|
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/>
|
||||||
@@ -17,8 +20,8 @@
|
|||||||
android:theme="@style/AppTheme">
|
android:theme="@style/AppTheme">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:launchMode="singleInstance"
|
android:label="@string/app_name"
|
||||||
android:label="@string/app_name">
|
android:launchMode="singleInstance">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
|
||||||
@@ -26,13 +29,10 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<service android:name=".HotspotService">
|
<service android:name=".RepeaterService">
|
||||||
|
</service>
|
||||||
|
<service android:name=".TetheringService">
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".SettingsActivity"
|
|
||||||
android:label="@string/title_activity_settings"
|
|
||||||
android:parentActivityName=".MainActivity"/>
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -4,10 +4,8 @@ import android.app.Application
|
|||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.content.res.Resources
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.preference.PreferenceManager
|
import android.preference.PreferenceManager
|
||||||
import java.net.NetworkInterface
|
|
||||||
|
|
||||||
class App : Application() {
|
class App : Application() {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -18,21 +16,10 @@ class App : Application() {
|
|||||||
super.onCreate()
|
super.onCreate()
|
||||||
app = this
|
app = this
|
||||||
if (Build.VERSION.SDK_INT >= 26) getSystemService(NotificationManager::class.java)
|
if (Build.VERSION.SDK_INT >= 26) getSystemService(NotificationManager::class.java)
|
||||||
.createNotificationChannel(NotificationChannel(HotspotService.CHANNEL,
|
.createNotificationChannel(NotificationChannel(RepeaterService.CHANNEL,
|
||||||
"Hotspot Service", NotificationManager.IMPORTANCE_LOW))
|
"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
|
val pref: SharedPreferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) }
|
||||||
lateinit var wifiInterfaces: Array<String>
|
val dns: String get() = app.pref.getString("service.dns", "8.8.8.8:53")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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<Network, String>()
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,160 +1,42 @@
|
|||||||
package be.mygod.vpnhotspot
|
package be.mygod.vpnhotspot
|
||||||
|
|
||||||
import android.content.*
|
|
||||||
import android.databinding.BaseObservable
|
|
||||||
import android.databinding.Bindable
|
|
||||||
import android.databinding.DataBindingUtil
|
import android.databinding.DataBindingUtil
|
||||||
import android.net.wifi.p2p.WifiP2pDevice
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.IBinder
|
import android.support.design.widget.BottomNavigationView
|
||||||
import android.support.v4.content.ContextCompat
|
import android.support.v4.app.Fragment
|
||||||
import android.support.v4.content.LocalBroadcastManager
|
|
||||||
import android.support.v7.app.AppCompatActivity
|
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.MenuItem
|
||||||
import android.view.ViewGroup
|
|
||||||
import be.mygod.vpnhotspot.databinding.ActivityMainBinding
|
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<ClientViewHolder>() {
|
|
||||||
private var owner: WifiP2pDevice? = null
|
|
||||||
private lateinit var clients: MutableCollection<WifiP2pDevice>
|
|
||||||
private lateinit var arpCache: Map<String, String>
|
|
||||||
|
|
||||||
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 lateinit var binding: ActivityMainBinding
|
||||||
private val data = Data()
|
|
||||||
private val adapter = ClientAdapter()
|
|
||||||
private var binder: HotspotService.HotspotBinder? = null
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
|
binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
|
||||||
binding.data = data
|
binding.navigation.setOnNavigationItemSelectedListener(this)
|
||||||
binding.clients.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
|
onNavigationItemSelected(binding.navigation.menu.getItem(0))
|
||||||
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() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMenuItemClick(item: MenuItem): Boolean = when (item.itemId) {
|
override fun onNavigationItemSelected(item: MenuItem) = when (item.itemId) {
|
||||||
R.id.reapply -> {
|
R.id.navigation_repeater -> {
|
||||||
val binder = binder
|
item.isChecked = true
|
||||||
when (binder?.service?.status) {
|
displayFragment(RepeaterFragment())
|
||||||
HotspotService.Status.IDLE -> Routing.clean()
|
|
||||||
HotspotService.Status.ACTIVE_P2P, HotspotService.Status.ACTIVE_AP -> binder.reapplyRouting()
|
|
||||||
}
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
R.id.settings -> {
|
R.id.navigation_tethering -> {
|
||||||
startActivity(Intent(this, SettingsActivity::class.java))
|
item.isChecked = true
|
||||||
|
displayFragment(TetheringFragment())
|
||||||
|
true
|
||||||
|
}
|
||||||
|
R.id.navigation_settings -> {
|
||||||
|
item.isChecked = true
|
||||||
|
displayFragment(SettingsFragment())
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStart() {
|
private fun displayFragment(fragment: Fragment) =
|
||||||
super.onStart()
|
supportFragmentManager.beginTransaction().replace(R.id.fragmentHolder, fragment).commit()
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
package be.mygod.vpnhotspot
|
package be.mygod.vpnhotspot
|
||||||
|
|
||||||
import android.net.wifi.WifiConfiguration
|
import android.net.ConnectivityManager
|
||||||
import android.util.Log
|
|
||||||
import java.io.DataInputStream
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
|
||||||
|
|
||||||
object NetUtils {
|
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 spaces = " +".toPattern()
|
||||||
private val mac = "^([0-9a-f]{2}:){5}[0-9a-f]{2}$".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<String>
|
||||||
|
|
||||||
fun arp(iface: String? = null) = File("/proc/net/arp").bufferedReader().useLines {
|
fun arp(iface: String? = null) = File("/proc/net/arp").bufferedReader().useLines {
|
||||||
// IP address HW type Flags HW address Mask Device
|
// IP address HW type Flags HW address Mask Device
|
||||||
it.map { it.split(spaces) }
|
it.map { it.split(spaces) }
|
||||||
@@ -19,34 +23,4 @@ object NetUtils {
|
|||||||
mac.matcher(it[3]).matches() }
|
mac.matcher(it[3]).matches() }
|
||||||
.associateBy({ it[3] }, { it[0] })
|
.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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
146
mobile/src/main/java/be/mygod/vpnhotspot/RepeaterFragment.kt
Normal file
146
mobile/src/main/java/be/mygod/vpnhotspot/RepeaterFragment.kt
Normal file
@@ -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<ClientViewHolder>() {
|
||||||
|
private var owner: WifiP2pDevice? = null
|
||||||
|
private lateinit var clients: MutableCollection<WifiP2pDevice>
|
||||||
|
private lateinit var arpCache: Map<String, String>
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
270
mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt
Normal file
270
mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import java.net.InetAddress
|
|||||||
import java.net.NetworkInterface
|
import java.net.NetworkInterface
|
||||||
import java.util.*
|
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 {
|
companion object {
|
||||||
fun clean() = noisySu(
|
fun clean() = noisySu(
|
||||||
"iptables -t nat -F PREROUTING",
|
"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()
|
?.singleOrNull { it is Inet4Address } ?: throw InterfaceNotFoundException()
|
||||||
private val startScript = LinkedList<String>()
|
private val startScript = LinkedList<String>()
|
||||||
private val stopScript = LinkedList<String>()
|
private val stopScript = LinkedList<String>()
|
||||||
private var started = false
|
var started = false
|
||||||
|
private set
|
||||||
|
|
||||||
fun ipForward(): Routing {
|
fun ipForward(): Routing {
|
||||||
startScript.add("echo 1 >/proc/sys/net/ipv4/ip_forward")
|
startScript.add("echo 1 >/proc/sys/net/ipv4/ip_forward")
|
||||||
|
|||||||
@@ -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<ActivitySettingsBinding>(this, R.layout.activity_settings)
|
|
||||||
.toolbar.setNavigationOnClickListener({ navigateUp() })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +1,12 @@
|
|||||||
package be.mygod.vpnhotspot
|
package be.mygod.vpnhotspot
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.support.customtabs.CustomTabsIntent
|
import android.support.v4.app.Fragment
|
||||||
import android.support.v4.content.ContextCompat
|
import android.view.LayoutInflater
|
||||||
import android.support.v7.preference.Preference
|
import android.view.View
|
||||||
import be.mygod.vpnhotspot.App.Companion.app
|
import android.view.ViewGroup
|
||||||
import be.mygod.vpnhotspot.preference.AlwaysAutoCompleteEditTextPreferenceDialogFragmentCompat
|
|
||||||
import com.takisoft.fix.support.v7.preference.PreferenceFragmentCompatDividers
|
|
||||||
import java.net.NetworkInterface
|
|
||||||
|
|
||||||
class SettingsFragment : PreferenceFragmentCompatDividers() {
|
class SettingsFragment : Fragment() {
|
||||||
private val customTabsIntent by lazy {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? =
|
||||||
CustomTabsIntent.Builder().setToolbarColor(ContextCompat.getColor(activity!!, R.color.colorPrimary)).build()
|
inflater.inflate(R.layout.fragment_settings, container, false)
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
141
mobile/src/main/java/be/mygod/vpnhotspot/TetheringFragment.kt
Normal file
141
mobile/src/main/java/be/mygod/vpnhotspot/TetheringFragment.kt
Normal file
@@ -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<T> : SortedList.Callback<T>() {
|
||||||
|
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<T : Comparable<T>> : BaseSorter<T>() {
|
||||||
|
override fun compareNonNull(o1: T, o2: T): Int = o1.compareTo(o2)
|
||||||
|
}
|
||||||
|
private object StringSorter : DefaultSorter<String>()
|
||||||
|
|
||||||
|
class Data(val iface: String) : BaseObservable() {
|
||||||
|
val icon: Int get() = when {
|
||||||
|
usbRegexes.any { it.matcher(iface).matches() } -> R.drawable.ic_device_usb
|
||||||
|
wifiRegexes.any { it.matcher(iface).matches() } -> R.drawable.ic_device_network_wifi
|
||||||
|
wimaxRegexes.any { it.matcher(iface).matches() } -> R.drawable.ic_device_network_wifi
|
||||||
|
bluetoothRegexes.any { it.matcher(iface).matches() } -> R.drawable.ic_device_bluetooth
|
||||||
|
else -> R.drawable.ic_device_wifi_tethering
|
||||||
|
}
|
||||||
|
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<InterfaceViewHolder>() {
|
||||||
|
private val tethered = SortedList(String::class.java, StringSorter)
|
||||||
|
|
||||||
|
fun update(data: Set<String> = 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<FragmentTetheringBinding>(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()
|
||||||
|
}
|
||||||
|
}
|
||||||
87
mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt
Normal file
87
mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt
Normal file
@@ -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<String>?
|
||||||
|
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<String, Routing?>()
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,6 @@ import android.content.BroadcastReceiver
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
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 android.util.Log
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
@@ -24,18 +21,6 @@ fun intentFilter(vararg actions: String): IntentFilter {
|
|||||||
return result
|
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<String>): Bundle {
|
|
||||||
putStringArray(key, map)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val NOISYSU_TAG = "NoisySU"
|
private const val NOISYSU_TAG = "NoisySU"
|
||||||
private const val NOISYSU_SUFFIX = "SUCCESS\n"
|
private const val NOISYSU_SUFFIX = "SUCCESS\n"
|
||||||
fun loggerSuStream(command: String): InputStream {
|
fun loggerSuStream(command: String): InputStream {
|
||||||
|
|||||||
58
mobile/src/main/java/be/mygod/vpnhotspot/VpnListener.kt
Normal file
58
mobile/src/main/java/be/mygod/vpnhotspot/VpnListener.kt
Normal file
@@ -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<Callback>()
|
||||||
|
private var registered = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtaining ifname in onLost doesn't work so we need to cache it in onAvailable.
|
||||||
|
*/
|
||||||
|
private val available = HashMap<Network, String>()
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<View>(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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
9
mobile/src/main/res/drawable/ic_device_bluetooth.xml
Normal file
9
mobile/src/main/res/drawable/ic_device_bluetooth.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24.0"
|
||||||
|
android:viewportHeight="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M17.71,7.71L12,2h-1v7.59L6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 11,14.41L11,22h1l5.71,-5.71 -4.3,-4.29 4.3,-4.29zM13,5.83l1.88,1.88L13,9.59L13,5.83zM14.88,16.29L13,18.17v-3.76l1.88,1.88z"/>
|
||||||
|
</vector>
|
||||||
13
mobile/src/main/res/drawable/ic_device_network_wifi.xml
Normal file
13
mobile/src/main/res/drawable/ic_device_network_wifi.xml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24.0"
|
||||||
|
android:viewportHeight="24.0">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M12.01,21.49L23.64,7c-0.45,-0.34 -4.93,-4 -11.64,-4C5.28,3 0.81,6.66 0.36,7l11.63,14.49 0.01,0.01 0.01,-0.01z"
|
||||||
|
android:fillAlpha=".3"/>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M3.53,10.95l8.46,10.54 0.01,0.01 0.01,-0.01 8.46,-10.54C20.04,10.62 16.81,8 12,8c-4.81,0 -8.04,2.62 -8.47,2.95z"/>
|
||||||
|
</vector>
|
||||||
5
mobile/src/main/res/drawable/ic_device_usb.xml
Normal file
5
mobile/src/main/res/drawable/ic_device_usb.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<vector android:autoMirrored="true" android:height="24dp"
|
||||||
|
android:viewportHeight="24.0" android:viewportWidth="24.0"
|
||||||
|
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<path android:fillColor="#FF000000" android:pathData="M15,7v4h1v2h-3V5h2l-3,-4 -3,4h2v8H8v-2.07c0.7,-0.37 1.2,-1.08 1.2,-1.93 0,-1.21 -0.99,-2.2 -2.2,-2.2 -1.21,0 -2.2,0.99 -2.2,2.2 0,0.85 0.5,1.56 1.2,1.93V13c0,1.11 0.89,2 2,2h3v3.05c-0.71,0.37 -1.2,1.1 -1.2,1.95 0,1.22 0.99,2.2 2.2,2.2 1.21,0 2.2,-0.98 2.2,-2.2 0,-0.85 -0.49,-1.58 -1.2,-1.95V15h3c1.11,0 2,-0.89 2,-2v-2h1V7h-4z"/>
|
||||||
|
</vector>
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
android:width="24dp"
|
android:width="24dp"
|
||||||
android:height="24dp"
|
android:height="24dp"
|
||||||
android:viewportWidth="24.0"
|
android:viewportWidth="24.0"
|
||||||
android:viewportHeight="24.0">
|
android:viewportHeight="24.0"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
<path
|
<path
|
||||||
android:fillColor="#FFFFFFFF"
|
android:fillColor="#FFFFFFFF"
|
||||||
android:pathData="M12,11c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM18,13c0,-3.31 -2.69,-6 -6,-6s-6,2.69 -6,6c0,2.22 1.21,4.15 3,5.19l1,-1.74c-1.19,-0.7 -2,-1.97 -2,-3.45 0,-2.21 1.79,-4 4,-4s4,1.79 4,4c0,1.48 -0.81,2.75 -2,3.45l1,1.74c1.79,-1.04 3,-2.97 3,-5.19zM12,3C6.48,3 2,7.48 2,13c0,3.7 2.01,6.92 4.99,8.65l1,-1.73C5.61,18.53 4,15.96 4,13c0,-4.42 3.58,-8 8,-8s8,3.58 8,8c0,2.96 -1.61,5.53 -4,6.92l1,1.73c2.99,-1.73 5,-4.95 5,-8.65 0,-5.52 -4.48,-10 -10,-10z"/>
|
android:pathData="M12,11c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM18,13c0,-3.31 -2.69,-6 -6,-6s-6,2.69 -6,6c0,2.22 1.21,4.15 3,5.19l1,-1.74c-1.19,-0.7 -2,-1.97 -2,-3.45 0,-2.21 1.79,-4 4,-4s4,1.79 4,4c0,1.48 -0.81,2.75 -2,3.45l1,1.74c1.79,-1.04 3,-2.97 3,-5.19zM12,3C6.48,3 2,7.48 2,13c0,3.7 2.01,6.92 4.99,8.65l1,-1.73C5.61,18.53 4,15.96 4,13c0,-4.42 3.58,-8 8,-8s8,3.58 8,8c0,2.96 -1.61,5.53 -4,6.92l1,1.73c2.99,-1.73 5,-4.95 5,-8.65 0,-5.52 -4.48,-10 -10,-10z"/>
|
||||||
|
|||||||
@@ -3,111 +3,34 @@
|
|||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
<data>
|
<android.support.constraint.ConstraintLayout
|
||||||
<variable
|
|
||||||
name="data"
|
|
||||||
type="be.mygod.vpnhotspot.MainActivity.Data"/>
|
|
||||||
</data>
|
|
||||||
<LinearLayout
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent">
|
|
||||||
<android.support.v7.widget.Toolbar
|
|
||||||
android:layout_height="?attr/actionBarSize"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:background="?attr/colorPrimary"
|
|
||||||
android:elevation="4dp"
|
|
||||||
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
|
|
||||||
app:popupTheme="@style/ThemeOverlay.AppCompat.Light"
|
|
||||||
android:id="@+id/toolbar">
|
|
||||||
|
|
||||||
<Switch
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:enabled="@{data.switchEnabled}"
|
tools:context="be.mygod.vpnhotspot.MainActivity">
|
||||||
android:checked="@{data.serviceStarted}"
|
|
||||||
android:onCheckedChanged="@{(_, checked) -> data.setServiceStarted(checked)}"
|
|
||||||
android:text="@string/app_name"
|
|
||||||
android:textAppearance="@style/TextAppearance.Widget.AppCompat.Toolbar.Title"/>
|
|
||||||
</android.support.v7.widget.Toolbar>
|
|
||||||
|
|
||||||
<GridLayout
|
<FrameLayout
|
||||||
android:layout_width="match_parent"
|
android:id="@+id/fragmentHolder"
|
||||||
android:layout_height="wrap_content"
|
android:layout_width="0dp"
|
||||||
android:paddingBottom="8dp"
|
|
||||||
android:paddingEnd="16dp"
|
|
||||||
android:paddingStart="16dp"
|
|
||||||
android:paddingTop="8dp">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="Network name"
|
|
||||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead"/>
|
|
||||||
|
|
||||||
<Space
|
|
||||||
android:layout_width="8dp"
|
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:layout_column="1"
|
android:layout_marginEnd="0dp"
|
||||||
android:layout_row="0"/>
|
android:layout_marginStart="0dp"
|
||||||
|
app:layout_constraintBottom_toTopOf="@+id/navigation"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"/>
|
||||||
|
|
||||||
<TextView
|
<android.support.design.widget.BottomNavigationView
|
||||||
android:layout_width="wrap_content"
|
android:id="@+id/navigation"
|
||||||
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_column="2"
|
android:layout_marginEnd="0dp"
|
||||||
android:layout_row="0"
|
android:layout_marginStart="0dp"
|
||||||
android:text="@{data.ssid}"
|
android:background="?android:attr/windowBackground"
|
||||||
android:textIsSelectable="true"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
tools:text="DIRECT-rAnd0m"/>
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
|
app:menu="@menu/navigation"/>
|
||||||
|
|
||||||
<TextView
|
</android.support.constraint.ConstraintLayout>
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_column="0"
|
|
||||||
android:layout_row="1"
|
|
||||||
android:text="Password"
|
|
||||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead"/>
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_column="2"
|
|
||||||
android:layout_row="1"
|
|
||||||
android:text="@{data.password}"
|
|
||||||
android:textIsSelectable="true"
|
|
||||||
tools:text="p4ssW0rd"/>
|
|
||||||
</GridLayout>
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:paddingStart="16dp"
|
|
||||||
android:paddingEnd="16dp"
|
|
||||||
android:text="Connected devices"
|
|
||||||
android:textAppearance="@style/TextAppearance.AppCompat.Medium"/>
|
|
||||||
|
|
||||||
<View
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="1dp"
|
|
||||||
android:background="#000"
|
|
||||||
android:backgroundTint="?android:attr/textColorSecondary"/>
|
|
||||||
|
|
||||||
<android.support.v4.widget.SwipeRefreshLayout
|
|
||||||
android:id="@+id/swipeRefresher"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent">
|
|
||||||
|
|
||||||
<android.support.v7.widget.RecyclerView
|
|
||||||
android:id="@+id/clients"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:paddingBottom="8dp"
|
|
||||||
android:paddingEnd="16dp"
|
|
||||||
android:paddingStart="16dp"
|
|
||||||
android:paddingTop="8dp"
|
|
||||||
android:clipToPadding="false"
|
|
||||||
android:scrollbars="vertical"
|
|
||||||
tools:listitem="@layout/listitem_client"/>
|
|
||||||
</android.support.v4.widget.SwipeRefreshLayout>
|
|
||||||
</LinearLayout>
|
|
||||||
</layout>
|
</layout>
|
||||||
|
|||||||
115
mobile/src/main/res/layout/fragment_repeater.xml
Normal file
115
mobile/src/main/res/layout/fragment_repeater.xml
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<layout
|
||||||
|
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">
|
||||||
|
<data>
|
||||||
|
<variable
|
||||||
|
name="data"
|
||||||
|
type="be.mygod.vpnhotspot.RepeaterFragment.Data"/>
|
||||||
|
</data>
|
||||||
|
<LinearLayout
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
<android.support.v7.widget.Toolbar
|
||||||
|
android:layout_height="?attr/actionBarSize"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:background="?attr/colorPrimary"
|
||||||
|
android:elevation="4dp"
|
||||||
|
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
|
||||||
|
app:popupTheme="@style/ThemeOverlay.AppCompat.Light"
|
||||||
|
android:id="@+id/toolbar">
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:enabled="@{data.switchEnabled}"
|
||||||
|
android:checked="@{data.serviceStarted}"
|
||||||
|
android:onCheckedChanged="@{(_, checked) -> data.setServiceStarted(checked)}"
|
||||||
|
android:paddingEnd="8dp"
|
||||||
|
android:text="@string/app_name"
|
||||||
|
android:textAppearance="@style/TextAppearance.Widget.AppCompat.Toolbar.Title"
|
||||||
|
tools:ignore="RtlSymmetry"/>
|
||||||
|
</android.support.v7.widget.Toolbar>
|
||||||
|
|
||||||
|
<GridLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingBottom="8dp"
|
||||||
|
android:paddingEnd="16dp"
|
||||||
|
android:paddingStart="16dp"
|
||||||
|
android:paddingTop="8dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Network name"
|
||||||
|
android:textAppearance="@style/TextAppearance.AppCompat.Subhead"/>
|
||||||
|
|
||||||
|
<Space
|
||||||
|
android:layout_width="8dp"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_column="1"
|
||||||
|
android:layout_row="0"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_column="2"
|
||||||
|
android:layout_row="0"
|
||||||
|
android:text="@{data.ssid}"
|
||||||
|
android:textIsSelectable="true"
|
||||||
|
tools:text="DIRECT-rAnd0m"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_column="0"
|
||||||
|
android:layout_row="1"
|
||||||
|
android:text="Password"
|
||||||
|
android:textAppearance="@style/TextAppearance.AppCompat.Subhead"/>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_column="2"
|
||||||
|
android:layout_row="1"
|
||||||
|
android:text="@{data.password}"
|
||||||
|
android:textIsSelectable="true"
|
||||||
|
tools:text="p4ssW0rd"/>
|
||||||
|
</GridLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingStart="16dp"
|
||||||
|
android:paddingEnd="16dp"
|
||||||
|
android:text="Connected devices"
|
||||||
|
android:textAppearance="@style/TextAppearance.AppCompat.Medium"/>
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:background="#000"
|
||||||
|
android:backgroundTint="?android:attr/textColorSecondary"/>
|
||||||
|
|
||||||
|
<android.support.v4.widget.SwipeRefreshLayout
|
||||||
|
android:id="@+id/swipeRefresher"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<android.support.v7.widget.RecyclerView
|
||||||
|
android:id="@+id/clients"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:paddingBottom="8dp"
|
||||||
|
android:paddingEnd="16dp"
|
||||||
|
android:paddingStart="16dp"
|
||||||
|
android:paddingTop="8dp"
|
||||||
|
android:clipToPadding="false"
|
||||||
|
android:scrollbars="vertical"
|
||||||
|
tools:listitem="@layout/listitem_client"/>
|
||||||
|
</android.support.v4.widget.SwipeRefreshLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
</layout>
|
||||||
23
mobile/src/main/res/layout/fragment_settings.xml
Normal file
23
mobile/src/main/res/layout/fragment_settings.xml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
<android.support.v7.widget.Toolbar
|
||||||
|
android:layout_height="?attr/actionBarSize"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:background="?attr/colorPrimary"
|
||||||
|
android:elevation="4dp"
|
||||||
|
app:title="@string/app_name"
|
||||||
|
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
|
||||||
|
app:popupTheme="@style/ThemeOverlay.AppCompat.Light"
|
||||||
|
android:id="@+id/toolbar"/>
|
||||||
|
<fragment
|
||||||
|
class="be.mygod.vpnhotspot.SettingsPreferenceFragment"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:id="@+id/preference"/>
|
||||||
|
</LinearLayout>
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<layout
|
<layout
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@@ -11,16 +12,17 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:background="?attr/colorPrimary"
|
android:background="?attr/colorPrimary"
|
||||||
android:elevation="4dp"
|
android:elevation="4dp"
|
||||||
app:title="@string/title_activity_settings"
|
app:title="@string/app_name"
|
||||||
app:navigationIcon="?attr/homeAsUpIndicator"
|
|
||||||
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
|
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
|
||||||
app:popupTheme="@style/ThemeOverlay.AppCompat.Light"
|
app:popupTheme="@style/ThemeOverlay.AppCompat.Light"
|
||||||
android:id="@+id/toolbar"/>
|
android:id="@+id/toolbar"/>
|
||||||
<fragment
|
<android.support.v7.widget.RecyclerView
|
||||||
class="be.mygod.vpnhotspot.SettingsFragment"
|
android:id="@+id/interfaces"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:id="@+id/preference"/>
|
android:clipToPadding="false"
|
||||||
|
android:scrollbars="vertical"
|
||||||
|
tools:listitem="@layout/listitem_interface"/>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</layout>
|
</layout>
|
||||||
44
mobile/src/main/res/layout/listitem_interface.xml
Normal file
44
mobile/src/main/res/layout/listitem_interface.xml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<layout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<data>
|
||||||
|
<variable
|
||||||
|
name="data"
|
||||||
|
type="be.mygod.vpnhotspot.TetheringFragment.Data"/>
|
||||||
|
</data>
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:focusable="true"
|
||||||
|
android:background="?android:attr/selectableItemBackground"
|
||||||
|
android:paddingBottom="8dp"
|
||||||
|
android:paddingEnd="16dp"
|
||||||
|
android:paddingStart="16dp"
|
||||||
|
android:paddingTop="8dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:src="@{data.icon}"
|
||||||
|
tools:src="@drawable/ic_device_network_wifi"/>
|
||||||
|
|
||||||
|
<Space
|
||||||
|
android:layout_width="8dp"
|
||||||
|
android:layout_height="0dp"/>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:clickable="false"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:focusable="false"
|
||||||
|
android:focusableInTouchMode="false"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:text="@{data.iface}"
|
||||||
|
android:checked="@{data.active}"
|
||||||
|
tools:text="wlan0"/>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</layout>
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
|
||||||
<item
|
|
||||||
android:id="@+id/reapply"
|
|
||||||
android:icon="@drawable/ic_navigation_refresh"
|
|
||||||
android:title="Reapply routing rules"
|
|
||||||
app:showAsAction="always"/>
|
|
||||||
<item
|
|
||||||
android:id="@+id/settings"
|
|
||||||
android:icon="@drawable/ic_action_settings"
|
|
||||||
android:title="@string/title_activity_settings"
|
|
||||||
app:showAsAction="always"/>
|
|
||||||
</menu>
|
|
||||||
19
mobile/src/main/res/menu/navigation.xml
Normal file
19
mobile/src/main/res/menu/navigation.xml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/navigation_repeater"
|
||||||
|
android:icon="@drawable/ic_device_network_wifi"
|
||||||
|
android:title="Repeater"/>
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/navigation_tethering"
|
||||||
|
android:icon="@drawable/ic_device_wifi_tethering"
|
||||||
|
android:title="Tethering"/>
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/navigation_settings"
|
||||||
|
android:icon="@drawable/ic_action_settings"
|
||||||
|
android:title="@string/title_activity_settings"/>
|
||||||
|
|
||||||
|
</menu>
|
||||||
@@ -2,21 +2,15 @@
|
|||||||
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
|
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<PreferenceCategory
|
<PreferenceCategory
|
||||||
android:title="Service">
|
android:title="Service">
|
||||||
<be.mygod.vpnhotspot.preference.AlwaysAutoCompleteEditTextPreference
|
|
||||||
android:key="service.upstream"
|
|
||||||
android:title="Upstream interface"
|
|
||||||
android:summary="%s"
|
|
||||||
android:defaultValue="tun0"/>
|
|
||||||
<be.mygod.vpnhotspot.preference.AlwaysAutoCompleteEditTextPreference
|
|
||||||
android:key="service.wifi"
|
|
||||||
android:title="Wi-Fi interface"
|
|
||||||
android:summary="%s"
|
|
||||||
android:defaultValue="wlan0"/>
|
|
||||||
<AutoSummaryEditTextPreference
|
<AutoSummaryEditTextPreference
|
||||||
android:key="service.dns"
|
android:key="service.dns"
|
||||||
android:title="Downstream DNS server:port"
|
android:title="Downstream DNS server:port"
|
||||||
android:summary="%s"
|
android:summary="%s"
|
||||||
android:defaultValue="8.8.8.8:53"/>
|
android:defaultValue="8.8.8.8:53"/>
|
||||||
|
<Preference
|
||||||
|
android:key="service.clean"
|
||||||
|
android:title="Clean routing rules"
|
||||||
|
android:summary="Only use after having shut down everything"/>
|
||||||
</PreferenceCategory>
|
</PreferenceCategory>
|
||||||
<PreferenceCategory
|
<PreferenceCategory
|
||||||
android:title="Misc">
|
android:title="Misc">
|
||||||
|
|||||||
Reference in New Issue
Block a user