Support VPN over any native tethering

First big refactoring of this app.
This commit is contained in:
Mygod
2018-01-13 00:41:55 +08:00
parent f341bf409e
commit eb165db86c
31 changed files with 1068 additions and 874 deletions

View File

@@ -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>

View File

@@ -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")
} }

View File

@@ -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)
}

View File

@@ -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()
}
}

View File

@@ -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()
}
} }

View File

@@ -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
}
} }

View 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()
}
}

View 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()
}
}

View File

@@ -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")

View File

@@ -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() })
}
}

View File

@@ -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)
}
} }

View File

@@ -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
}
}
}

View 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()
}
}

View 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()
}
}

View File

@@ -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 {

View 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()
}
}

View File

@@ -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()
}
}
}

View File

@@ -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()
}
}

View File

@@ -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
}
}
}
}

View 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>

View 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>

View 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>

View File

@@ -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"/>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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>

View 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>

View File

@@ -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>

View 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>

View File

@@ -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">