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">
|
||||
|
||||
<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_WIFI_STATE"/>
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/>
|
||||
@@ -17,8 +20,8 @@
|
||||
android:theme="@style/AppTheme">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:launchMode="singleInstance"
|
||||
android:label="@string/app_name">
|
||||
android:label="@string/app_name"
|
||||
android:launchMode="singleInstance">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
|
||||
@@ -26,13 +29,10 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service android:name=".HotspotService">
|
||||
<service android:name=".RepeaterService">
|
||||
</service>
|
||||
<service android:name=".TetheringService">
|
||||
</service>
|
||||
|
||||
<activity
|
||||
android:name=".SettingsActivity"
|
||||
android:label="@string/title_activity_settings"
|
||||
android:parentActivityName=".MainActivity"/>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -4,10 +4,8 @@ import android.app.Application
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.SharedPreferences
|
||||
import android.content.res.Resources
|
||||
import android.os.Build
|
||||
import android.preference.PreferenceManager
|
||||
import java.net.NetworkInterface
|
||||
|
||||
class App : Application() {
|
||||
companion object {
|
||||
@@ -18,21 +16,10 @@ class App : Application() {
|
||||
super.onCreate()
|
||||
app = this
|
||||
if (Build.VERSION.SDK_INT >= 26) getSystemService(NotificationManager::class.java)
|
||||
.createNotificationChannel(NotificationChannel(HotspotService.CHANNEL,
|
||||
.createNotificationChannel(NotificationChannel(RepeaterService.CHANNEL,
|
||||
"Hotspot Service", NotificationManager.IMPORTANCE_LOW))
|
||||
pref = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
val wifiRegexes = resources.getStringArray(Resources.getSystem()
|
||||
.getIdentifier("config_tether_wifi_regexs", "array", "android"))
|
||||
.map { it.toPattern() }
|
||||
wifiInterfaces = NetworkInterface.getNetworkInterfaces().asSequence()
|
||||
.map { it.name }
|
||||
.filter { ifname -> wifiRegexes.any { it.matcher(ifname).matches() } }
|
||||
.sorted().toList().toTypedArray()
|
||||
val wifiInterface = wifiInterfaces.singleOrNull()
|
||||
if (wifiInterface != null && pref.getString(HotspotService.KEY_WIFI, null) == null)
|
||||
pref.edit().putString(HotspotService.KEY_WIFI, wifiInterface).apply()
|
||||
}
|
||||
|
||||
lateinit var pref: SharedPreferences
|
||||
lateinit var wifiInterfaces: Array<String>
|
||||
val pref: SharedPreferences by lazy { PreferenceManager.getDefaultSharedPreferences(this) }
|
||||
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
|
||||
|
||||
import android.content.*
|
||||
import android.databinding.BaseObservable
|
||||
import android.databinding.Bindable
|
||||
import android.databinding.DataBindingUtil
|
||||
import android.net.wifi.p2p.WifiP2pDevice
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.support.v4.content.ContextCompat
|
||||
import android.support.v4.content.LocalBroadcastManager
|
||||
import android.support.design.widget.BottomNavigationView
|
||||
import android.support.v4.app.Fragment
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.support.v7.widget.DefaultItemAnimator
|
||||
import android.support.v7.widget.LinearLayoutManager
|
||||
import android.support.v7.widget.RecyclerView
|
||||
import android.support.v7.widget.Toolbar
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.ViewGroup
|
||||
import be.mygod.vpnhotspot.databinding.ActivityMainBinding
|
||||
import be.mygod.vpnhotspot.databinding.ListitemClientBinding
|
||||
|
||||
class MainActivity : AppCompatActivity(), ServiceConnection, Toolbar.OnMenuItemClickListener {
|
||||
inner class Data : BaseObservable() {
|
||||
val switchEnabled: Boolean
|
||||
@Bindable get() = when (binder?.service?.status) {
|
||||
HotspotService.Status.IDLE, HotspotService.Status.ACTIVE_P2P, HotspotService.Status.ACTIVE_AP -> true
|
||||
else -> false
|
||||
}
|
||||
var serviceStarted: Boolean
|
||||
@Bindable get() = when (binder?.service?.status) {
|
||||
HotspotService.Status.STARTING, HotspotService.Status.ACTIVE_P2P, HotspotService.Status.ACTIVE_AP ->
|
||||
true
|
||||
else -> false
|
||||
}
|
||||
set(value) {
|
||||
val binder = binder
|
||||
when (binder?.service?.status) {
|
||||
HotspotService.Status.IDLE ->
|
||||
if (value) ContextCompat.startForegroundService(this@MainActivity,
|
||||
Intent(this@MainActivity, HotspotService::class.java))
|
||||
HotspotService.Status.ACTIVE_P2P, HotspotService.Status.ACTIVE_AP -> if (!value) binder.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
val ssid @Bindable get() = binder?.service?.ssid ?: "Service inactive"
|
||||
val password @Bindable get() = binder?.service?.password ?: ""
|
||||
|
||||
fun onStatusChanged() {
|
||||
notifyPropertyChanged(BR.switchEnabled)
|
||||
notifyPropertyChanged(BR.serviceStarted)
|
||||
onGroupChanged()
|
||||
}
|
||||
fun onGroupChanged() {
|
||||
notifyPropertyChanged(BR.ssid)
|
||||
notifyPropertyChanged(BR.password)
|
||||
adapter.fetchClients()
|
||||
}
|
||||
|
||||
val statusListener = broadcastReceiver { _, _ -> onStatusChanged() }
|
||||
}
|
||||
|
||||
class ClientViewHolder(val binding: ListitemClientBinding) : RecyclerView.ViewHolder(binding.root)
|
||||
inner class ClientAdapter : RecyclerView.Adapter<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 val data = Data()
|
||||
private val adapter = ClientAdapter()
|
||||
private var binder: HotspotService.HotspotBinder? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
|
||||
binding.data = data
|
||||
binding.clients.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
|
||||
val animator = DefaultItemAnimator()
|
||||
animator.supportsChangeAnimations = false // prevent fading-in/out when rebinding
|
||||
binding.clients.itemAnimator = animator
|
||||
binding.clients.adapter = adapter
|
||||
binding.toolbar.inflateMenu(R.menu.main)
|
||||
binding.toolbar.setOnMenuItemClickListener(this)
|
||||
binding.swipeRefresher.setOnRefreshListener { adapter.fetchClients() }
|
||||
binding.navigation.setOnNavigationItemSelectedListener(this)
|
||||
onNavigationItemSelected(binding.navigation.menu.getItem(0))
|
||||
}
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem): Boolean = when (item.itemId) {
|
||||
R.id.reapply -> {
|
||||
val binder = binder
|
||||
when (binder?.service?.status) {
|
||||
HotspotService.Status.IDLE -> Routing.clean()
|
||||
HotspotService.Status.ACTIVE_P2P, HotspotService.Status.ACTIVE_AP -> binder.reapplyRouting()
|
||||
}
|
||||
override fun onNavigationItemSelected(item: MenuItem) = when (item.itemId) {
|
||||
R.id.navigation_repeater -> {
|
||||
item.isChecked = true
|
||||
displayFragment(RepeaterFragment())
|
||||
true
|
||||
}
|
||||
R.id.settings -> {
|
||||
startActivity(Intent(this, SettingsActivity::class.java))
|
||||
R.id.navigation_tethering -> {
|
||||
item.isChecked = true
|
||||
displayFragment(TetheringFragment())
|
||||
true
|
||||
}
|
||||
R.id.navigation_settings -> {
|
||||
item.isChecked = true
|
||||
displayFragment(SettingsFragment())
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
bindService(Intent(this, HotspotService::class.java), this, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
onServiceDisconnected(null)
|
||||
unbindService(this)
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
val binder = service as HotspotService.HotspotBinder
|
||||
binder.data = data
|
||||
this.binder = binder
|
||||
data.onStatusChanged()
|
||||
LocalBroadcastManager.getInstance(this)
|
||||
.registerReceiver(data.statusListener, intentFilter(HotspotService.STATUS_CHANGED))
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
binder?.data = null
|
||||
binder = null
|
||||
LocalBroadcastManager.getInstance(this).unregisterReceiver(data.statusListener)
|
||||
data.onStatusChanged()
|
||||
}
|
||||
private fun displayFragment(fragment: Fragment) =
|
||||
supportFragmentManager.beginTransaction().replace(R.id.fragmentHolder, fragment).commit()
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
package be.mygod.vpnhotspot
|
||||
|
||||
import android.net.wifi.WifiConfiguration
|
||||
import android.util.Log
|
||||
import java.io.DataInputStream
|
||||
import android.net.ConnectivityManager
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
object NetUtils {
|
||||
private const val TAG = "NetUtils"
|
||||
// hidden constants from ConnectivityManager
|
||||
const val ACTION_TETHER_STATE_CHANGED = "android.net.conn.TETHER_STATE_CHANGED"
|
||||
const val EXTRA_ACTIVE_TETHER = "tetherArray"
|
||||
|
||||
private val spaces = " +".toPattern()
|
||||
private val mac = "^([0-9a-f]{2}:){5}[0-9a-f]{2}$".toPattern()
|
||||
|
||||
private val getTetheredIfaces = ConnectivityManager::class.java.getDeclaredMethod("getTetheredIfaces")
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val ConnectivityManager.tetheredIfaces get() = getTetheredIfaces.invoke(this) as Array<String>
|
||||
|
||||
fun arp(iface: String? = null) = File("/proc/net/arp").bufferedReader().useLines {
|
||||
// IP address HW type Flags HW address Mask Device
|
||||
it.map { it.split(spaces) }
|
||||
@@ -19,34 +23,4 @@ object NetUtils {
|
||||
mac.matcher(it[3]).matches() }
|
||||
.associateBy({ it[3] }, { it[0] })
|
||||
}
|
||||
|
||||
/**
|
||||
* Load AP configuration from persistent storage.
|
||||
*
|
||||
* Based on: https://android.googlesource.com/platform/frameworks/opt/net/wifi/+/0cafbe0/service/java/com/android/server/wifi/WifiApConfigStore.java#138
|
||||
*/
|
||||
fun loadApConfiguration(): WifiConfiguration? = try {
|
||||
loggerSuStream("cat /data/misc/wifi/softap.conf").buffered().use {
|
||||
val data = DataInputStream(it)
|
||||
val version = data.readInt()
|
||||
when (version) {
|
||||
1, 2 -> {
|
||||
val config = WifiConfiguration()
|
||||
config.SSID = data.readUTF()
|
||||
if (version >= 2) data.readLong() // apBand and apChannel
|
||||
val authType = data.readInt()
|
||||
config.allowedKeyManagement.set(authType)
|
||||
if (authType != WifiConfiguration.KeyMgmt.NONE) config.preSharedKey = data.readUTF()
|
||||
config
|
||||
}
|
||||
else -> {
|
||||
Log.e(TAG, "Bad version on hotspot configuration file $version")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Error reading hotspot configuration $e")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
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.util.*
|
||||
|
||||
class Routing(val upstream: String, val downstream: String, ownerAddress: InetAddress? = null) {
|
||||
class Routing(private val upstream: String, val downstream: String, ownerAddress: InetAddress? = null) {
|
||||
companion object {
|
||||
fun clean() = noisySu(
|
||||
"iptables -t nat -F PREROUTING",
|
||||
@@ -25,7 +25,8 @@ class Routing(val upstream: String, val downstream: String, ownerAddress: InetAd
|
||||
?.singleOrNull { it is Inet4Address } ?: throw InterfaceNotFoundException()
|
||||
private val startScript = LinkedList<String>()
|
||||
private val stopScript = LinkedList<String>()
|
||||
private var started = false
|
||||
var started = false
|
||||
private set
|
||||
|
||||
fun ipForward(): Routing {
|
||||
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
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.support.customtabs.CustomTabsIntent
|
||||
import android.support.v4.content.ContextCompat
|
||||
import android.support.v7.preference.Preference
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import be.mygod.vpnhotspot.preference.AlwaysAutoCompleteEditTextPreferenceDialogFragmentCompat
|
||||
import com.takisoft.fix.support.v7.preference.PreferenceFragmentCompatDividers
|
||||
import java.net.NetworkInterface
|
||||
import android.support.v4.app.Fragment
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
|
||||
class SettingsFragment : PreferenceFragmentCompatDividers() {
|
||||
private val customTabsIntent by lazy {
|
||||
CustomTabsIntent.Builder().setToolbarColor(ContextCompat.getColor(activity!!, R.color.colorPrimary)).build()
|
||||
}
|
||||
|
||||
override fun onCreatePreferencesFix(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_settings)
|
||||
findPreference("misc.logcat").setOnPreferenceClickListener {
|
||||
val intent = Intent(Intent.ACTION_SEND)
|
||||
.setType("text/plain")
|
||||
.putExtra(Intent.EXTRA_TEXT, Runtime.getRuntime().exec(arrayOf("logcat", "-d"))
|
||||
.inputStream.bufferedReader().use { it.readText() })
|
||||
startActivity(Intent.createChooser(intent, getString(R.string.abc_shareactionprovider_share_with)))
|
||||
true
|
||||
}
|
||||
findPreference("misc.source").setOnPreferenceClickListener {
|
||||
customTabsIntent.launchUrl(activity, Uri.parse("https://github.com/Mygod/VPNHotspot"))
|
||||
true
|
||||
}
|
||||
findPreference("misc.donate").setOnPreferenceClickListener {
|
||||
customTabsIntent.launchUrl(activity, Uri.parse("https://mygod.be/donate/"))
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDisplayPreferenceDialog(preference: Preference) = when (preference.key) {
|
||||
HotspotService.KEY_UPSTREAM -> displayPreferenceDialog(
|
||||
AlwaysAutoCompleteEditTextPreferenceDialogFragmentCompat(), HotspotService.KEY_UPSTREAM,
|
||||
Bundle().put(AlwaysAutoCompleteEditTextPreferenceDialogFragmentCompat.KEY_SUGGESTIONS,
|
||||
NetworkInterface.getNetworkInterfaces().asSequence()
|
||||
.filter { it.isUp && !it.isLoopback && it.interfaceAddresses.isNotEmpty() }
|
||||
.map { it.name }.sorted().toList().toTypedArray()))
|
||||
HotspotService.KEY_WIFI -> displayPreferenceDialog(
|
||||
AlwaysAutoCompleteEditTextPreferenceDialogFragmentCompat(), HotspotService.KEY_WIFI, Bundle()
|
||||
.put(AlwaysAutoCompleteEditTextPreferenceDialogFragmentCompat.KEY_SUGGESTIONS, app.wifiInterfaces))
|
||||
else -> super.onDisplayPreferenceDialog(preference)
|
||||
}
|
||||
class SettingsFragment : Fragment() {
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? =
|
||||
inflater.inflate(R.layout.fragment_settings, container, false)
|
||||
}
|
||||
|
||||
@@ -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.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.TaskStackBuilder
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.util.Log
|
||||
import java.io.InputStream
|
||||
|
||||
@@ -24,18 +21,6 @@ fun intentFilter(vararg actions: String): IntentFilter {
|
||||
return result
|
||||
}
|
||||
|
||||
fun AppCompatActivity.navigateUp() {
|
||||
val intent = parentActivityIntent
|
||||
if (shouldUpRecreateTask(intent))
|
||||
TaskStackBuilder.create(this).addNextIntentWithParentStack(intent).startActivities()
|
||||
else navigateUpTo(intent)
|
||||
}
|
||||
|
||||
fun Bundle.put(key: String, map: Array<String>): Bundle {
|
||||
putStringArray(key, map)
|
||||
return this
|
||||
}
|
||||
|
||||
private const val NOISYSU_TAG = "NoisySU"
|
||||
private const val NOISYSU_SUFFIX = "SUCCESS\n"
|
||||
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:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
android:viewportHeight="24.0"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
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"/>
|
||||
|
||||
@@ -3,111 +3,34 @@
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<data>
|
||||
<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.support.constraint.ConstraintLayout
|
||||
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:text="@string/app_name"
|
||||
android:textAppearance="@style/TextAppearance.Widget.AppCompat.Toolbar.Title"/>
|
||||
</android.support.v7.widget.Toolbar>
|
||||
tools:context="be.mygod.vpnhotspot.MainActivity">
|
||||
|
||||
<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"
|
||||
<FrameLayout
|
||||
android:id="@+id/fragmentHolder"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_column="1"
|
||||
android:layout_row="0"/>
|
||||
android:layout_marginEnd="0dp"
|
||||
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:layout_width="wrap_content"
|
||||
<android.support.design.widget.BottomNavigationView
|
||||
android:id="@+id/navigation"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_column="2"
|
||||
android:layout_row="0"
|
||||
android:text="@{data.ssid}"
|
||||
android:textIsSelectable="true"
|
||||
tools:text="DIRECT-rAnd0m"/>
|
||||
android:layout_marginEnd="0dp"
|
||||
android:layout_marginStart="0dp"
|
||||
android:background="?android:attr/windowBackground"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:menu="@menu/navigation"/>
|
||||
|
||||
<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"/>
|
||||
</android.support.constraint.ConstraintLayout>
|
||||
|
||||
<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>
|
||||
|
||||
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"?>
|
||||
<layout
|
||||
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
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
@@ -11,16 +12,17 @@
|
||||
android:layout_width="match_parent"
|
||||
android:background="?attr/colorPrimary"
|
||||
android:elevation="4dp"
|
||||
app:title="@string/title_activity_settings"
|
||||
app:navigationIcon="?attr/homeAsUpIndicator"
|
||||
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.SettingsFragment"
|
||||
<android.support.v7.widget.RecyclerView
|
||||
android:id="@+id/interfaces"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:id="@+id/preference"/>
|
||||
android:clipToPadding="false"
|
||||
android:scrollbars="vertical"
|
||||
tools:listitem="@layout/listitem_interface"/>
|
||||
</LinearLayout>
|
||||
</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">
|
||||
<PreferenceCategory
|
||||
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
|
||||
android:key="service.dns"
|
||||
android:title="Downstream DNS server:port"
|
||||
android:summary="%s"
|
||||
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
|
||||
android:title="Misc">
|
||||
|
||||
Reference in New Issue
Block a user