librootkotlinx

Fixes #14, #27, #114, #117.
This commit is contained in:
Mygod
2020-06-21 05:33:39 +08:00
parent 7b1f610f9a
commit ad218d7ec6
51 changed files with 1781 additions and 574 deletions

View File

@@ -31,6 +31,14 @@ I don't know about you but I can't get my stupid Windows 10 to work with
now that they introduced this
[Mobile hotspot](https://support.microsoft.com/en-us/help/4027762/windows-use-your-pc-as-a-mobile-hotspot).
## Features that requires system app installation
The following features in the app requires it to be installed under `/system/priv-app`.
One way to do this is to use [App systemizer for Magisk](https://github.com/Magisk-Modules-Repo/terminal_systemizer).
* (prior to Android 11) Read/write system Wi-Fi hotspot configuration. ([#117](https://github.com/Mygod/VPNHotspot/issues/117))
* (since Android 11) Use the Bluetooth tethering shortcut switch in app.
## Settings and How to Use Them
Default settings are picked to suit general use cases and maximize compatibility but it might not be optimal for battery
@@ -271,4 +279,4 @@ If some of these are unavailable, you can alternatively install a recent version
Wi-Fi driver `wpa_supplicant`:
* P2P configuration file is assumed to be saved to [`/data/vendor/wifi/wpa/p2p_supplicant.conf` or `/data/misc/wifi/p2p_supplicant.conf`](https://android.googlesource.com/platform/external/wpa_supplicant_8/+/0b4856b6dc451e290f1f64f6af17e010be78c073/wpa_supplicant/hidl/1.1/supplicant.cpp#26) and have reasonable format;
* Android system is expected to restart `wpa_supplicant` after it crashes.
* Android system is expected to restart `wpa_supplicant` after it terminates.

View File

@@ -15,7 +15,7 @@ buildscript {
classpath(kotlin("gradle-plugin", kotlinVersion))
classpath("com.android.tools.build:gradle:4.1.0-beta01")
classpath("com.github.ben-manes:gradle-versions-plugin:0.28.0")
classpath("com.google.firebase:firebase-crashlytics-gradle:2.1.1")
classpath("com.google.firebase:firebase-crashlytics-gradle:2.2.0")
classpath("com.google.android.gms:oss-licenses-plugin:0.10.2")
classpath("com.google.gms:google-services:4.3.3")
}

View File

@@ -84,15 +84,15 @@ dependencies {
implementation("androidx.room:room-ktx:$roomVersion")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0-rc01")
implementation("com.android.billingclient:billing-ktx:3.0.0")
implementation("com.github.topjohnwu.libsu:core:2.5.1")
implementation("com.google.android.gms:play-services-oss-licenses:17.0.0")
implementation("com.google.android.material:material:1.2.0-beta01")
implementation("com.google.firebase:firebase-analytics-ktx:17.4.3")
implementation("com.google.firebase:firebase-crashlytics:17.0.1")
implementation("com.google.firebase:firebase-crashlytics:17.1.0")
implementation("com.google.zxing:core:3.4.0")
implementation("com.jakewharton.timber:timber:4.7.1")
implementation("com.linkedin.dexmaker:dexmaker:2.28.0")
implementation("com.takisoft.preferencex:preferencex-simplemenu:1.1.0")
implementation("eu.chainfire:librootjava:1.3.0")
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.7")
testImplementation("junit:junit:4.13")

View File

@@ -36,14 +36,14 @@
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.MANAGE_USB"
tools:ignore="ProtectedPermissions"/>
<uses-permission android:name="android.permission.NETWORK_SETTINGS"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.OVERRIDE_WIFI_CONFIG"
tools:ignore="ProtectedPermissions"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.TETHER_PRIVILEGED"
tools:ignore="ProtectedPermissions"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS"
tools:ignore="ProtectedPermissions"/>
<uses-permission android:name="android.permission.WRITE_SETTINGS"
tools:ignore="ProtectedPermissions"/>
<uses-permission-sdk-23 android:name="android.permission.ACCESS_COARSE_LOCATION"/>

View File

@@ -0,0 +1,427 @@
package be.mygod.librootkotlinx
import android.content.Context
import android.os.Looper
import android.os.Parcelable
import android.os.RemoteException
import android.util.Log
import androidx.collection.LongSparseArray
import androidx.collection.set
import androidx.collection.valueIterator
import eu.chainfire.librootjava.AppProcess
import eu.chainfire.librootjava.RootJava
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.*
import java.lang.ref.WeakReference
import java.util.*
import java.util.concurrent.CountDownLatch
import kotlin.system.exitProcess
class RootServer @JvmOverloads constructor(private val warnLogger: (String) -> Unit = { Log.w(TAG, it) }) {
private sealed class Callback {
abstract fun cancel()
abstract fun shouldRemove(result: Byte): Boolean
abstract operator fun invoke(input: DataInputStream, result: Byte)
class Ordinary(private val classLoader: ClassLoader?,
private val callback: CompletableDeferred<Parcelable?>) : Callback() {
override fun cancel() = callback.cancel()
override fun shouldRemove(result: Byte) = true
override fun invoke(input: DataInputStream, result: Byte) {
when (result.toInt()) {
SUCCESS -> callback.complete(input.readParcelable(classLoader))
EX_GENERIC -> callback.completeExceptionally(RemoteException(input.readUTF()))
EX_PARCELABLE -> callback.completeExceptionally(RemoteException().initCause(
input.readParcelable<Parcelable>(classLoader) as Throwable?))
else -> throw IllegalArgumentException("Unexpected result $result")
}
}
}
class Channel(private val classLoader: ClassLoader?,
private val channel: SendChannel<Parcelable?>,
private val server: RootServer,
private val index: Long) : Callback() {
var active = true
val finish: CompletableDeferred<Unit> = CompletableDeferred()
override fun cancel() = finish.cancel()
override fun shouldRemove(result: Byte) = result.toInt() != SUCCESS
override fun invoke(input: DataInputStream, result: Byte) {
when (result.toInt()) {
// the channel we are supporting should never block
SUCCESS -> check(try {
channel.offer(input.readParcelable(classLoader))
} catch (closed: Throwable) {
active = false
GlobalScope.launch(Dispatchers.Unconfined) { sendClosed() }
finish.completeExceptionally(closed)
return
})
EX_GENERIC -> finish.completeExceptionally(RemoteException(input.readUTF()))
EX_PARCELABLE -> finish.completeExceptionally(RemoteException().initCause(
input.readParcelable<Parcelable>(classLoader) as Throwable?))
CHANNEL_CONSUMED -> finish.complete(Unit)
else -> throw IllegalArgumentException("Unexpected result $result")
}
}
suspend fun sendClosed() = server.execute(ChannelClosed(index))
}
}
private lateinit var process: Process
private lateinit var worker: Thread
/**
* Thread safety: needs to be protected by mutex.
*/
private lateinit var output: DataOutputStream
@Volatile
var active = false
private var counter = 0L
private val callbackListenerExit = CompletableDeferred<Unit>()
private val callbackLookup = LongSparseArray<Callback>()
private val mutex = Mutex()
/**
* If we encountered unexpected output from stderr during initialization, its content will be stored here.
*
* It is advised to read this after initializing the instance.
*/
fun readUnexpectedStderr(): String? {
var available = process.errorStream.available()
return if (available <= 0) null else String(ByteArrayOutputStream().apply {
while (available > 0) {
val bytes = ByteArray(available)
val len = process.errorStream.read(bytes)
if (len < 0) throw EOFException() // should not happen
write(bytes, 0, len)
available = process.errorStream.available()
}
}.toByteArray())
}
private fun BufferedReader.lookForToken(token: String) {
while (true) {
val line = readLine() ?: throw EOFException()
if (line.endsWith(token)) {
val extraLength = line.length - token.length
if (extraLength > 0) warnLogger(line.substring(0, extraLength))
break
}
warnLogger(line)
}
}
private fun doInit(context: Context, niceName: String) {
val writer: DataOutputStream
val reader: BufferedReader
try {
process = ProcessBuilder("su").start()
val token1 = UUID.randomUUID().toString()
writer = DataOutputStream(process.outputStream.buffered())
writer.writeBytes("echo $token1\n")
writer.flush()
reader = process.inputStream.bufferedReader()
reader.lookForToken(token1)
} catch (e: Exception) {
throw NoShellException(e)
}
if (DEBUG) Log.d(TAG, "Root shell initialized")
val appProcess = AppProcess.getAppProcess()
val token2 = UUID.randomUUID().toString()
writer.writeBytes(RootJava.getLaunchString(context.packageCodePath + " exec", // hack: plugging in exec
RootServer::class.java.name, appProcess, AppProcess.guessIfAppProcessIs64Bits(appProcess),
arrayOf("$token2\n"), niceName))
writer.flush()
reader.lookForToken(token2) // wait for ready signal
output = writer
require(!active)
active = true
if (DEBUG) Log.d(TAG, "Root server initialized")
}
private fun callbackSpin() {
val input = DataInputStream(process.inputStream.buffered())
while (active) {
val index = try {
input.readLong()
} catch (_: EOFException) {
break
}
val result = input.readByte()
val callback = mutex.synchronized {
callbackLookup[index]!!.also { if (it.shouldRemove(result)) callbackLookup.remove(index) }
}
if (DEBUG) Log.d(TAG, "Received callback #$index: $result")
callback(input, result)
}
}
/**
* Initialize a RootServer synchronously, can throw a lot of exceptions.
*
* @param context Any [Context] from the app.
* @param niceName Name to call the rooted Java process.
*/
suspend fun init(context: Context, niceName: String = "${context.packageName}:root") {
val future = CompletableDeferred<Unit>()
worker = Thread {
try {
doInit(context, niceName)
future.complete(Unit)
} catch (e: Throwable) {
future.completeExceptionally(e)
callbackListenerExit.complete(Unit)
return@Thread
}
try {
callbackSpin()
} catch (e: Throwable) {
callbackListenerExit.completeExceptionally(e)
return@Thread
} finally {
if (DEBUG) Log.d(TAG, "Waiting for exit")
process.waitFor()
runBlocking { closeInternal(true) }
}
check(process.errorStream.available() == 0) // stderr should not be used
callbackListenerExit.complete(Unit)
}
worker.start()
future.await()
}
/**
* Convenience function that initializes and also logs warnings to [Log].
*/
suspend fun initAndroidLog(context: Context, niceName: String = "${context.packageName}:root") = try {
init(context, niceName)
} finally {
readUnexpectedStderr()?.let { Log.e(TAG, it) }
}
/**
* Caller should check for active.
*/
private fun sendLocked(command: Parcelable) {
output.writeParcelable(command)
output.flush()
if (DEBUG) Log.d(TAG, "Sent #$counter: $command")
counter++
}
suspend fun execute(command: RootCommandOneWay) = mutex.withLock { if (active) sendLocked(command) }
@Throws(RemoteException::class)
suspend inline fun <reified T : Parcelable?> execute(command: RootCommand<T>) =
execute(command, T::class.java.classLoader)
@Throws(RemoteException::class)
suspend fun <T : Parcelable?> execute(command: RootCommand<T>, classLoader: ClassLoader?): T {
val future = CompletableDeferred<T>()
mutex.withLock {
if (active) {
@Suppress("UNCHECKED_CAST")
callbackLookup[counter] = Callback.Ordinary(classLoader, future as CompletableDeferred<Parcelable?>)
sendLocked(command)
} else future.cancel()
}
return future.await()
}
@ExperimentalCoroutinesApi
@Throws(RemoteException::class)
inline fun <reified T : Parcelable?> create(command: RootCommandChannel<T>, scope: CoroutineScope) =
create(command, scope, T::class.java.classLoader)
@ExperimentalCoroutinesApi
@Throws(RemoteException::class)
fun <T : Parcelable?> create(command: RootCommandChannel<T>, scope: CoroutineScope,
classLoader: ClassLoader?) = scope.produce<T>(
capacity = command.capacity.also {
when (it) {
Channel.UNLIMITED, Channel.CONFLATED -> { }
else -> throw IllegalArgumentException("Unsupported channel capacity $it")
}
}) {
@Suppress("UNCHECKED_CAST")
val callback = Callback.Channel(classLoader, this as SendChannel<Parcelable?>, this@RootServer, counter)
mutex.withLock {
if (active) {
callbackLookup[counter] = callback
sendLocked(command)
} else callback.finish.cancel()
}
try {
callback.finish.await()
} finally {
if (callback.active) withContext(NonCancellable) { callback.sendClosed() }
callback.active = false
}
}
private suspend fun closeInternal(fromWorker: Boolean = false) = mutex.withLock {
if (active) {
active = false
if (DEBUG) Log.d(TAG, "Shutting down from client")
sendLocked(Shutdown())
output.close()
process.outputStream.close()
if (DEBUG) Log.d(TAG, "Client closed")
}
if (fromWorker) {
for (callback in callbackLookup.valueIterator()) callback.cancel()
callbackLookup.clear()
}
}
/**
* Shutdown the instance gracefully.
*/
suspend fun close() {
closeInternal()
callbackListenerExit.await()
}
companion object {
/**
* If set to true, debug information will be printed to logcat.
*/
@JvmStatic
var DEBUG = false
private const val TAG = "RootServer"
private const val SUCCESS = 0
private const val EX_GENERIC = 1
private const val EX_PARCELABLE = 2
private const val CHANNEL_CONSUMED = 3
private inline fun <reified T : Parcelable> DataInputStream.readParcelable(
classLoader: ClassLoader? = T::class.java.classLoader
) = ByteArray(readInt()).also { readFully(it) }.toParcelable<T>(classLoader)
private fun DataOutputStream.writeParcelable(data: Parcelable?, parcelableFlags: Int = 0) {
val bytes = data.toByteArray(parcelableFlags)
writeInt(bytes.size)
write(bytes)
}
private inline fun <T> Mutex.synchronized(crossinline block: () -> T): T = runBlocking {
withLock { block() }
}
@JvmStatic
fun main(args: Array<String>) {
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
Log.e(TAG, "Uncaught exception from $thread", throwable)
exitProcess(1)
}
rootMain(args)
exitProcess(0) // there might be other non-daemon threads
}
private fun DataOutputStream.pushThrowable(callback: Long, e: Throwable) {
writeLong(callback)
if (e is Parcelable) {
writeByte(EX_PARCELABLE)
writeParcelable(e)
} else {
writeByte(EX_GENERIC)
writeUTF(StringWriter().also {
e.printStackTrace(PrintWriter(it))
}.toString())
}
flush()
}
private fun DataOutputStream.pushResult(callback: Long, result: Parcelable?) {
writeLong(callback)
writeByte(SUCCESS)
writeParcelable(result)
flush()
}
private fun rootMain(args: Array<String>) {
require(args.isNotEmpty())
RootJava.restoreOriginalLdLibraryPath()
val mainInitialized = CountDownLatch(1)
val main = Thread({
@Suppress("DEPRECATION")
Looper.prepareMainLooper()
mainInitialized.countDown()
Looper.loop()
}, "main")
main.start()
val job = Job()
val defaultWorker by lazy {
mainInitialized.await()
CoroutineScope(Dispatchers.Main.immediate + job)
}
val callbackWorker = newSingleThreadContext("callbackWorker")
val channels = LongSparseArray<WeakReference<ReceiveChannel<Parcelable?>>>()
// thread safety: usage of output should be guarded by callbackWorker
val output = DataOutputStream(System.out.buffered().apply {
val writer = writer()
writer.appendln(args[0]) // echo ready signal
writer.flush()
})
// thread safety: usage of input should be in main thread
val input = DataInputStream(System.`in`.buffered())
var counter = 0L
if (DEBUG) Log.d(TAG, "Server entering main loop")
loop@ while (true) {
val command = try {
input.readParcelable<Parcelable>(RootServer::class.java.classLoader)
} catch (e: EOFException) {
break
}
val callback = counter
if (DEBUG) Log.d(TAG, "Received #$callback: $command")
when (command) {
is ChannelClosed -> channels[command.index]?.get()?.cancel()
is RootCommandOneWay -> defaultWorker.launch {
try {
command.execute()
} catch (e: Throwable) {
Log.e(command.javaClass.simpleName, "Unexpected exception in RootCommandOneWay", e)
}
}
is RootCommand<*> -> defaultWorker.launch {
val result = try {
val result = command.execute();
{ output.pushResult(callback, result) }
} catch (e: Throwable) {
{ output.pushThrowable(callback, e) }
}
withContext(callbackWorker) { result() }
}
is RootCommandChannel<*> -> defaultWorker.launch {
val result = try {
command.create(defaultWorker).also {
channels[callback] = WeakReference(it)
}.consumeEach { result ->
withContext(callbackWorker) { output.pushResult(callback, result) }
};
@Suppress("BlockingMethodInNonBlockingContext") {
output.writeByte(CHANNEL_CONSUMED)
output.writeLong(callback)
output.flush()
}
} catch (e: Throwable) {
{ output.pushThrowable(callback, e) }
} finally {
channels.remove(callback)
}
withContext(callbackWorker) { result() }
}
is Shutdown -> break@loop
else -> throw IllegalArgumentException("Unrecognized input: $command")
}
counter++
}
job.cancel()
if (DEBUG) Log.d(TAG, "Clean up initiated before exit. Jobs: ${job.children.joinToString()}")
if (runBlocking { withTimeoutOrNull(5000) { job.join() } } == null) {
Log.w(TAG, "Clean up timeout: ${job.children.joinToString()}")
} else if (DEBUG) Log.d(TAG, "Clean up finished, exiting")
}
}
}

View File

@@ -0,0 +1,106 @@
package be.mygod.librootkotlinx
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.concurrent.TimeUnit
/**
* This object manages creation of [RootServer] and times them out automagically, with default timeout of 5 minutes.
*/
abstract class RootSession {
protected open fun createServer() = RootServer()
protected abstract suspend fun initServer(server: RootServer)
/**
* Timeout to close [RootServer] in milliseconds.
*/
protected open val timeout get() = TimeUnit.MINUTES.toMillis(5)
protected open val timeoutDispatcher get() = Dispatchers.Default
private val mutex = Mutex()
private var server: RootServer? = null
private var timeoutJob: Job? = null
private var usersCount = 0L
private var closePending = false
private suspend fun ensureServerLocked(): RootServer {
server?.let { return it }
check(usersCount == 0L)
val server = createServer()
try {
initServer(server)
this.server = server
return server
} catch (e: Throwable) {
try {
server.close()
} catch (eClose: Throwable) {
throw eClose.apply { addSuppressed(e) }
}
throw e
}
}
private suspend fun closeLocked() {
server?.close()
server = null
}
private fun startTimeoutLocked() {
check(timeoutJob == null)
timeoutJob = GlobalScope.launch(timeoutDispatcher, CoroutineStart.UNDISPATCHED) {
delay(timeout)
mutex.withLock {
check(usersCount == 0L)
closeLocked()
timeoutJob = null
}
}
}
private fun haltTimeoutLocked() {
timeoutJob?.cancel()
timeoutJob = null
}
suspend fun acquire() = withContext(NonCancellable) {
mutex.withLock {
haltTimeoutLocked()
closePending = false
ensureServerLocked().also { ++usersCount }
}
}
suspend fun release(server: RootServer) = withContext(NonCancellable) {
mutex.withLock {
if (this@RootSession.server != server) return@withLock // outdated reference
require(usersCount > 0)
when {
!server.active -> {
usersCount = 0
closeLocked()
closePending = false
return@withLock
}
--usersCount > 0L -> return@withLock
closePending -> {
closeLocked()
closePending = false
}
else -> startTimeoutLocked()
}
}
}
suspend inline fun <T> use(block: (RootServer) -> T): T {
val server = acquire()
try {
return block(server)
} finally {
release(server)
}
}
suspend fun closeExisting() = mutex.withLock {
if (usersCount > 0) closePending = true else {
haltTimeoutLocked()
closeLocked()
}
}
}

View File

@@ -0,0 +1,47 @@
package be.mygod.librootkotlinx
import android.os.Parcelable
import androidx.annotation.MainThread
import kotlinx.android.parcel.Parcelize
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ReceiveChannel
interface RootCommand<Result : Parcelable?> : Parcelable {
/**
* If a throwable was thrown, it will be wrapped in RemoteException only if it implements [Parcelable].
*/
@MainThread
suspend fun execute(): Result
}
typealias RootCommandNoResult = RootCommand<Parcelable?>
/**
* Execute a command and discards its result, even if an exception occurs.
*
* If you want to catch exception, use e.g. [RootCommandNoResult] and return null.
*/
interface RootCommandOneWay : Parcelable {
@MainThread
suspend fun execute()
}
interface RootCommandChannel<T : Parcelable?> : Parcelable {
/**
* The capacity of the channel that is returned by [create] to be used by client.
* Only [Channel.UNLIMITED] and [Channel.CONFLATED] is supported for now to avoid blocking the entire connection.
*/
val capacity: Int get() = Channel.UNLIMITED
@MainThread
fun create(scope: CoroutineScope): ReceiveChannel<T>
}
@Parcelize
internal class ChannelClosed(val index: Long) : RootCommandOneWay {
override suspend fun execute() = throw IllegalStateException("Internal implementation")
}
@Parcelize
internal class Shutdown : Parcelable

View File

@@ -0,0 +1,201 @@
package be.mygod.librootkotlinx
import android.annotation.SuppressLint
import android.os.IBinder
import android.os.Parcel
import android.os.Parcelable
import android.util.*
import kotlinx.android.parcel.Parcelize
class NoShellException(cause: Throwable) : Exception("Root missing", cause)
@Parcelize
data class ParcelableByte(val value: Byte) : Parcelable
@Parcelize
data class ParcelableShort(val value: Short) : Parcelable
@Parcelize
data class ParcelableInt(val value: Int) : Parcelable
@Parcelize
data class ParcelableLong(val value: Long) : Parcelable
@Parcelize
data class ParcelableFloat(val value: Float) : Parcelable
@Parcelize
data class ParcelableDouble(val value: Double) : Parcelable
@Parcelize
data class ParcelableBoolean(val value: Boolean) : Parcelable
@Parcelize
data class ParcelableString(val value: String) : Parcelable
@Parcelize
data class ParcelableByteArray(val value: ByteArray) : Parcelable {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ParcelableByteArray
if (!value.contentEquals(other.value)) return false
return true
}
override fun hashCode(): Int {
return value.contentHashCode()
}
}
@Parcelize
data class ParcelableIntArray(val value: IntArray) : Parcelable {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ParcelableIntArray
if (!value.contentEquals(other.value)) return false
return true
}
override fun hashCode(): Int {
return value.contentHashCode()
}
}
@Parcelize
data class ParcelableLongArray(val value: LongArray) : Parcelable {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ParcelableLongArray
if (!value.contentEquals(other.value)) return false
return true
}
override fun hashCode(): Int {
return value.contentHashCode()
}
}
@Parcelize
data class ParcelableFloatArray(val value: FloatArray) : Parcelable {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ParcelableFloatArray
if (!value.contentEquals(other.value)) return false
return true
}
override fun hashCode(): Int {
return value.contentHashCode()
}
}
@Parcelize
data class ParcelableDoubleArray(val value: DoubleArray) : Parcelable {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ParcelableDoubleArray
if (!value.contentEquals(other.value)) return false
return true
}
override fun hashCode(): Int {
return value.contentHashCode()
}
}
@Parcelize
data class ParcelableBooleanArray(val value: BooleanArray) : Parcelable {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ParcelableBooleanArray
if (!value.contentEquals(other.value)) return false
return true
}
override fun hashCode(): Int {
return value.contentHashCode()
}
}
@Parcelize
data class ParcelableStringArray(val value: Array<String>) : Parcelable {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ParcelableStringArray
if (!value.contentEquals(other.value)) return false
return true
}
override fun hashCode(): Int {
return value.contentHashCode()
}
}
@Parcelize
data class ParcelableStringList(val value: List<String>) : Parcelable
@Parcelize
data class ParcelableSparseIntArray(val value: SparseIntArray) : Parcelable
@Parcelize
data class ParcelableSparseLongArray(val value: SparseLongArray) : Parcelable
@Parcelize
data class ParcelableSparseBooleanArray(val value: SparseBooleanArray) : Parcelable
@Parcelize
data class ParcelableCharSequence(val value: CharSequence) : Parcelable
@Parcelize
data class ParcelableSize(val value: Size) : Parcelable
@Parcelize
data class ParcelableSizeF(val value: SizeF) : Parcelable
@SuppressLint("Recycle")
inline fun <T> useParcel(block: (Parcel) -> T) = Parcel.obtain().run {
try {
block(this)
} finally {
recycle()
}
}
fun Parcelable?.toByteArray(parcelableFlags: Int = 0) = useParcel { p ->
p.writeParcelable(this, parcelableFlags)
p.marshall()
}
inline fun <reified T : Parcelable> ByteArray.toParcelable(classLoader: ClassLoader? = T::class.java.classLoader) =
useParcel { p ->
p.unmarshall(this, 0, size)
p.setDataPosition(0)
p.readParcelable<T>(classLoader)
}

View File

@@ -2,11 +2,9 @@ package be.mygod.vpnhotspot
import android.annotation.SuppressLint
import android.app.Application
import android.app.UiModeManager
import android.content.ClipboardManager
import android.content.Context
import android.content.res.Configuration
import android.net.ConnectivityManager
import android.net.wifi.WifiManager
import android.os.Build
import android.util.Log
import androidx.annotation.Size
@@ -18,10 +16,12 @@ import androidx.core.provider.FontRequest
import androidx.emoji.text.EmojiCompat
import androidx.emoji.text.FontRequestEmojiCompatConfig
import androidx.preference.PreferenceManager
import be.mygod.librootkotlinx.NoShellException
import be.mygod.vpnhotspot.net.DhcpWorkaround
import be.mygod.vpnhotspot.room.AppDatabase
import be.mygod.vpnhotspot.root.RootManager
import be.mygod.vpnhotspot.util.DeviceStorageApp
import be.mygod.vpnhotspot.util.RootSession
import be.mygod.vpnhotspot.util.Services
import com.google.firebase.analytics.ktx.ParametersBuilder
import com.google.firebase.analytics.ktx.analytics
import com.google.firebase.crashlytics.FirebaseCrashlytics
@@ -38,6 +38,10 @@ class App : Application() {
lateinit var app: App
}
public override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
}
override fun onCreate() {
super.onCreate()
app = this
@@ -47,6 +51,7 @@ class App : Application() {
deviceStorage.moveSharedPreferencesFrom(this, PreferenceManager(this).sharedPreferencesName)
deviceStorage.moveDatabaseFrom(this, AppDatabase.DB_NAME)
} else deviceStorage = this
Services.init(this)
Firebase.initialize(deviceStorage)
when (val codename = Build.VERSION.CODENAME) {
"REL" -> { }
@@ -62,7 +67,9 @@ class App : Application() {
FirebaseCrashlytics.getInstance().log("${"XXVDIWEF".getOrElse(priority) { 'X' }}/$tag: $message")
} else {
if (priority >= Log.WARN || priority == Log.DEBUG) Log.println(priority, tag, message)
if (priority >= Log.INFO) FirebaseCrashlytics.getInstance().recordException(t)
if (priority >= Log.INFO && t !is NoShellException) {
FirebaseCrashlytics.getInstance().recordException(t)
}
}
}
})
@@ -89,7 +96,7 @@ class App : Application() {
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
if (level >= TRIM_MEMORY_RUNNING_CRITICAL) GlobalScope.launch { RootSession.trimMemory() }
if (level >= TRIM_MEMORY_RUNNING_CRITICAL) GlobalScope.launch { RootManager.closeExisting() }
}
/**
@@ -110,10 +117,7 @@ class App : Application() {
})
}
val pref by lazy { PreferenceManager.getDefaultSharedPreferences(deviceStorage) }
val connectivity by lazy { getSystemService<ConnectivityManager>()!! }
val clipboard by lazy { getSystemService<ClipboardManager>()!! }
val uiMode by lazy { getSystemService<UiModeManager>()!! }
val wifi by lazy { getSystemService<WifiManager>()!! }
val hasTouch by lazy { packageManager.hasSystemFeature("android.hardware.faketouch") }
val customTabsIntent by lazy {

View File

@@ -7,6 +7,7 @@ import android.content.Intent
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.util.Services
class BootReceiver : BroadcastReceiver() {
companion object {
@@ -27,7 +28,7 @@ class BootReceiver : BroadcastReceiver() {
Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_LOCKED_BOOT_COMPLETED -> started = true
else -> return
}
if (RepeaterService.supported) {
if (Services.p2p != null) {
ContextCompat.startForegroundService(context, Intent(context, RepeaterService::class.java))
}
}

View File

@@ -3,6 +3,7 @@ package be.mygod.vpnhotspot
import android.app.Service
import be.mygod.vpnhotspot.net.IpNeighbour
import be.mygod.vpnhotspot.net.monitor.IpNeighbourMonitor
import java.net.Inet4Address
abstract class IpNeighbourMonitoringService : Service(), IpNeighbourMonitor.Callback {
private var neighbours: Collection<IpNeighbour> = emptyList()
@@ -17,7 +18,7 @@ abstract class IpNeighbourMonitoringService : Service(), IpNeighbourMonitor.Call
protected fun updateNotification() {
val sizeLookup = neighbours.groupBy { it.dev }.mapValues { (_, neighbours) ->
neighbours
.filter { it.state != IpNeighbour.State.FAILED }
.filter { it.ip is Inet4Address && it.state != IpNeighbour.State.FAILED }
.distinctBy { it.lladdr }
.size
}

View File

@@ -5,7 +5,6 @@ import android.content.IntentFilter
import android.net.wifi.WifiManager
import android.os.Build
import androidx.annotation.RequiresApi
import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.net.IpNeighbour
import be.mygod.vpnhotspot.net.TetheringManager
import be.mygod.vpnhotspot.net.TetheringManager.localOnlyTetheredIfaces
@@ -13,11 +12,13 @@ import be.mygod.vpnhotspot.net.monitor.IpNeighbourMonitor
import be.mygod.vpnhotspot.net.monitor.TetherTimeoutMonitor
import be.mygod.vpnhotspot.net.wifi.SoftApConfigurationCompat.Companion.toCompat
import be.mygod.vpnhotspot.net.wifi.WifiApManager
import be.mygod.vpnhotspot.util.Services
import be.mygod.vpnhotspot.util.StickyEvent1
import be.mygod.vpnhotspot.util.broadcastReceiver
import be.mygod.vpnhotspot.widget.SmartSnackbar
import kotlinx.coroutines.*
import timber.log.Timber
import java.net.Inet4Address
@RequiresApi(26)
class LocalOnlyHotspotService : IpNeighbourMonitoringService(), CoroutineScope {
@@ -80,7 +81,7 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService(), CoroutineScope {
binder.iface = ""
updateNotification() // show invisible foreground notification to avoid being killed
try {
app.wifi.startLocalOnlyHotspot(object : WifiManager.LocalOnlyHotspotCallback() {
Services.wifi.startLocalOnlyHotspot(object : WifiManager.LocalOnlyHotspotCallback() {
override fun onStarted(reservation: WifiManager.LocalOnlyHotspotReservation?) {
if (reservation == null) onFailed(-2) else {
this@LocalOnlyHotspotService.reservation = reservation
@@ -128,7 +129,7 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService(), CoroutineScope {
override fun onIpNeighbourAvailable(neighbours: Collection<IpNeighbour>) {
super.onIpNeighbourAvailable(neighbours)
if (Build.VERSION.SDK_INT >= 28) timeoutMonitor?.onClientsChanged(neighbours.none {
it.state != IpNeighbour.State.FAILED
it.ip is Inet4Address && it.state != IpNeighbour.State.FAILED
})
}

View File

@@ -14,6 +14,7 @@ import be.mygod.vpnhotspot.manage.TetheringFragment
import be.mygod.vpnhotspot.net.IpNeighbour
import be.mygod.vpnhotspot.net.wifi.WifiDoubleLock
import be.mygod.vpnhotspot.util.ServiceForegroundConnector
import be.mygod.vpnhotspot.util.Services
import be.mygod.vpnhotspot.widget.SmartSnackbar
import com.google.android.material.bottomnavigation.BottomNavigationView
import java.net.Inet4Address
@@ -28,7 +29,7 @@ class MainActivity : AppCompatActivity(), BottomNavigationView.OnNavigationItemS
binding.navigation.setOnNavigationItemSelectedListener(this)
if (savedInstanceState == null) displayFragment(TetheringFragment())
val model by viewModels<ClientViewModel>()
if (RepeaterService.supported) ServiceForegroundConnector(this, model, RepeaterService::class)
if (Services.p2p != null) ServiceForegroundConnector(this, model, RepeaterService::class)
model.clients.observe(this) { clients ->
val count = clients.count {
it.ip.any { (ip, state) -> ip is Inet4Address && state != IpNeighbour.State.FAILED }

View File

@@ -14,7 +14,6 @@ import android.provider.Settings
import androidx.annotation.RequiresApi
import androidx.annotation.StringRes
import androidx.core.content.edit
import androidx.core.content.getSystemService
import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.net.MacAddressCompat
import be.mygod.vpnhotspot.net.monitor.TetherTimeoutMonitor
@@ -25,6 +24,8 @@ import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.deletePersistentGroup
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.requestPersistentGroupInfo
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.setWifiP2pChannels
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.startWps
import be.mygod.vpnhotspot.root.RepeaterCommands
import be.mygod.vpnhotspot.root.RootManager
import be.mygod.vpnhotspot.util.*
import be.mygod.vpnhotspot.widget.SmartSnackbar
import kotlinx.coroutines.*
@@ -51,18 +52,6 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
*/
private const val PLACEHOLDER_NETWORK_NAME = "DIRECT-00-VPNHotspot"
/**
* This is only a "ServiceConnection" to system service and its impact on system is minimal.
*/
private val p2pManager: WifiP2pManager? by lazy {
try {
app.getSystemService<WifiP2pManager>()
} catch (e: RuntimeException) {
Timber.w(e)
null
}
}
val supported get() = p2pManager != null
var persistentSupported = false
@delegate:TargetApi(29)
@@ -145,7 +134,7 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
}
}
private val p2pManager get() = RepeaterService.p2pManager!!
private val p2pManager get() = Services.p2p!!
private var channel: WifiP2pManager.Channel? = null
private val binder = Binder()
@RequiresApi(28)
@@ -207,14 +196,23 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
override fun onBind(intent: Intent) = binder
private fun setOperatingChannel(oc: Int = operatingChannel) = try {
private fun setOperatingChannel(forceReinit: Boolean = false, oc: Int = operatingChannel) = try {
val channel = channel
if (channel == null) SmartSnackbar.make(R.string.repeater_failure_disconnected).show()
// we don't care about listening channel
else p2pManager.setWifiP2pChannels(channel, 0, oc, object : WifiP2pManager.ActionListener {
override fun onSuccess() { }
override fun onFailure(reason: Int) {
SmartSnackbar.make(formatReason(R.string.repeater_set_oc_failure, reason)).show()
if (reason == WifiP2pManager.ERROR && Build.VERSION.SDK_INT >= 30) launch(start = CoroutineStart.UNDISPATCHED) {
val rootReason = try {
RootManager.use { it.execute(RepeaterCommands.SetChannel(oc, forceReinit)) }
} catch (e: Exception) {
Timber.w(e)
SmartSnackbar.make(e).show()
null
} ?: return@launch
SmartSnackbar.make(formatReason(R.string.repeater_set_oc_failure, rootReason.value)).show()
} else SmartSnackbar.make(formatReason(R.string.repeater_set_oc_failure, reason)).show()
}
})
} catch (e: InvocationTargetException) {
@@ -229,7 +227,7 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
channel = null
if (status != Status.DESTROYED) try {
channel = p2pManager.initialize(this, Looper.getMainLooper(), this)
if (!safeMode) setOperatingChannel()
if (!safeMode) setOperatingChannel(true)
} catch (e: RuntimeException) {
Timber.w(e)
launch(Dispatchers.Main) {
@@ -240,18 +238,19 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
if (!safeMode && key == KEY_OPERATING_CHANNEL) setOperatingChannel()
if (!safeMode) when (key) {
KEY_OPERATING_CHANNEL -> setOperatingChannel()
KEY_SAFE_MODE -> setOperatingChannel(true)
}
}
@SuppressLint("NewApi") // networkId is available since Android 4.2
private fun onPersistentGroupsChanged() = launch {
val ownerAddress = lastMac?.let(MacAddressCompat.Companion::fromString) ?: withContext(Dispatchers.Default) {
try {
P2pSupplicantConfiguration().bssid
val ownerAddress = lastMac?.let(MacAddressCompat.Companion::fromString) ?: try {
P2pSupplicantConfiguration().apply { init() }.bssid
} catch (e: RuntimeException) {
Timber.d(e)
null
}
} ?: return@launch
val channel = channel ?: return@launch
try {
@@ -287,12 +286,8 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
if (status != Status.IDLE) return START_NOT_STICKY
val channel = channel ?: return START_NOT_STICKY.also { stopSelf() }
status = Status.STARTING
// bump self to foreground location service to use foreground location permission later
if (Build.VERSION.SDK_INT >= 29 ||
// or show invisible foreground notification on television to avoid being killed
Build.VERSION.SDK_INT >= 26 && app.uiMode.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION) {
showNotification()
}
// bump self to foreground location service (API 29+) to use location later, also to avoid getting killed
if (Build.VERSION.SDK_INT >= 26) showNotification()
launch {
registerReceiver(receiver, intentFilter(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION,
WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION))
@@ -420,7 +415,7 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
SmartSnackbar.make(msg).apply {
if (showWifiEnable) action(R.string.repeater_p2p_unavailable_enable) {
if (Build.VERSION.SDK_INT < 29) @Suppress("DEPRECATION") {
app.wifi.isWifiEnabled = true
Services.wifi.isWifiEnabled = true
} else it.context.startActivity(Intent(Settings.Panel.ACTION_WIFI))
}
}.show()

View File

@@ -7,6 +7,7 @@ import be.mygod.vpnhotspot.net.Routing
import be.mygod.vpnhotspot.net.TetherType
import be.mygod.vpnhotspot.net.wifi.WifiDoubleLock
import be.mygod.vpnhotspot.widget.SmartSnackbar
import kotlinx.coroutines.runBlocking
import timber.log.Timber
import java.net.NetworkInterface
@@ -34,7 +35,7 @@ abstract class RoutingManager(private val caller: Any, val downstream: String, p
if (!reinit && active.isEmpty()) return@synchronized
for (manager in active.values) manager.routing?.stop()
try {
Routing.clean()
runBlocking { Routing.clean() }
} catch (e: RuntimeException) {
Timber.d(e)
SmartSnackbar.make(e).show()

View File

@@ -1,6 +1,5 @@
package be.mygod.vpnhotspot
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
@@ -9,7 +8,6 @@ import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SwitchPreference
import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.net.Routing.Companion.IPTABLES
import be.mygod.vpnhotspot.net.TetherOffloadManager
import be.mygod.vpnhotspot.net.monitor.FallbackUpstreamMonitor
import be.mygod.vpnhotspot.net.monitor.IpMonitor
@@ -18,7 +16,9 @@ import be.mygod.vpnhotspot.net.wifi.WifiDoubleLock
import be.mygod.vpnhotspot.preference.AlwaysAutoCompleteEditTextPreferenceDialogFragment
import be.mygod.vpnhotspot.preference.SharedPreferenceDataStore
import be.mygod.vpnhotspot.preference.SummaryFallbackProvider
import be.mygod.vpnhotspot.util.RootSession
import be.mygod.vpnhotspot.root.Dump
import be.mygod.vpnhotspot.root.RootManager
import be.mygod.vpnhotspot.util.Services
import be.mygod.vpnhotspot.util.launchUrl
import be.mygod.vpnhotspot.util.showAllowingStateLoss
import be.mygod.vpnhotspot.widget.SmartSnackbar
@@ -27,9 +27,9 @@ import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.PrintWriter
import kotlin.system.exitProcess
@@ -48,34 +48,30 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() {
if (Build.VERSION.SDK_INT >= 27) {
isChecked = TetherOffloadManager.enabled
setOnPreferenceChangeListener { _, newValue ->
if (TetherOffloadManager.enabled != newValue) {
if (TetherOffloadManager.enabled != newValue) GlobalScope.launch(Dispatchers.Main.immediate) {
isEnabled = false
GlobalScope.launch {
try {
TetherOffloadManager.enabled = newValue as Boolean
TetherOffloadManager.setEnabled(newValue as Boolean)
} catch (e: Exception) {
Timber.d(e)
Timber.w(e)
SmartSnackbar.make(e).show()
}
withContext(Dispatchers.Main) {
isChecked = TetherOffloadManager.enabled
isEnabled = true
}
}
}
false
}
} else parent!!.removePreference(this)
}
val boot = findPreference<SwitchPreference>("service.repeater.startOnBoot")!!
if (RepeaterService.supported) {
if (Services.p2p != null) {
boot.setOnPreferenceChangeListener { _, value ->
BootReceiver.enabled = value as Boolean
true
}
boot.isChecked = BootReceiver.enabled
} else boot.parent!!.removePreference(boot)
if (!RepeaterService.supported || !RepeaterService.safeModeConfigurable) {
if (Services.p2p == null || !RepeaterService.safeModeConfigurable) {
val safeMode = findPreference<Preference>(RepeaterService.KEY_SAFE_MODE)!!
safeMode.parent!!.removePreference(safeMode)
}
@@ -88,7 +84,6 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() {
setAction(R.string.settings_exit_app) {
GlobalScope.launch {
RoutingManager.clean(false)
RootSession.trimMemory()
exitProcess(0)
}
}
@@ -109,56 +104,18 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() {
Runtime.getRuntime().exec(arrayOf("logcat", "-d")).inputStream.use { it.copyTo(out) }
} catch (e: IOException) {
Timber.w(e)
e.printStackTrace(writer)
}
writer.println()
val commands = StringBuilder()
// https://android.googlesource.com/platform/external/iptables/+/android-7.0.0_r1/iptables/Android.mk#34
val iptablesSave = if (Build.VERSION.SDK_INT >= 24) "iptables-save" else
File(app.deviceStorage.cacheDir, "iptables-save").absolutePath.also {
commands.appendln("ln -sf /system/bin/iptables $it")
}
val ip6tablesSave = if (Build.VERSION.SDK_INT >= 24) "ip6tables-save" else
File(app.deviceStorage.cacheDir, "ip6tables-save").absolutePath.also {
commands.appendln("ln -sf /system/bin/ip6tables $it")
}
commands.append("""
|echo dumpsys ${Context.WIFI_P2P_SERVICE}
|dumpsys ${Context.WIFI_P2P_SERVICE}
|echo
|echo dumpsys ${Context.CONNECTIVITY_SERVICE} tethering
|dumpsys ${Context.CONNECTIVITY_SERVICE} tethering
|echo
|echo iptables -t filter
|$iptablesSave -t filter
|echo
|echo iptables -t nat
|$iptablesSave -t nat
|echo
|echo ip6tables-save
|$ip6tablesSave
|echo
|echo ip rule
|ip rule
|echo
|echo ip neigh
|ip neigh
|echo
|echo iptables -nvx -L vpnhotspot_fwd
|$IPTABLES -nvx -L vpnhotspot_fwd
|echo
|echo iptables -nvx -L vpnhotspot_acl
|$IPTABLES -nvx -L vpnhotspot_acl
|echo
|echo logcat-su
|logcat -d
""".trimMargin())
try {
RootSession.use { it.execQuiet(commands.toString(), true).out.forEach(writer::println) }
RootManager.use {
it.execute(Dump(logFile.absolutePath))
}
} catch (e: Exception) {
e.printStackTrace(writer)
Timber.i(e)
}
}
Timber.w(e)
PrintWriter(FileOutputStream(logFile, true)).use { e.printStackTrace(it) }
}
context.startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND)
.setType("text/x-log")
@@ -187,8 +144,8 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() {
when (preference.key) {
UpstreamMonitor.KEY, FallbackUpstreamMonitor.KEY ->
AlwaysAutoCompleteEditTextPreferenceDialogFragment().apply {
setArguments(preference.key, app.connectivity.allNetworks.mapNotNull {
app.connectivity.getLinkProperties(it)?.interfaceName
setArguments(preference.key, Services.connectivity.allNetworks.mapNotNull {
Services.connectivity.getLinkProperties(it)?.interfaceName
}.toTypedArray())
setTargetFragment(this@SettingsPreferenceFragment, 0)
}.showAllowingStateLoss(parentFragmentManager, preference.key)

View File

@@ -1,6 +1,7 @@
package be.mygod.vpnhotspot
import android.content.Intent
import android.os.Build
import androidx.annotation.RequiresApi
import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.net.Routing
@@ -93,6 +94,8 @@ class TetheringService : IpNeighbourMonitoringService(), TetheringManager.Tether
override fun onBind(intent: Intent?) = binder
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// call this first just in case we are shutting down immediately
if (Build.VERSION.SDK_INT >= 26) updateNotification()
launch {
if (intent != null) {
for (iface in intent.getStringArrayExtra(EXTRA_ADD_INTERFACES) ?: emptyArray()) {
@@ -109,7 +112,6 @@ class TetheringService : IpNeighbourMonitoringService(), TetheringManager.Tether
} else downstream.monitor = true
}
intent.getStringExtra(EXTRA_REMOVE_INTERFACE)?.also { downstreams.remove(it)?.stop() }
updateNotification() // call this first just in case we are shutting down immediately
onDownstreamsChangedLocked()
} else if (downstreams.isEmpty()) stopSelf(startId)
}

View File

@@ -7,7 +7,6 @@ import android.os.Parcelable
import android.text.format.DateUtils
import android.text.format.Formatter
import android.text.method.LinkMovementMethod
import android.util.LongSparseArray
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
@@ -15,6 +14,7 @@ import android.view.ViewGroup
import android.widget.EditText
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.PopupMenu
import androidx.collection.LongSparseArray
import androidx.databinding.BaseObservable
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
@@ -145,7 +145,7 @@ class ClientsFragment : Fragment() {
AppDatabase.instance.clientRecordDao.update(this@apply)
}
}
IpNeighbourMonitor.instance?.flush()
IpNeighbourMonitor.instance?.flushAsync()
if (!wasWorking && item.itemId == R.id.block) {
SmartSnackbar.make(R.string.clients_popup_block_service_inactive).show()
}
@@ -223,9 +223,7 @@ class ClientsFragment : Fragment() {
binding.clients.itemAnimator = DefaultItemAnimator()
binding.clients.adapter = adapter
binding.swipeRefresher.setColorSchemeResources(R.color.colorSecondary)
binding.swipeRefresher.setOnRefreshListener {
IpNeighbourMonitor.instance?.flush()
}
binding.swipeRefresher.setOnRefreshListener { IpNeighbourMonitor.instance?.flushAsync() }
activityViewModels<ClientViewModel>().value.clients.observe(viewLifecycleOwner) {
adapter.submitList(it.toMutableList())
}

View File

@@ -8,15 +8,11 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.widget.Toast
import androidx.annotation.RequiresApi
import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.net.TetheringManager
import be.mygod.vpnhotspot.util.broadcastReceiver
import be.mygod.vpnhotspot.util.readableMessage
import be.mygod.vpnhotspot.widget.SmartSnackbar
import timber.log.Timber
import java.io.IOException
import java.lang.reflect.InvocationTargetException
class BluetoothTethering(context: Context, val stateListener: () -> Unit) :
@@ -26,9 +22,16 @@ class BluetoothTethering(context: Context, val stateListener: () -> Unit) :
* PAN Profile
*/
private const val PAN = 5
private val isTetheringOn by lazy {
Class.forName("android.bluetooth.BluetoothPan").getDeclaredMethod("isTetheringOn")
private val clazz by lazy { Class.forName("android.bluetooth.BluetoothPan") }
private val constructor by lazy {
clazz.getDeclaredConstructor(Context::class.java, BluetoothProfile.ServiceListener::class.java)
}
private val isTetheringOn by lazy { clazz.getDeclaredMethod("isTetheringOn") }
fun pan(context: Context, serviceListener: BluetoothProfile.ServiceListener) =
constructor.newInstance(context, serviceListener) as BluetoothProfile
val BluetoothProfile.isTetheringOn get() = isTetheringOn(this) as Boolean
fun BluetoothProfile.closePan() = BluetoothAdapter.getDefaultAdapter()!!.closeProfileProxy(PAN, this)
private fun registerBluetoothStateListener(receiver: BroadcastReceiver) =
app.registerReceiver(receiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED))
@@ -41,23 +44,8 @@ class BluetoothTethering(context: Context, val stateListener: () -> Unit) :
@TargetApi(24)
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)) {
BluetoothAdapter.STATE_ON -> try {
BluetoothAdapter.STATE_ON -> {
TetheringManager.startTethering(TetheringManager.TETHERING_BLUETOOTH, true, pendingCallback!!)
} catch (e: IOException) {
Timber.w(e)
Toast.makeText(context, e.readableMessage, Toast.LENGTH_LONG).show()
pendingCallback!!.onException()
} catch (e: InvocationTargetException) {
if (e.targetException !is SecurityException) Timber.w(e)
var cause: Throwable? = e
while (cause != null) {
cause = cause.cause
if (cause != null && cause !is InvocationTargetException) {
Toast.makeText(context, cause.readableMessage, Toast.LENGTH_LONG).show()
pendingCallback!!.onException()
break
}
}
}
BluetoothAdapter.STATE_OFF, BluetoothAdapter.ERROR -> { }
else -> return // ignore transition states
@@ -81,18 +69,18 @@ class BluetoothTethering(context: Context, val stateListener: () -> Unit) :
}
}
private var connected = false
private var pan: BluetoothProfile? = null
var activeFailureCause: Throwable? = null
/**
* Requires BLUETOOTH_PRIVILEGED on API 30+.
*
* Based on: https://android.googlesource.com/platform/packages/apps/Settings/+/78d5efd/src/com/android/settings/TetherSettings.java
*/
val active: Boolean? get() {
activeFailureCause = null
val pan = pan ?: return null
if (!connected) return null
activeFailureCause = null
return BluetoothAdapter.getDefaultAdapter()?.state == BluetoothAdapter.STATE_ON && try {
isTetheringOn(pan) as Boolean
pan.isTetheringOn
} catch (e: InvocationTargetException) {
activeFailureCause = e.cause ?: e
if (e.cause is SecurityException && Build.VERSION.SDK_INT >= 30) Timber.d(e) else Timber.w(e)
@@ -104,24 +92,23 @@ class BluetoothTethering(context: Context, val stateListener: () -> Unit) :
init {
try {
BluetoothAdapter.getDefaultAdapter()?.getProfileProxy(context, this, PAN)
} catch (e: SecurityException) {
pan = pan(context, this)
} catch (e: InvocationTargetException) {
Timber.w(e)
SmartSnackbar.make(e).show()
activeFailureCause = e
}
registerBluetoothStateListener(receiver)
}
override fun onServiceDisconnected(profile: Int) {
pan = null
connected = false
}
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
pan = proxy
connected = true
stateListener()
}
override fun close() {
app.unregisterReceiver(receiver)
BluetoothAdapter.getDefaultAdapter()?.closeProfileProxy(PAN, pan)
pan = null
pan?.closePan()
}
}

View File

@@ -6,6 +6,7 @@ import be.mygod.vpnhotspot.R
import be.mygod.vpnhotspot.net.IpNeighbour
import be.mygod.vpnhotspot.net.monitor.IpNeighbourMonitor
import be.mygod.vpnhotspot.util.KillableTileService
import java.net.Inet4Address
@RequiresApi(24)
abstract class IpNeighbourMonitoringTileService : KillableTileService(), IpNeighbourMonitor.Callback {
@@ -24,7 +25,7 @@ abstract class IpNeighbourMonitoringTileService : KillableTileService(), IpNeigh
protected fun Tile.subtitleDevices(filter: (String) -> Boolean) {
val size = neighbours
.filter { it.state != IpNeighbour.State.FAILED && filter(it.dev) }
.filter { it.ip is Inet4Address && it.state != IpNeighbour.State.FAILED && filter(it.dev) }
.distinctBy { it.lladdr }
.size
if (size > 0) subtitle(resources.getQuantityString(

View File

@@ -210,9 +210,8 @@ class RepeaterManager(private val parent: TetheringFragment) : Manager(), Servic
band = SoftApConfigurationCompat.BAND_ANY
channel = RepeaterService.operatingChannel
try {
val config = withContext(Dispatchers.Default) {
P2pSupplicantConfiguration(group, RepeaterService.lastMac)
}
val config = P2pSupplicantConfiguration(group)
config.init(RepeaterService.lastMac)
holder.config = config
passphrase = config.psk
bssid = config.bssid

View File

@@ -12,6 +12,7 @@ import androidx.core.content.ContextCompat
import be.mygod.vpnhotspot.R
import be.mygod.vpnhotspot.RepeaterService
import be.mygod.vpnhotspot.util.KillableTileService
import be.mygod.vpnhotspot.util.Services
import be.mygod.vpnhotspot.util.stopAndUnbind
@RequiresApi(24)
@@ -22,13 +23,13 @@ class RepeaterTileService : KillableTileService() {
override fun onStartListening() {
super.onStartListening()
if (RepeaterService.supported) {
if (Services.p2p != null) {
bindService(Intent(this, RepeaterService::class.java), this, Context.BIND_AUTO_CREATE)
} else updateTile()
}
override fun onStopListening() {
if (RepeaterService.supported) stopAndUnbind(this)
if (Services.p2p != null) stopAndUnbind(this)
super.onStopListening()
}

View File

@@ -20,8 +20,10 @@ import be.mygod.vpnhotspot.net.TetheringManager
import be.mygod.vpnhotspot.net.wifi.WifiApManager
import be.mygod.vpnhotspot.util.readableMessage
import be.mygod.vpnhotspot.widget.SmartSnackbar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.IOException
import java.lang.reflect.InvocationTargetException
sealed class TetherManager(protected val parent: TetheringFragment) : Manager(),
@@ -50,25 +52,12 @@ sealed class TetherManager(protected val parent: TetheringFragment) : Manager(),
} catch (e: RuntimeException) {
app.logEvent("manage_write_settings") { param("message", e.toString()) }
}
val started = manager.isStarted
try {
if (started) manager.stop() else manager.start()
} catch (e: IOException) {
Timber.w(e)
Toast.makeText(mainActivity, e.readableMessage, Toast.LENGTH_LONG).show()
ManageBar.start(itemView.context)
if (manager.isStarted) try {
manager.stop()
} catch (e: InvocationTargetException) {
if (e.targetException !is SecurityException) Timber.w(e)
var cause: Throwable? = e
while (cause != null) {
cause = cause.cause
if (cause != null && cause !is InvocationTargetException) {
Toast.makeText(mainActivity, cause.readableMessage, Toast.LENGTH_LONG).show()
ManageBar.start(itemView.context)
break
}
}
}
manager.onException(e)
} else manager.start()
}
}
@@ -96,6 +85,14 @@ sealed class TetherManager(protected val parent: TetheringFragment) : Manager(),
error?.let { SmartSnackbar.make("$tetherType: ${TetheringManager.tetherErrorMessage(it)}") }
data.notifyChange()
}
override fun onException(e: Exception) {
if (e !is InvocationTargetException || e.targetException !is SecurityException) Timber.w(e)
GlobalScope.launch(Dispatchers.Main.immediate) {
val context = parent.context ?: app
Toast.makeText(context, e.readableMessage, Toast.LENGTH_LONG).show()
ManageBar.start(context)
}
}
override fun bindTo(viewHolder: RecyclerView.ViewHolder) {
(viewHolder as ViewHolder).manager = this
@@ -124,7 +121,7 @@ sealed class TetherManager(protected val parent: TetheringFragment) : Manager(),
override val type get() = VIEW_TYPE_WIFI
override fun start() = TetheringManager.startTethering(TetheringManager.TETHERING_WIFI, true, this)
override fun stop() = TetheringManager.stopTethering(TetheringManager.TETHERING_WIFI)
override fun stop() = TetheringManager.stopTethering(TetheringManager.TETHERING_WIFI, this::onException)
}
@RequiresApi(24)
class Usb(parent: TetheringFragment) : TetherManager(parent) {
@@ -133,7 +130,7 @@ sealed class TetherManager(protected val parent: TetheringFragment) : Manager(),
override val type get() = VIEW_TYPE_USB
override fun start() = TetheringManager.startTethering(TetheringManager.TETHERING_USB, true, this)
override fun stop() = TetheringManager.stopTethering(TetheringManager.TETHERING_USB)
override fun stop() = TetheringManager.stopTethering(TetheringManager.TETHERING_USB, this::onException)
}
@RequiresApi(24)
class Bluetooth(parent: TetheringFragment) : TetherManager(parent), DefaultLifecycleObserver {
@@ -153,8 +150,6 @@ sealed class TetherManager(protected val parent: TetheringFragment) : Manager(),
override val type get() = VIEW_TYPE_BLUETOOTH
override val isStarted get() = tethering.active == true
override fun onException() = ManageBar.start(parent.context ?: app)
private var baseError: CharSequence? = null
private fun makeErrorMessage(): CharSequence = listOfNotNull(
if (tethering.active == null) tethering.activeFailureCause?.readableMessage else null,
@@ -166,7 +161,7 @@ sealed class TetherManager(protected val parent: TetheringFragment) : Manager(),
override fun start() = BluetoothTethering.start(this)
override fun stop() {
TetheringManager.stopTethering(TetheringManager.TETHERING_BLUETOOTH)
TetheringManager.stopTethering(TetheringManager.TETHERING_BLUETOOTH, this::onException)
Thread.sleep(1) // give others a room to breathe
onTetheringStarted() // force flush state
}
@@ -178,7 +173,7 @@ sealed class TetherManager(protected val parent: TetheringFragment) : Manager(),
override val type get() = VIEW_TYPE_ETHERNET
override fun start() = TetheringManager.startTethering(TetheringManager.TETHERING_ETHERNET, true, this)
override fun stop() = TetheringManager.stopTethering(TetheringManager.TETHERING_ETHERNET)
override fun stop() = TetheringManager.stopTethering(TetheringManager.TETHERING_ETHERNET, this::onException)
}
@RequiresApi(30)
class Ncm(parent: TetheringFragment) : TetherManager(parent) {
@@ -187,7 +182,7 @@ sealed class TetherManager(protected val parent: TetheringFragment) : Manager(),
override val type get() = VIEW_TYPE_NCM
override fun start() = TetheringManager.startTethering(TetheringManager.TETHERING_NCM, true, this)
override fun stop() = TetheringManager.stopTethering(TetheringManager.TETHERING_NCM)
override fun stop() = TetheringManager.stopTethering(TetheringManager.TETHERING_NCM, this::onException)
}
@Suppress("DEPRECATION")

View File

@@ -27,10 +27,9 @@ import be.mygod.vpnhotspot.net.TetheringManager.localOnlyTetheredIfaces
import be.mygod.vpnhotspot.net.TetheringManager.tetheredIfaces
import be.mygod.vpnhotspot.net.wifi.WifiApDialogFragment
import be.mygod.vpnhotspot.net.wifi.WifiApManager
import be.mygod.vpnhotspot.util.ServiceForegroundConnector
import be.mygod.vpnhotspot.util.broadcastReceiver
import be.mygod.vpnhotspot.util.isNotGone
import be.mygod.vpnhotspot.util.showAllowingStateLoss
import be.mygod.vpnhotspot.root.RootManager
import be.mygod.vpnhotspot.root.WifiApCommands
import be.mygod.vpnhotspot.util.*
import be.mygod.vpnhotspot.widget.SmartSnackbar
import kotlinx.coroutines.CompletableDeferred
import timber.log.Timber
@@ -89,7 +88,7 @@ class TetheringFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClick
updateEnabledTypes()
val list = ArrayList<Manager>()
if (RepeaterService.supported) list.add(repeaterManager)
if (Services.p2p != null) list.add(repeaterManager)
if (Build.VERSION.SDK_INT >= 26) list.add(localOnlyHotspotManager)
val monitoredIfaces = binder?.monitoredIfaces ?: emptyList()
updateMonitorList(activeIfaces - monitoredIfaces)
@@ -150,10 +149,12 @@ class TetheringFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClick
}
}
}
private var apConfigurationRunning = false
override fun onMenuItemClick(item: MenuItem?): Boolean {
return when (item?.itemId) {
R.id.configuration -> item.subMenu.run {
findItem(R.id.configuration_repeater).isNotGone = RepeaterService.supported
findItem(R.id.configuration_repeater).isNotGone = Services.p2p != null
findItem(R.id.configuration_temp_hotspot).isNotGone =
adapter.localOnlyHotspotManager.binder?.configuration != null
true
@@ -170,16 +171,30 @@ class TetheringFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClick
}.showAllowingStateLoss(parentFragmentManager)
true
}
R.id.configuration_ap -> try {
WifiApDialogFragment().apply {
arg(WifiApDialogFragment.Arg(WifiApManager.configuration))
key()
}.showAllowingStateLoss(parentFragmentManager)
true
R.id.configuration_ap -> if (apConfigurationRunning) false else {
apConfigurationRunning = true
viewLifecycleOwner.lifecycleScope.launchWhenCreated {
try {
WifiApManager.configuration
} catch (e: InvocationTargetException) {
if (e.targetException !is SecurityException) Timber.w(e)
SmartSnackbar.make(e.targetException).show()
false
try {
RootManager.use { it.execute(WifiApCommands.GetConfiguration()) }
} catch (eRoot: Exception) {
eRoot.addSuppressed(e)
Timber.w(eRoot)
SmartSnackbar.make(eRoot).show()
null
}
}?.let { configuration ->
WifiApDialogFragment().apply {
arg(WifiApDialogFragment.Arg(configuration))
key()
}.showAllowingStateLoss(parentFragmentManager)
}
apConfigurationRunning = false
}
true
}
else -> false
}
@@ -187,13 +202,20 @@ class TetheringFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClick
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
AlertDialogFragment.setResultListener<WifiApDialogFragment, WifiApDialogFragment.Arg>(this) { which, ret ->
if (which == DialogInterface.BUTTON_POSITIVE) try {
WifiApManager.configuration = ret!!.configuration
} catch (e: IllegalArgumentException) {
Timber.d(e)
SmartSnackbar.make(R.string.configuration_rejected).show()
if (which == DialogInterface.BUTTON_POSITIVE) viewLifecycleOwner.lifecycleScope.launchWhenCreated {
val success = try {
WifiApManager.setConfiguration(ret!!.configuration)
} catch (e: InvocationTargetException) {
SmartSnackbar.make(e.targetException).show()
try {
RootManager.use { it.execute(WifiApCommands.SetConfiguration(ret!!.configuration)) }
} catch (eRoot: Exception) {
eRoot.addSuppressed(e)
Timber.w(eRoot)
SmartSnackbar.make(eRoot).show()
null
}
}
if (success == false) SmartSnackbar.make(R.string.configuration_rejected).show()
}
}
binding = FragmentTetheringBinding.inflate(inflater, container, false)

View File

@@ -11,6 +11,7 @@ import android.service.quicksettings.Tile
import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat
import be.mygod.vpnhotspot.App
import be.mygod.vpnhotspot.R
import be.mygod.vpnhotspot.TetheringService
import be.mygod.vpnhotspot.net.TetherType
@@ -20,8 +21,10 @@ import be.mygod.vpnhotspot.net.wifi.WifiApManager
import be.mygod.vpnhotspot.util.broadcastReceiver
import be.mygod.vpnhotspot.util.readableMessage
import be.mygod.vpnhotspot.util.stopAndUnbind
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.IOException
import java.lang.reflect.InvocationTargetException
@RequiresApi(24)
@@ -97,30 +100,17 @@ sealed class TetheringTileService : IpNeighbourMonitoringTileService(), Tetherin
}
}
protected inline fun safeInvoker(func: () -> Unit) = try {
func()
} catch (e: IOException) {
Timber.w(e)
Toast.makeText(this, e.readableMessage, Toast.LENGTH_LONG).show()
} catch (e: InvocationTargetException) {
if (e.targetException !is SecurityException) Timber.w(e)
var cause: Throwable? = e
while (cause != null) {
cause = cause.cause
if (cause != null && cause !is InvocationTargetException) {
Toast.makeText(this, cause.readableMessage, Toast.LENGTH_LONG).show()
break
}
}
}
override fun onClick() {
val interested = interested ?: return
if (interested.isEmpty()) safeInvoker { start() } else {
if (interested.isEmpty()) start() else {
val binder = binder
if (binder == null) tapPending = true else {
val inactive = interested.filterNot(binder::isActive)
if (inactive.isEmpty()) safeInvoker { stop() }
else ContextCompat.startForegroundService(this, Intent(this, TetheringService::class.java)
if (inactive.isEmpty()) try {
stop()
} catch (e: Exception) {
onException(e)
} else ContextCompat.startForegroundService(this, Intent(this, TetheringService::class.java)
.putExtra(TetheringService.EXTRA_ADD_INTERFACES, inactive.toTypedArray()))
}
}
@@ -132,6 +122,12 @@ sealed class TetheringTileService : IpNeighbourMonitoringTileService(), Tetherin
error?.let { Toast.makeText(this, TetheringManager.tetherErrorMessage(it), Toast.LENGTH_LONG).show() }
updateTile()
}
override fun onException(e: Exception) {
if (e !is InvocationTargetException || e.targetException !is SecurityException) Timber.w(e)
GlobalScope.launch(Dispatchers.Main.immediate) {
Toast.makeText(this@TetheringTileService, e.readableMessage, Toast.LENGTH_LONG).show()
}
}
class Wifi : TetheringTileService() {
override val labelString get() = R.string.tethering_manage_wifi
@@ -139,14 +135,14 @@ sealed class TetheringTileService : IpNeighbourMonitoringTileService(), Tetherin
override val icon get() = R.drawable.ic_device_wifi_tethering
override fun start() = TetheringManager.startTethering(TetheringManager.TETHERING_WIFI, true, this)
override fun stop() = TetheringManager.stopTethering(TetheringManager.TETHERING_WIFI)
override fun stop() = TetheringManager.stopTethering(TetheringManager.TETHERING_WIFI, this::onException)
}
class Usb : TetheringTileService() {
override val labelString get() = R.string.tethering_manage_usb
override val tetherType get() = TetherType.USB
override fun start() = TetheringManager.startTethering(TetheringManager.TETHERING_USB, true, this)
override fun stop() = TetheringManager.stopTethering(TetheringManager.TETHERING_USB)
override fun stop() = TetheringManager.stopTethering(TetheringManager.TETHERING_USB, this::onException)
}
class Bluetooth : TetheringTileService() {
private var tethering: BluetoothTethering? = null
@@ -156,7 +152,7 @@ sealed class TetheringTileService : IpNeighbourMonitoringTileService(), Tetherin
override fun start() = BluetoothTethering.start(this)
override fun stop() {
TetheringManager.stopTethering(TetheringManager.TETHERING_BLUETOOTH)
TetheringManager.stopTethering(TetheringManager.TETHERING_BLUETOOTH, this::onException)
Thread.sleep(1) // give others a room to breathe
onTetheringStarted() // force flush state
}
@@ -202,12 +198,15 @@ sealed class TetheringTileService : IpNeighbourMonitoringTileService(), Tetherin
val binder = binder
if (binder == null) tapPending = true else {
val inactive = (interested ?: return).filterNot(binder::isActive)
if (inactive.isEmpty()) safeInvoker { stop() }
else ContextCompat.startForegroundService(this, Intent(this, TetheringService::class.java)
if (inactive.isEmpty()) try {
stop()
} catch (e: Exception) {
onException(e)
} else ContextCompat.startForegroundService(this, Intent(this, TetheringService::class.java)
.putExtra(TetheringService.EXTRA_ADD_INTERFACES, inactive.toTypedArray()))
}
}
false -> safeInvoker { start() }
false -> start()
else -> tapPending = true
}
}
@@ -218,7 +217,7 @@ sealed class TetheringTileService : IpNeighbourMonitoringTileService(), Tetherin
override val tetherType get() = TetherType.ETHERNET
override fun start() = TetheringManager.startTethering(TetheringManager.TETHERING_ETHERNET, true, this)
override fun stop() = TetheringManager.stopTethering(TetheringManager.TETHERING_ETHERNET)
override fun stop() = TetheringManager.stopTethering(TetheringManager.TETHERING_ETHERNET, this::onException)
}
@RequiresApi(30)
class Ncm : TetheringTileService() {
@@ -226,7 +225,7 @@ sealed class TetheringTileService : IpNeighbourMonitoringTileService(), Tetherin
override val tetherType get() = TetherType.NCM
override fun start() = TetheringManager.startTethering(TetheringManager.TETHERING_NCM, true, this)
override fun stop() = TetheringManager.stopTethering(TetheringManager.TETHERING_NCM)
override fun stop() = TetheringManager.stopTethering(TetheringManager.TETHERING_NCM, this::onException)
}
@Suppress("DEPRECATION")

View File

@@ -2,6 +2,7 @@ package be.mygod.vpnhotspot.net
import android.content.SharedPreferences
import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.root.RoutingCommands
import be.mygod.vpnhotspot.util.RootSession
import be.mygod.vpnhotspot.widget.SmartSnackbar
import kotlinx.coroutines.GlobalScope
@@ -32,11 +33,11 @@ object DhcpWorkaround : SharedPreferences.OnSharedPreferenceChangeListener {
RootSession.use {
try {
it.exec("ip rule $action iif lo uidrange 0-0 lookup local_network priority 11000")
} catch (e: RootSession.UnexpectedOutputException) {
if (e.result.out.isEmpty() && (e.result.code == 2 || e.result.code == 254) && if (enabled) {
e.result.err.joinToString("\n") == "RTNETLINK answers: File exists"
} catch (e: RoutingCommands.UnexpectedOutputException) {
if (e.result.out.isEmpty() && (e.result.exit == 2 || e.result.exit == 254) && if (enabled) {
e.result.err == "RTNETLINK answers: File exists"
} else {
e.result.err.joinToString("\n") == "RTNETLINK answers: No such file or directory"
e.result.err == "RTNETLINK answers: No such file or directory"
}) return@use
Timber.w(IOException("Failed to tweak dhcp workaround rule", e))
SmartSnackbar.make(e).show()

View File

@@ -3,7 +3,10 @@ package be.mygod.vpnhotspot.net
import android.os.Build
import android.system.ErrnoException
import android.system.OsConstants
import be.mygod.vpnhotspot.root.ReadArp
import be.mygod.vpnhotspot.root.RootManager
import be.mygod.vpnhotspot.util.parseNumericAddress
import kotlinx.coroutines.runBlocking
import timber.log.Timber
import java.io.File
import java.io.FileNotFoundException
@@ -35,6 +38,7 @@ data class IpNeighbour(val ip: InetAddress, val dev: String, val lladdr: MacAddr
private fun checkLladdrNotLoopback(lladdr: String) = if (lladdr == "00:00:00:00:00:00") "" else lladdr
fun parse(line: String): List<IpNeighbour> {
if (line.isBlank()) return emptyList()
return try {
val match = parser.matchEntire(line)!!
val ip = parseNumericAddress(match.groupValues[2]) // by regex, ip is non-empty
@@ -87,17 +91,24 @@ data class IpNeighbour(val ip: InetAddress, val dev: String, val lladdr: MacAddr
private const val ARP_CACHE_EXPIRE = 1L * 1000 * 1000 * 1000
private var arpCache = emptyList<List<String>>()
private var arpCacheTime = -ARP_CACHE_EXPIRE
private fun arp(): List<List<String>> {
if (System.nanoTime() - arpCacheTime >= ARP_CACHE_EXPIRE) try {
arpCache = File("/proc/net/arp").bufferedReader().readLines()
.asSequence()
private fun Sequence<String>.makeArp() = this
.map { it.split(spaces) }
.drop(1)
.filter { it.size >= 6 && mac.matcher(it[ARP_HW_ADDRESS]).matches() }
.toList()
private fun arp(): List<List<String>> {
if (System.nanoTime() - arpCacheTime >= ARP_CACHE_EXPIRE) try {
arpCache = File("/proc/net/arp").bufferedReader().lineSequence().makeArp()
} catch (e: IOException) {
if (e !is FileNotFoundException || Build.VERSION.SDK_INT < 29 ||
(e.cause as? ErrnoException)?.errno != OsConstants.EACCES) Timber.w(e)
if (e is FileNotFoundException && Build.VERSION.SDK_INT >= 29 &&
(e.cause as? ErrnoException)?.errno == OsConstants.EACCES) try {
arpCache = runBlocking {
RootManager.use { it.execute(ReadArp()) }
}.value.lineSequence().makeArp()
} catch (eRoot: Exception) {
eRoot.addSuppressed(e)
Timber.w(eRoot)
} else Timber.w(e)
}
return arpCache
}

View File

@@ -11,9 +11,12 @@ import be.mygod.vpnhotspot.net.monitor.IpNeighbourMonitor
import be.mygod.vpnhotspot.net.monitor.TrafficRecorder
import be.mygod.vpnhotspot.net.monitor.UpstreamMonitor
import be.mygod.vpnhotspot.room.AppDatabase
import be.mygod.vpnhotspot.root.RootManager
import be.mygod.vpnhotspot.root.RoutingCommands
import be.mygod.vpnhotspot.util.RootSession
import be.mygod.vpnhotspot.widget.SmartSnackbar
import timber.log.Timber
import java.io.BufferedWriter
import java.io.IOException
import java.net.Inet4Address
import java.net.InetAddress
@@ -41,42 +44,38 @@ class Routing(private val caller: Any, private val downstream: String,
private const val RULE_PRIORITY_UPSTREAM_FALLBACK = 17900
private const val RULE_PRIORITY_UPSTREAM_DISABLE_SYSTEM = 17980
/**
* -w <seconds> is not supported on 7.1-.
* Fortunately there also isn't a time limit for starting a foreground service back in 7.1-.
*
* Source: https://android.googlesource.com/platform/external/iptables/+/android-5.0.0_r1/iptables/iptables.c#1574
*/
val IPTABLES = if (Build.VERSION.SDK_INT >= 26) "iptables -w 1" else "iptables -w"
val IP6TABLES = if (Build.VERSION.SDK_INT >= 26) "ip6tables -w 1" else "ip6tables -w"
const val IPTABLES ="iptables -w"
const val IP6TABLES = "ip6tables -w"
fun clean() {
TrafficRecorder.clean()
RootSession.use {
it.execQuiet("$IPTABLES -t nat -F PREROUTING")
it.execQuiet("while $IPTABLES -D FORWARD -j vpnhotspot_fwd; do done")
it.execQuiet("$IPTABLES -F vpnhotspot_fwd")
it.execQuiet("$IPTABLES -X vpnhotspot_fwd")
it.execQuiet("$IPTABLES -F vpnhotspot_acl")
it.execQuiet("$IPTABLES -X vpnhotspot_acl")
it.execQuiet("while $IPTABLES -t nat -D POSTROUTING -j vpnhotspot_masquerade; do done")
it.execQuiet("$IPTABLES -t nat -F vpnhotspot_masquerade")
it.execQuiet("$IPTABLES -t nat -X vpnhotspot_masquerade")
it.execQuiet("while $IP6TABLES -D INPUT -j vpnhotspot_filter; do done")
it.execQuiet("while $IP6TABLES -D FORWARD -j vpnhotspot_filter; do done")
it.execQuiet("while $IP6TABLES -D OUTPUT -j vpnhotspot_filter; do done")
it.execQuiet("$IP6TABLES -F vpnhotspot_filter")
it.execQuiet("$IP6TABLES -X vpnhotspot_filter")
it.execQuiet("while ip rule del priority $RULE_PRIORITY_DNS; do done")
it.execQuiet("while ip rule del priority $RULE_PRIORITY_UPSTREAM; do done")
it.execQuiet("while ip rule del priority $RULE_PRIORITY_UPSTREAM_FALLBACK; do done")
it.execQuiet("while ip rule del priority $RULE_PRIORITY_UPSTREAM_DISABLE_SYSTEM; do done")
fun appendCleanCommands(commands: BufferedWriter) {
commands.appendln("$IPTABLES -t nat -F PREROUTING")
commands.appendln("while $IPTABLES -D FORWARD -j vpnhotspot_fwd; do done")
commands.appendln("$IPTABLES -F vpnhotspot_fwd")
commands.appendln("$IPTABLES -X vpnhotspot_fwd")
commands.appendln("$IPTABLES -F vpnhotspot_acl")
commands.appendln("$IPTABLES -X vpnhotspot_acl")
commands.appendln("while $IPTABLES -t nat -D POSTROUTING -j vpnhotspot_masquerade; do done")
commands.appendln("$IPTABLES -t nat -F vpnhotspot_masquerade")
commands.appendln("$IPTABLES -t nat -X vpnhotspot_masquerade")
commands.appendln("while $IP6TABLES -D INPUT -j vpnhotspot_filter; do done")
commands.appendln("while $IP6TABLES -D FORWARD -j vpnhotspot_filter; do done")
commands.appendln("while $IP6TABLES -D OUTPUT -j vpnhotspot_filter; do done")
commands.appendln("$IP6TABLES -F vpnhotspot_filter")
commands.appendln("$IP6TABLES -X vpnhotspot_filter")
commands.appendln("while ip rule del priority $RULE_PRIORITY_DNS; do done")
commands.appendln("while ip rule del priority $RULE_PRIORITY_UPSTREAM; do done")
commands.appendln("while ip rule del priority $RULE_PRIORITY_UPSTREAM_FALLBACK; do done")
commands.appendln("while ip rule del priority $RULE_PRIORITY_UPSTREAM_DISABLE_SYSTEM; do done")
}
suspend fun clean() {
TrafficRecorder.clean()
RootManager.use { it.execute(RoutingCommands.Clean()) }
}
private fun RootSession.Transaction.iptables(command: String, revert: String) {
val result = execQuiet(command, revert)
val message = RootSession.checkOutput(command, result, err = false)
val message = result.message(listOf(command), err = false)
if (result.err.isNotEmpty()) Timber.i(message) // busy wait message
}
private fun RootSession.Transaction.iptablesAdd(content: String, table: String = "filter") =
@@ -88,9 +87,9 @@ class Routing(private val caller: Any, private val downstream: String,
private fun RootSession.Transaction.ndc(name: String, command: String, revert: String? = null) {
val result = execQuiet(command, revert)
val log = RootSession.checkOutput(command, result,
result.out.lastOrNull() != "200 0 $name operation succeeded")
if (result.out.size > 1) Timber.i(log)
val suffix = "200 0 $name operation succeeded\n"
result.check(listOf(command), !result.out.endsWith(suffix))
if (result.out.length > suffix.length) Timber.i(result.message(listOf(command), true))
}
}
@@ -270,7 +269,7 @@ class Routing(private val caller: Any, private val downstream: String,
transaction.ndc("ipfwd", "ndc ipfwd enable vpnhotspot_$downstream",
"ndc ipfwd disable vpnhotspot_$downstream")
return
} catch (e: RootSession.UnexpectedOutputException) {
} catch (e: RoutingCommands.UnexpectedOutputException) {
Timber.w(IOException("ndc ipfwd enable failure", e))
}
transaction.exec("echo 1 >/proc/sys/net/ipv4/ip_forward")

View File

@@ -3,7 +3,9 @@ package be.mygod.vpnhotspot.net
import android.provider.Settings
import androidx.annotation.RequiresApi
import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.util.RootSession
import be.mygod.vpnhotspot.root.RootManager
import be.mygod.vpnhotspot.root.SettingsGlobalPut
import be.mygod.vpnhotspot.util.Services
/**
* It's hard to change tethering rules with Tethering hardware acceleration enabled for now.
@@ -16,11 +18,18 @@ import be.mygod.vpnhotspot.util.RootSession
@RequiresApi(27)
object TetherOffloadManager {
private const val TETHER_OFFLOAD_DISABLED = "tether_offload_disabled"
var enabled: Boolean
get() = Settings.Global.getInt(app.contentResolver, TETHER_OFFLOAD_DISABLED, 0) == 0
set(value) {
RootSession.use {
it.exec("settings put global $TETHER_OFFLOAD_DISABLED ${if (value) 0 else 1}")
val enabled get() = Settings.Global.getInt(app.contentResolver, TETHER_OFFLOAD_DISABLED, 0) == 0
suspend fun setEnabled(value: Boolean) {
val int = if (value) 0 else 1
try {
check(Settings.Global.putInt(Services.context.contentResolver, TETHER_OFFLOAD_DISABLED, int))
} catch (e: SecurityException) {
try {
RootManager.use { it.execute(SettingsGlobalPut(TETHER_OFFLOAD_DISABLED, int.toString())) }
} catch (eRoot: Exception) {
eRoot.addSuppressed(e)
throw eRoot
}
}
}
}

View File

@@ -15,11 +15,19 @@ import androidx.annotation.RequiresApi
import androidx.collection.SparseArrayCompat
import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.R
import be.mygod.vpnhotspot.root.RootManager
import be.mygod.vpnhotspot.root.StartTethering
import be.mygod.vpnhotspot.root.StopTethering
import be.mygod.vpnhotspot.util.Services
import be.mygod.vpnhotspot.util.broadcastReceiver
import be.mygod.vpnhotspot.util.callSuper
import be.mygod.vpnhotspot.util.ensureReceiverUnregistered
import com.android.dx.stock.ProxyBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.File
import java.lang.ref.WeakReference
import java.lang.reflect.InvocationHandler
import java.lang.reflect.InvocationTargetException
@@ -49,7 +57,10 @@ object TetheringManager {
*/
fun onTetheringFailed(error: Int? = null) { }
fun onException() { }
/**
* ADDED: Called when a local Exception occurred.
*/
fun onException(e: Exception) { }
}
/**
@@ -130,7 +141,6 @@ object TetheringManager {
/**
* Ncm local tethering type.
*
* Requires NETWORK_SETTINGS permission, which is sadly not obtainable.
* @see [startTethering]
*/
@RequiresApi(30)
@@ -149,7 +159,7 @@ object TetheringManager {
@get:RequiresApi(30)
private val instance by lazy @TargetApi(30) {
@SuppressLint("WrongConstant") // hidden services are not included in constants as of R preview 4
val service = app.getSystemService(TETHERING_SERVICE)
val service = Services.context.getSystemService(TETHERING_SERVICE)
service
}
@get:RequiresApi(30)
@@ -211,12 +221,64 @@ object TetheringManager {
private fun Handler?.makeExecutor() = Executor { if (this == null) it.run() else post(it) }
@Deprecated("Legacy API")
@RequiresApi(24)
fun startTetheringLegacy(type: Int, showProvisioningUi: Boolean, callback: StartTetheringCallback,
handler: Handler? = null, cacheDir: File = app.deviceStorage.codeCacheDir) {
val reference = WeakReference(callback)
val proxy = ProxyBuilder.forClass(classOnStartTetheringCallback).apply {
dexCache(cacheDir)
handler { proxy, method, args ->
if (args.isNotEmpty()) Timber.w("Unexpected args for ${method.name}: $args")
@Suppress("NAME_SHADOWING") val callback = reference.get()
when (method.name) {
"onTetheringStarted" -> callback?.onTetheringStarted()
"onTetheringFailed" -> callback?.onTetheringFailed()
else -> ProxyBuilder.callSuper(proxy, method, args)
}
}
}.build()
startTetheringLegacy(Services.connectivity, type, showProvisioningUi, proxy, handler)
}
@RequiresApi(30)
fun startTethering(type: Int, exemptFromEntitlementCheck: Boolean, showProvisioningUi: Boolean, executor: Executor,
proxy: Any) {
startTethering(instance, newTetheringRequestBuilder.newInstance(type).let { builder ->
// setting exemption requires TETHER_PRIVILEGED permission
if (exemptFromEntitlementCheck) setExemptFromEntitlementCheck(builder, true)
setShouldShowEntitlementUi(builder, showProvisioningUi)
build(builder)
}, executor, proxy)
}
@RequiresApi(30)
fun proxy(callback: StartTetheringCallback): Any {
val reference = WeakReference(callback)
return Proxy.newProxyInstance(interfaceStartTetheringCallback.classLoader,
arrayOf(interfaceStartTetheringCallback), object : InvocationHandler {
override fun invoke(proxy: Any, method: Method, args: Array<out Any?>?): Any? {
@Suppress("NAME_SHADOWING") val callback = reference.get()
return when (val name = method.name) {
"onTetheringStarted" -> {
if (!args.isNullOrEmpty()) Timber.w("Unexpected args for $name: $args")
callback?.onTetheringStarted()
}
"onTetheringFailed" -> {
if (args?.size != 1) Timber.w("Unexpected args for $name: $args")
callback?.onTetheringFailed(args?.get(0) as Int)
}
else -> callSuper(interfaceStartTetheringCallback, proxy, method, args)
}
}
})
}
/**
* Runs tether provisioning for the given type if needed and then starts tethering if
* the check succeeds. If no carrier provisioning is required for tethering, tethering is
* enabled immediately. If provisioning fails, tethering will not be enabled. It also
* schedules tether provisioning re-checks if appropriate.
*
* CHANGED BEHAVIOR: This method will not throw Exceptions, instead, callback.onException will be called.
*
* @param type The type of tethering to start. Must be one of
* {@link ConnectivityManager.TETHERING_WIFI},
* {@link ConnectivityManager.TETHERING_USB}, or
@@ -234,48 +296,57 @@ object TetheringManager {
*/
@RequiresApi(24)
fun startTethering(type: Int, showProvisioningUi: Boolean, callback: StartTetheringCallback,
handler: Handler? = null) {
val reference = WeakReference(callback)
if (Build.VERSION.SDK_INT >= 30) {
val request = newTetheringRequestBuilder.newInstance(type).let { builder ->
// setting exemption requires TETHER_PRIVILEGED permission
if (app.checkSelfPermission("android.permission.TETHER_PRIVILEGED") ==
PackageManager.PERMISSION_GRANTED) setExemptFromEntitlementCheck(builder, true)
setShouldShowEntitlementUi(builder, showProvisioningUi)
build(builder)
handler: Handler? = null, cacheDir: File = app.deviceStorage.codeCacheDir) {
if (Build.VERSION.SDK_INT >= 30) try {
val proxy = proxy(callback)
val executor = handler.makeExecutor()
try {
startTethering(type, true, showProvisioningUi, executor, proxy)
} catch (e1: InvocationTargetException) {
if (e1.targetException is SecurityException) GlobalScope.launch(Dispatchers.Unconfined) {
val result = try {
RootManager.use { it.execute(StartTethering(type, showProvisioningUi)) }
} catch (e2: Exception) {
e2.addSuppressed(e1)
try {
// last resort: start tethering without trying to bypass entitlement check
startTethering(type, false, showProvisioningUi, executor, proxy)
Timber.w(e2)
} catch (e3: Exception) {
e3.addSuppressed(e2)
Timber.w(e3)
callback.onException(e3)
}
val proxy = Proxy.newProxyInstance(interfaceStartTetheringCallback.classLoader,
arrayOf(interfaceStartTetheringCallback), object : InvocationHandler {
override fun invoke(proxy: Any, method: Method, args: Array<out Any?>?): Any? {
@Suppress("NAME_SHADOWING") val callback = reference.get()
return when (val name = method.name) {
"onTetheringStarted" -> {
if (!args.isNullOrEmpty()) Timber.w("Unexpected args for $name: $args")
callback?.onTetheringStarted()
return@launch
}
"onTetheringFailed" -> {
if (args?.size != 1) Timber.w("Unexpected args for $name: $args")
callback?.onTetheringFailed(args?.getOrNull(0) as? Int?)
if (result == null) callback.onTetheringStarted()
else callback.onTetheringFailed(result.value)
} else callback.onException(e1)
}
else -> callSuper(interfaceStartTetheringCallback, proxy, method, args)
} catch (e: Exception) {
callback.onException(e)
} else @Suppress("DEPRECATION") try {
startTetheringLegacy(type, showProvisioningUi, callback, handler, cacheDir)
} catch (e: InvocationTargetException) {
if (e.targetException is SecurityException) GlobalScope.launch(Dispatchers.Unconfined) {
val result = try {
val rootCache = File(cacheDir, "root")
rootCache.mkdirs()
check(rootCache.exists()) { "Creating root cache dir failed" }
RootManager.use {
it.execute(be.mygod.vpnhotspot.root.StartTetheringLegacy(
rootCache, type, showProvisioningUi))
}.value
} catch (eRoot: Exception) {
eRoot.addSuppressed(e)
Timber.w(eRoot)
callback.onException(eRoot)
return@launch
}
}
})
startTethering(instance, request, handler.makeExecutor(), proxy)
} else {
val proxy = ProxyBuilder.forClass(classOnStartTetheringCallback).apply {
dexCache(app.deviceStorage.cacheDir)
handler { proxy, method, args ->
if (args.isNotEmpty()) Timber.w("Unexpected args for ${method.name}: $args")
@Suppress("NAME_SHADOWING") val callback = reference.get()
when (method.name) {
"onTetheringStarted" -> callback?.onTetheringStarted()
"onTetheringFailed" -> callback?.onTetheringFailed()
else -> ProxyBuilder.callSuper(proxy, method, args)
}
}
}.build()
startTetheringLegacy(app.connectivity, type, showProvisioningUi, proxy, handler)
if (result) callback.onTetheringStarted() else callback.onTetheringFailed()
} else callback.onException(e)
} catch (e: Exception) {
callback.onException(e)
}
}
@@ -290,7 +361,21 @@ object TetheringManager {
*/
@RequiresApi(24)
fun stopTethering(type: Int) {
if (Build.VERSION.SDK_INT >= 30) stopTethering(instance, type) else stopTetheringLegacy(app.connectivity, type)
if (Build.VERSION.SDK_INT >= 30) stopTethering(instance, type)
else stopTetheringLegacy(Services.connectivity, type)
}
@RequiresApi(24)
fun stopTethering(type: Int, callback: (Exception) -> Unit) = try {
stopTethering(type)
} catch (e: InvocationTargetException) {
if (e.targetException is SecurityException) GlobalScope.launch(Dispatchers.Unconfined) {
try {
RootManager.use { it.execute(StopTethering(type)) }
} catch (eRoot: Exception) {
eRoot.addSuppressed(e)
callback(eRoot)
}
} else callback(e)
}
/**
@@ -517,7 +602,7 @@ object TetheringManager {
* @return error The error code of the last error tethering or untethering the named
* interface
*/
fun getLastTetherError(iface: String): Int = getLastTetherError(app.connectivity, iface) as Int
fun getLastTetherError(iface: String): Int = getLastTetherError(Services.connectivity, iface) as Int
// tether errors defined in ConnectivityManager up to Android 10
private val tetherErrors29 = arrayOf("TETHER_ERROR_NO_ERROR", "TETHER_ERROR_UNKNOWN_IFACE",

View File

@@ -1,9 +1,12 @@
package be.mygod.vpnhotspot.net.monitor
import android.annotation.TargetApi
import android.net.*
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import android.os.Build
import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.util.Services
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import timber.log.Timber
@@ -23,7 +26,7 @@ object DefaultNetworkMonitor : UpstreamMonitor() {
.build()
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
val properties = app.connectivity.getLinkProperties(network)
val properties = Services.connectivity.getLinkProperties(network)
val ifname = properties?.interfaceName ?: return
var switching = false
synchronized(this@DefaultNetworkMonitor) {
@@ -83,9 +86,9 @@ object DefaultNetworkMonitor : UpstreamMonitor() {
}
} else {
if (Build.VERSION.SDK_INT in 24..27) @TargetApi(24) {
app.connectivity.registerDefaultNetworkCallback(networkCallback)
Services.connectivity.registerDefaultNetworkCallback(networkCallback)
} else try {
app.connectivity.requestNetwork(networkRequest, networkCallback)
Services.connectivity.requestNetwork(networkRequest, networkCallback)
} catch (e: SecurityException) {
// SecurityException would be thrown in requestNetwork on Android 6.0 thanks to Google's stupid bug
if (Build.VERSION.SDK_INT != 23) throw e
@@ -98,7 +101,7 @@ object DefaultNetworkMonitor : UpstreamMonitor() {
override fun destroyLocked() {
if (!registered) return
app.connectivity.unregisterNetworkCallback(networkCallback)
Services.connectivity.unregisterNetworkCallback(networkCallback)
registered = false
currentNetwork = null
currentLinkProperties = null

View File

@@ -1,7 +1,7 @@
package be.mygod.vpnhotspot.net.monitor
import android.net.LinkProperties
import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.util.Services
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
@@ -27,8 +27,8 @@ class InterfaceMonitor(val iface: String) : UpstreamMonitor() {
private var registered = false
override var currentIface: String? = null
private set
override val currentLinkProperties get() = app.connectivity.allNetworks
.map { app.connectivity.getLinkProperties(it) }
override val currentLinkProperties get() = Services.connectivity.allNetworks
.map { Services.connectivity.getLinkProperties(it) }
.singleOrNull { it?.interfaceName == iface }
override fun registerCallbackLocked(callback: Callback) {

View File

@@ -17,7 +17,7 @@ class IpLinkMonitor private constructor() : IpMonitor() {
monitor = IpLinkMonitor()
instance = monitor
}
monitor.flush()
monitor.flushAsync()
}
fun unregisterCallback(owner: Any) = synchronized(this) {
if (callbacks.remove(owner) == null || callbacks.isNotEmpty()) return@synchronized

View File

@@ -4,20 +4,25 @@ import android.os.Build
import android.system.ErrnoException
import android.system.OsConstants
import androidx.core.content.edit
import be.mygod.librootkotlinx.RootServer
import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.BuildConfig
import be.mygod.vpnhotspot.R
import be.mygod.vpnhotspot.util.RootSession
import be.mygod.vpnhotspot.root.ProcessData
import be.mygod.vpnhotspot.root.ProcessListener
import be.mygod.vpnhotspot.root.RootManager
import be.mygod.vpnhotspot.root.RoutingCommands
import be.mygod.vpnhotspot.widget.SmartSnackbar
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.consumeEach
import timber.log.Timber
import java.io.IOException
import java.io.InterruptedIOException
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
import kotlin.concurrent.thread
import kotlin.coroutines.EmptyCoroutineContext
abstract class IpMonitor : Runnable {
abstract class IpMonitor {
companion object {
const val KEY = "service.ipMonitor"
// https://android.googlesource.com/platform/external/iproute2/+/7f7a711/lib/libnetlink.c#493
@@ -51,7 +56,7 @@ abstract class IpMonitor : Runnable {
@Volatile
private var destroyed = false
private var monitor: Process? = null
private var pool: ScheduledExecutorService? = null
private val worker = Job()
private fun handleProcess(builder: ProcessBuilder) {
val process = try {
@@ -79,8 +84,18 @@ abstract class IpMonitor : Runnable {
if ((e.cause as? ErrnoException)?.errno != OsConstants.EBADF) Timber.w(e)
}
err.join()
process.waitFor()
Timber.d("Monitor process exited with ${process.exitValue()}")
Timber.d("Monitor process exited with ${process.waitFor()}")
}
private suspend fun handleChannel(channel: ReceiveChannel<ProcessData>) {
channel.consumeEach {
when (it) {
is ProcessData.StdoutLine -> if (errorMatcher.containsMatchIn(it.line)) {
Timber.w(it.line)
} else processLine(it.line)
is ProcessData.StderrLine -> Timber.e(it.line)
is ProcessData.Exit -> Timber.d("Root monitor process exited with ${it.code}")
}
}
}
init {
@@ -92,17 +107,64 @@ abstract class IpMonitor : Runnable {
handleProcess(ProcessBuilder("ip", "monitor", monitoredObject))
if (destroyed) return@thread
}
handleProcess(ProcessBuilder("su", "-c", "exec ip monitor $monitoredObject"))
try {
runBlocking(EmptyCoroutineContext + worker) {
RootManager.use { server ->
// while we only need to use this server once, we need to also keep the server alive
handleChannel(server.create(ProcessListener(errorMatcher, "ip", "monitor", monitoredObject),
this))
}
}
} catch (e: CancellationException) {
} catch (e: Exception) {
Timber.w(e)
}
if (destroyed) return@thread
app.logEvent("ip_monitor_failure")
}
val pool = Executors.newScheduledThreadPool(1)
pool.scheduleAtFixedRate(this, 1, 1, TimeUnit.SECONDS)
this.pool = pool
GlobalScope.launch(Dispatchers.IO + worker) {
var server: RootServer? = null
try {
while (isActive) {
delay(1000)
server = work(server)
}
} finally {
if (server != null) RootManager.release(server)
}
}
}
}
fun flush() = thread(name = "${javaClass.simpleName}-flush") { run() }
/**
* Possibly blocking. Should run in IO dispatcher or use [flushAsync].
*/
suspend fun flush() = work(null)?.let { RootManager.release(it) }
fun flushAsync() = GlobalScope.launch(Dispatchers.IO) { flush() }
private suspend fun work(server: RootServer?): RootServer? {
if (currentMode != Mode.PollRoot) try {
poll()
return server
} catch (e: IOException) {
app.logEvent("ip_poll_failure")
Timber.d(e)
}
var newServer = server
try {
val command = listOf("ip", monitoredObject)
val result = (server ?: RootManager.acquire().also { newServer = it })
.execute(RoutingCommands.Process(command))
result.check(command, false)
val lines = result.out.lines()
if (lines.any { errorMatcher.containsMatchIn(it) }) throw IOException(result.out)
processLines(lines.asSequence())
} catch (e: RuntimeException) {
app.logEvent("ip_su_poll_failure") { param("cause", e.message.toString()) }
Timber.d(e)
}
return newServer
}
private fun poll() {
val process = ProcessBuilder("ip", monitoredObject)
@@ -125,32 +187,9 @@ abstract class IpMonitor : Runnable {
}
}
override fun run() {
if (currentMode != Mode.PollRoot) try {
return poll()
} catch (e: IOException) {
app.logEvent("ip_poll_failure")
Timber.d(e)
}
try {
val command = "ip $monitoredObject"
RootSession.use { shell ->
val result = shell.execQuiet(command)
RootSession.checkOutput(command, result, false)
if (result.out.any { errorMatcher.containsMatchIn(it) }) {
throw IOException(result.out.joinToString("\n"))
}
processLines(result.out.asSequence())
}
} catch (e: RuntimeException) {
app.logEvent("ip_su_poll_failure") { param("cause", e.message.toString()) }
Timber.d(e)
}
}
fun destroy() {
destroyed = true
monitor?.destroy()
pool?.shutdown()
worker.cancel()
}
}

View File

@@ -20,7 +20,7 @@ class IpNeighbourMonitor private constructor() : IpMonitor() {
if (monitor == null) {
monitor = IpNeighbourMonitor()
instance = monitor
monitor.flush()
monitor.flushAsync()
null
} else monitor.neighbours.values
}?.let { callback.onIpNeighbourAvailable(it) }

View File

@@ -42,6 +42,7 @@ class TetherTimeoutMonitor(private val context: Context, private val onTimeout:
var enabled
get() = Settings.Global.getInt(app.contentResolver, SOFT_AP_TIMEOUT_ENABLED, 1) == 1
set(value) {
// TODO: WRITE_SECURE_SETTINGS permission
check(Settings.Global.putInt(app.contentResolver, SOFT_AP_TIMEOUT_ENABLED, if (value) 1 else 0))
}
@Deprecated("Use SoftApConfigurationCompat instead")

View File

@@ -1,6 +1,7 @@
package be.mygod.vpnhotspot.net.monitor
import android.util.LongSparseArray
import androidx.collection.LongSparseArray
import androidx.collection.set
import be.mygod.vpnhotspot.net.MacAddressCompat
import be.mygod.vpnhotspot.net.Routing.Companion.IPTABLES
import be.mygod.vpnhotspot.room.AppDatabase
@@ -63,10 +64,11 @@ object TrafficRecorder {
loop@ for (line in RootSession.use {
val command = "$IPTABLES -nvx -L vpnhotspot_acl"
val result = it.execQuiet(command)
val message = RootSession.checkOutput(command, result, false, false)
val message = result.message(listOf(command))
if (result.err.isNotEmpty()) Timber.i(message)
result.out.drop(2)
result.out.lineSequence().drop(2)
}) {
if (line.isBlank()) continue
val columns = line.split("\\s+".toRegex()).filter { it.isNotEmpty() }
try {
check(columns.size >= 9)
@@ -104,7 +106,7 @@ object TrafficRecorder {
}
if (oldRecord.id != null) {
check(records.put(key, record) == oldRecord)
oldRecords.put(oldRecord.id!!, oldRecord)
oldRecords[oldRecord.id!!] = oldRecord
}
}
else -> check(false)

View File

@@ -1,7 +1,10 @@
package be.mygod.vpnhotspot.net.monitor
import android.net.*
import be.mygod.vpnhotspot.App.Companion.app
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import be.mygod.vpnhotspot.util.Services
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import timber.log.Timber
@@ -21,7 +24,7 @@ object VpnMonitor : UpstreamMonitor() {
}
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
val properties = app.connectivity.getLinkProperties(network)
val properties = Services.connectivity.getLinkProperties(network)
val ifname = properties?.interfaceName ?: return
var switching = false
synchronized(this@VpnMonitor) {
@@ -88,14 +91,14 @@ object VpnMonitor : UpstreamMonitor() {
callback.onAvailable(currentLinkProperties.interfaceName!!, currentLinkProperties)
}
} else {
app.connectivity.registerNetworkCallback(request, networkCallback)
Services.connectivity.registerNetworkCallback(request, networkCallback)
registered = true
}
}
override fun destroyLocked() {
if (!registered) return
app.connectivity.unregisterNetworkCallback(networkCallback)
Services.connectivity.unregisterNetworkCallback(networkCallback)
registered = false
available.clear()
currentNetwork = null

View File

@@ -1,24 +1,20 @@
package be.mygod.vpnhotspot.net.wifi
import android.net.wifi.p2p.WifiP2pGroup
import android.os.Build
import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.RepeaterService
import be.mygod.vpnhotspot.net.MacAddressCompat
import be.mygod.vpnhotspot.util.RootSession
import be.mygod.vpnhotspot.root.RepeaterCommands
import be.mygod.vpnhotspot.root.RootManager
import com.google.firebase.crashlytics.FirebaseCrashlytics
import java.io.File
/**
* This parser is based on:
* https://android.googlesource.com/platform/external/wpa_supplicant_8/+/d2986c2/wpa_supplicant/config.c#488
* https://android.googlesource.com/platform/external/wpa_supplicant_8/+/6fa46df/wpa_supplicant/config_file.c#182
*/
class P2pSupplicantConfiguration(private val group: WifiP2pGroup? = null, ownerAddress: String? = null) {
class P2pSupplicantConfiguration(private val group: WifiP2pGroup? = null) {
companion object {
private const val TAG = "P2pSupplicantConfiguration"
private const val CONF_PATH_TREBLE = "/data/vendor/wifi/wpa/p2p_supplicant.conf"
private const val CONF_PATH_LEGACY = "/data/misc/wifi/p2p_supplicant.conf"
private const val PERSISTENT_MAC = "p2p_device_persistent_mac_addr="
private val networkParser =
"^(bssid=(([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2})|psk=(ext:|\"(.*)\"|[0-9a-fA-F]{64}\$)?)".toRegex()
@@ -36,12 +32,11 @@ class P2pSupplicantConfiguration(private val group: WifiP2pGroup? = null, ownerA
override fun toString() = joinToString("\n")
}
private class Parser(val lines: List<String>) {
private val iterator = lines.iterator()
private class Parser(val lines: Iterator<String>) {
lateinit var line: String
lateinit var trimmed: String
fun next() = if (iterator.hasNext()) {
line = iterator.next().apply { trimmed = trimStart('\r', '\t', ' ') }
fun next() = if (lines.hasNext()) {
line = lines.next().apply { trimmed = trimStart('\r', '\t', ' ') }
true
} else false
}
@@ -49,14 +44,12 @@ class P2pSupplicantConfiguration(private val group: WifiP2pGroup? = null, ownerA
private data class Content(val lines: ArrayList<Any>, var target: NetworkBlock, var persistentMacLine: Int?,
var legacy: Boolean)
private val content = RootSession.use {
private lateinit var content: Content
suspend fun init(ownerAddress: String? = null) {
val result = ArrayList<Any>()
var target: NetworkBlock? = null
var persistentMacLine: Int? = null
val command = "cat $CONF_PATH_TREBLE || cat $CONF_PATH_LEGACY"
val shell = it.execQuiet(command)
RootSession.checkOutput(command, shell, false, false)
val parser = Parser(shell.out)
val (config, legacy) = RootManager.use { it.execute(RepeaterCommands.ReadP2pConfig()) }
try {
var bssids = listOfNotNull(group?.owner?.deviceAddress, ownerAddress)
.distinct()
@@ -68,6 +61,7 @@ class P2pSupplicantConfiguration(private val group: WifiP2pGroup? = null, ownerA
false
}
}
val parser = Parser(config.lineSequence().iterator())
while (parser.next()) {
if (parser.trimmed.startsWith("network={")) {
val block = NetworkBlock()
@@ -129,22 +123,22 @@ class P2pSupplicantConfiguration(private val group: WifiP2pGroup? = null, ownerA
if (target == null) target = this
})
}
Content(result, target!!.apply {
content = Content(result, target!!.apply {
RepeaterService.lastMac = bssid!!
}, persistentMacLine, shell.err.isNotEmpty())
} catch (e: RuntimeException) {
}, persistentMacLine, legacy)
} catch (e: Exception) {
FirebaseCrashlytics.getInstance().apply {
setCustomKey(TAG, parser.lines.joinToString("\n"))
setCustomKey(TAG, config)
setCustomKey("$TAG.ownerAddress", ownerAddress.toString())
setCustomKey("$TAG.p2pGroup", group.toString())
}
throw e
}
}
val psk = group?.passphrase ?: content.target.psk!!
val bssid = MacAddressCompat.fromString(content.target.bssid!!)
val psk by lazy { group?.passphrase ?: content.target.psk!! }
val bssid by lazy { MacAddressCompat.fromString(content.target.bssid!!) }
fun update(ssid: String, psk: String, bssid: MacAddressCompat?) {
suspend fun update(ssid: String, psk: String, bssid: MacAddressCompat?) {
val (lines, block, persistentMacLine, legacy) = content
block[block.ssidLine!!] = "\tssid=" + ssid.toByteArray()
.joinToString("") { (it.toInt() and 255).toString(16).padStart(2, '0') }
@@ -153,25 +147,6 @@ class P2pSupplicantConfiguration(private val group: WifiP2pGroup? = null, ownerA
persistentMacLine?.let { lines[it] = PERSISTENT_MAC + bssid }
block[block.bssidLine!!] = "\tbssid=$bssid"
}
val tempFile = File.createTempFile("vpnhotspot-", ".conf", app.deviceStorage.cacheDir)
try {
tempFile.printWriter().use { writer ->
lines.forEach { writer.println(it) }
}
// pkill not available on Lollipop. Source: https://android.googlesource.com/platform/system/core/+/master/shell_and_utilities/README.md
RootSession.use {
it.exec("cat ${tempFile.absolutePath} > ${if (legacy) CONF_PATH_LEGACY else CONF_PATH_TREBLE}")
if (Build.VERSION.SDK_INT >= 23) it.exec("pkill wpa_supplicant") else {
val result = try {
it.execOut("ps | grep wpa_supplicant").split(whitespaceMatcher).apply { check(size >= 2) }
} catch (e: Exception) {
throw IllegalStateException("wpa_supplicant not found, please toggle Airplane mode manually", e)
}
it.exec("kill ${result[1]}")
}
}
} finally {
if (!tempFile.delete()) tempFile.deleteOnExit()
}
RootManager.use { it.execute(RepeaterCommands.WriteP2pConfig(lines.joinToString("\n"), legacy)) }
}
}

View File

@@ -38,10 +38,10 @@ data class SoftApConfigurationCompat(
/**
* TODO
*/
const val BAND_ANY = -1
const val BAND_2GHZ = 0
const val BAND_5GHZ = 1
const val BAND_6GHZ = 2
const val BAND_ANY = 0
const val BAND_2GHZ = 1
const val BAND_5GHZ = 2
const val BAND_6GHZ = 3
const val CH_INVALID = 0
// TODO: localize?
@@ -144,7 +144,9 @@ data class SoftApConfigurationCompat(
classBuilder.getDeclaredMethod("setBssid", MacAddress::class.java)
}
@get:RequiresApi(30)
private val setChannel by lazy { classBuilder.getDeclaredMethod("setChannel", Int::class.java) }
private val setChannel by lazy {
classBuilder.getDeclaredMethod("setChannel", Int::class.java, Int::class.java)
}
@get:RequiresApi(30)
private val setClientControlByUserEnabled by lazy {
classBuilder.getDeclaredMethod("setClientControlByUserEnabled", Boolean::class.java)
@@ -156,7 +158,9 @@ data class SoftApConfigurationCompat(
classBuilder.getDeclaredMethod("setMaxNumberOfClients", Int::class.java)
}
@get:RequiresApi(30)
private val setPassphrase by lazy { classBuilder.getDeclaredMethod("setPassphrase", String::class.java) }
private val setPassphrase by lazy {
classBuilder.getDeclaredMethod("setPassphrase", String::class.java, Int::class.java)
}
@get:RequiresApi(30)
private val setShutdownTimeoutMillis by lazy {
classBuilder.getDeclaredMethod("setShutdownTimeoutMillis", Long::class.java)
@@ -186,7 +190,7 @@ data class SoftApConfigurationCompat(
}
},
preSharedKey,
if (Build.VERSION.SDK_INT >= 23) apBand.getInt(this) else BAND_ANY, // TODO
if (Build.VERSION.SDK_INT >= 23) apBand.getInt(this) + 1 else BAND_ANY, // TODO
if (Build.VERSION.SDK_INT >= 23) apChannel.getInt(this) else CH_INVALID, // TODO
BSSID?.let { MacAddressCompat.fromString(it) }?.addr,
0, // TODO: unsupported field should have @RequiresApi?
@@ -275,10 +279,10 @@ data class SoftApConfigurationCompat(
// TODO: can we always call copy constructor?
val builder = if (sac == null) classBuilder.newInstance() else newBuilder.newInstance(sac)
setSsid(builder, ssid)
// TODO: setSecurityType
setPassphrase(builder, passphrase)
setBand(builder, band)
setChannel(builder, channel)
setPassphrase(builder, passphrase, securityType)
// TODO: how to use these?
// setBand(builder, band)
// setChannel(builder, band, channel)
setBssid(builder, bssid?.toPlatform())
setMaxNumberOfClients(builder, maxNumberOfClients)
setShutdownTimeoutMillis(builder, shutdownTimeoutMillis)

View File

@@ -5,8 +5,8 @@ import android.net.wifi.SoftApConfiguration
import android.net.wifi.WifiManager
import android.os.Build
import androidx.annotation.RequiresApi
import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.net.wifi.SoftApConfigurationCompat.Companion.toCompat
import be.mygod.vpnhotspot.util.Services
object WifiApManager {
private val getWifiApConfiguration by lazy { WifiManager::class.java.getDeclaredMethod("getWifiApConfiguration") }
@@ -22,22 +22,18 @@ object WifiApManager {
WifiManager::class.java.getDeclaredMethod("setSoftApConfiguration", SoftApConfiguration::class.java)
}
var configuration: SoftApConfigurationCompat
get() = if (Build.VERSION.SDK_INT < 30) @Suppress("DEPRECATION") {
(getWifiApConfiguration(app.wifi) as android.net.wifi.WifiConfiguration?)?.toCompat()
val configuration get() = if (Build.VERSION.SDK_INT < 30) @Suppress("DEPRECATION") {
(getWifiApConfiguration(Services.wifi) as android.net.wifi.WifiConfiguration?)?.toCompat()
?: SoftApConfigurationCompat.empty()
} else (getSoftApConfiguration(app.wifi) as SoftApConfiguration).toCompat()
set(value) = if (Build.VERSION.SDK_INT < 30) @Suppress("DEPRECATION") {
require(setWifiApConfiguration(app.wifi,
value.toWifiConfiguration()) as Boolean) { "setWifiApConfiguration failed" }
} else require(setSoftApConfiguration(app.wifi, value.toPlatform()) as Boolean) {
"setSoftApConfiguration failed"
}
} else (getSoftApConfiguration(Services.wifi) as SoftApConfiguration).toCompat()
fun setConfiguration(value: SoftApConfigurationCompat) = (if (Build.VERSION.SDK_INT < 30) @Suppress("DEPRECATION") {
setWifiApConfiguration(Services.wifi, value.toWifiConfiguration())
} else setSoftApConfiguration(Services.wifi, value.toPlatform())) as Boolean
private val cancelLocalOnlyHotspotRequest by lazy {
WifiManager::class.java.getDeclaredMethod("cancelLocalOnlyHotspotRequest")
}
fun cancelLocalOnlyHotspotRequest() = cancelLocalOnlyHotspotRequest(app.wifi)
fun cancelLocalOnlyHotspotRequest() = cancelLocalOnlyHotspotRequest(Services.wifi)
@Suppress("DEPRECATION")
private val setWifiApEnabled by lazy {
@@ -66,13 +62,13 @@ object WifiApManager {
@Suppress("DEPRECATION")
@Deprecated("Not usable since API 26, malfunctioning on API 25")
fun start(wifiConfig: android.net.wifi.WifiConfiguration? = null) {
app.wifi.isWifiEnabled = false
app.wifi.setWifiApEnabled(wifiConfig, true)
Services.wifi.isWifiEnabled = false
Services.wifi.setWifiApEnabled(wifiConfig, true)
}
@Suppress("DEPRECATION")
@Deprecated("Not usable since API 26")
fun stop() {
app.wifi.setWifiApEnabled(null, false)
app.wifi.isWifiEnabled = true
Services.wifi.setWifiApEnabled(null, false)
Services.wifi.isWifiEnabled = true
}
}

View File

@@ -13,6 +13,7 @@ import androidx.core.content.getSystemService
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.util.Services
/**
* This mechanism is used to maximize profit. Source: https://stackoverflow.com/a/29657230/2245107
@@ -91,7 +92,7 @@ class WifiDoubleLock(lockType: Int) : AutoCloseable {
override fun onDestroy(owner: LifecycleOwner) = app.pref.unregisterOnSharedPreferenceChangeListener(this)
}
private val wifi = app.wifi.createWifiLock(lockType, "vpnhotspot:wifi").apply { acquire() }
private val wifi = Services.wifi.createWifiLock(lockType, "vpnhotspot:wifi").apply { acquire() }
@SuppressLint("WakelockTimeout")
private val power = service.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "vpnhotspot:power").apply { acquire() }

View File

@@ -0,0 +1,185 @@
package be.mygod.vpnhotspot.root
import android.content.Context
import android.os.Build
import android.os.Parcelable
import android.os.RemoteException
import android.util.Log
import androidx.annotation.RequiresApi
import be.mygod.librootkotlinx.*
import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.net.Routing
import be.mygod.vpnhotspot.net.TetheringManager
import kotlinx.android.parcel.Parcelize
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.produce
import java.io.File
import java.io.FileOutputStream
import java.io.InterruptedIOException
import java.util.concurrent.Executor
@Parcelize
class Dump(val path: String, val cacheDir: File = app.deviceStorage.codeCacheDir) : RootCommandNoResult {
@Suppress("BlockingMethodInNonBlockingContext")
override suspend fun execute() = withContext(Dispatchers.IO) {
FileOutputStream(path, true).use { out ->
val process = ProcessBuilder("sh").redirectErrorStream(true).start()
process.outputStream.bufferedWriter().use { commands ->
// https://android.googlesource.com/platform/external/iptables/+/android-7.0.0_r1/iptables/Android.mk#34
val iptablesSave = if (Build.VERSION.SDK_INT >= 24) "iptables-save" else
File(cacheDir, "iptables-save").absolutePath.also {
commands.appendln("ln -sf /system/bin/iptables $it")
}
val ip6tablesSave = if (Build.VERSION.SDK_INT >= 24) "ip6tables-save" else
File(cacheDir, "ip6tables-save").absolutePath.also {
commands.appendln("ln -sf /system/bin/ip6tables $it")
}
commands.appendln("""
|echo dumpsys ${Context.WIFI_P2P_SERVICE}
|dumpsys ${Context.WIFI_P2P_SERVICE}
|echo
|echo dumpsys ${Context.CONNECTIVITY_SERVICE} tethering
|dumpsys ${Context.CONNECTIVITY_SERVICE} tethering
|echo
|echo iptables -t filter
|$iptablesSave -t filter
|echo
|echo iptables -t nat
|$iptablesSave -t nat
|echo
|echo ip6tables-save
|$ip6tablesSave
|echo
|echo ip rule
|ip rule
|echo
|echo ip neigh
|ip neigh
|echo
|echo iptables -nvx -L vpnhotspot_fwd
|${Routing.IPTABLES} -nvx -L vpnhotspot_fwd
|echo
|echo iptables -nvx -L vpnhotspot_acl
|${Routing.IPTABLES} -nvx -L vpnhotspot_acl
|echo
|echo logcat-su
|logcat -d
""".trimMargin())
}
process.inputStream.copyTo(out)
check(process.waitFor() == 0)
}
null
}
}
sealed class ProcessData : Parcelable {
@Parcelize
data class StdoutLine(val line: String) : ProcessData()
@Parcelize
data class StderrLine(val line: String) : ProcessData()
@Parcelize
data class Exit(val code: Int) : ProcessData()
}
@Parcelize
class ProcessListener(private val terminateRegex: Regex,
private vararg val command: String) : RootCommandChannel<ProcessData> {
override fun create(scope: CoroutineScope) = scope.produce(Dispatchers.IO, capacity) {
val process = ProcessBuilder(*command).start()
val parent = Job() // we need to destroy process before joining, so we cannot use coroutineScope
try {
launch(parent) {
try {
process.inputStream.bufferedReader().forEachLine {
check(offer(ProcessData.StdoutLine(it)))
if (terminateRegex.containsMatchIn(it)) process.destroy()
}
} catch (_: InterruptedIOException) { }
}
launch(parent) {
try {
process.errorStream.bufferedReader().forEachLine { check(offer(ProcessData.StderrLine(it))) }
} catch (_: InterruptedIOException) { }
}
launch(parent) { check(offer(ProcessData.Exit(process.waitFor()))) }
parent.join()
} finally {
parent.cancel()
if (Build.VERSION.SDK_INT < 26) process.destroy() else if (process.isAlive) process.destroyForcibly()
parent.join()
}
}
}
@Parcelize
class ReadArp : RootCommand<ParcelableString> {
override suspend fun execute() = withContext(Dispatchers.IO) {
ParcelableString(File("/proc/net/arp").bufferedReader().readText())
}
}
@Parcelize
@RequiresApi(30)
class StartTethering(private val type: Int, private val showProvisioningUi: Boolean) : RootCommand<ParcelableInt?> {
override suspend fun execute(): ParcelableInt? {
val future = CompletableDeferred<Int?>()
val callback = object : TetheringManager.StartTetheringCallback {
override fun onTetheringStarted() {
future.complete(null)
}
override fun onTetheringFailed(error: Int?) {
future.complete(error!!)
}
}
TetheringManager.startTethering(type, true, showProvisioningUi, Executor {
GlobalScope.launch(Dispatchers.Unconfined) { it.run() }
}, TetheringManager.proxy(callback))
return future.await()?.let { ParcelableInt(it) }
}
}
@Deprecated("Old API since API 30")
@Parcelize
@RequiresApi(24)
@Suppress("DEPRECATION")
class StartTetheringLegacy(private val cacheDir: File, private val type: Int,
private val showProvisioningUi: Boolean) : RootCommand<ParcelableBoolean> {
override suspend fun execute(): ParcelableBoolean {
val future = CompletableDeferred<Boolean>()
val callback = object : TetheringManager.StartTetheringCallback {
override fun onTetheringStarted() {
future.complete(true)
}
override fun onTetheringFailed(error: Int?) {
check(error == null)
future.complete(false)
}
}
TetheringManager.startTetheringLegacy(type, showProvisioningUi, callback, cacheDir = cacheDir)
return ParcelableBoolean(future.await())
}
}
@Parcelize
@RequiresApi(24)
class StopTethering(private val type: Int) : RootCommandNoResult {
override suspend fun execute(): Parcelable? {
TetheringManager.stopTethering(type)
return null
}
}
@Parcelize
class SettingsGlobalPut(val name: String, val value: String) : RootCommandNoResult {
@Suppress("BlockingMethodInNonBlockingContext")
override suspend fun execute() = withContext(Dispatchers.IO) {
val process = ProcessBuilder("settings", "put", "global", name, value).redirectErrorStream(true).start()
val error = process.inputStream.bufferedReader().readText()
check(process.waitFor() == 0)
if (error.isNotEmpty()) throw RemoteException(error)
null
}
}

View File

@@ -0,0 +1,78 @@
package be.mygod.vpnhotspot.root
import android.net.wifi.p2p.WifiP2pManager
import android.os.Looper
import android.os.Parcelable
import android.system.Os
import android.system.OsConstants
import android.text.TextUtils
import be.mygod.librootkotlinx.ParcelableInt
import be.mygod.librootkotlinx.RootCommand
import be.mygod.librootkotlinx.RootCommandNoResult
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.setWifiP2pChannels
import be.mygod.vpnhotspot.util.Services
import eu.chainfire.librootjava.RootJava
import kotlinx.android.parcel.Parcelize
import kotlinx.coroutines.CompletableDeferred
import java.io.File
object RepeaterCommands {
@Parcelize
class SetChannel(private val oc: Int, private val forceReinit: Boolean = false) : RootCommand<ParcelableInt?> {
override suspend fun execute() = Services.p2p!!.run {
if (forceReinit) channel = null
val uninitializer = object : WifiP2pManager.ChannelListener {
var target: WifiP2pManager.Channel? = null
override fun onChannelDisconnected() {
if (target == channel) channel = null
}
}
val channel = channel ?: initialize(RootJava.getSystemContext(),
Looper.getMainLooper(), uninitializer)
uninitializer.target = channel
RepeaterCommands.channel = channel // cache the instance until invalidated
val future = CompletableDeferred<Int?>()
setWifiP2pChannels(channel, 0, oc, object : WifiP2pManager.ActionListener {
override fun onSuccess() {
future.complete(null)
}
override fun onFailure(reason: Int) {
future.complete(reason)
}
})
future.await()?.let { ParcelableInt(it) }
}
}
@Parcelize
data class WriteP2pConfig(val data: String, val legacy: Boolean) : RootCommandNoResult {
override suspend fun execute(): Parcelable? {
File(if (legacy) CONF_PATH_LEGACY else CONF_PATH_TREBLE).writeText(data)
for (process in File("/proc").listFiles { _, name -> TextUtils.isDigitsOnly(name) }!!) {
if (File(File(process, "cmdline").inputStream().bufferedReader().readText()
.split(Char.MIN_VALUE, limit = 2).first()).name == "wpa_supplicant") {
Os.kill(process.name.toInt(), OsConstants.SIGTERM)
}
}
return null
}
}
@Parcelize
class ReadP2pConfig : RootCommand<WriteP2pConfig> {
private fun test(path: String) = File(path).run {
if (canRead()) readText() else null
}
override suspend fun execute(): WriteP2pConfig {
test(CONF_PATH_TREBLE)?.let { return WriteP2pConfig(it, false) }
test(CONF_PATH_LEGACY)?.let { return WriteP2pConfig(it, true) }
throw IllegalStateException("p2p config file not found")
}
}
private const val CONF_PATH_TREBLE = "/data/vendor/wifi/wpa/p2p_supplicant.conf"
private const val CONF_PATH_LEGACY = "/data/misc/wifi/p2p_supplicant.conf"
private var channel: WifiP2pManager.Channel? = null
}

View File

@@ -0,0 +1,33 @@
package be.mygod.vpnhotspot.root
import android.os.Parcelable
import be.mygod.librootkotlinx.RootCommandNoResult
import be.mygod.librootkotlinx.RootServer
import be.mygod.librootkotlinx.RootSession
import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.BuildConfig
import be.mygod.vpnhotspot.util.Services
import eu.chainfire.librootjava.RootJava
import kotlinx.android.parcel.Parcelize
import timber.log.Timber
object RootManager : RootSession() {
@Parcelize
class RootInit : RootCommandNoResult {
override suspend fun execute(): Parcelable? {
Services.init(RootJava.getSystemContext())
return null
}
}
override fun createServer() = RootServer { Timber.w(it) }
override suspend fun initServer(server: RootServer) {
RootServer.DEBUG = BuildConfig.DEBUG
try {
server.init(app)
} finally {
server.readUnexpectedStderr()?.let { Timber.e(it) }
}
server.execute(RootInit())
}
}

View File

@@ -0,0 +1,59 @@
package be.mygod.vpnhotspot.root
import android.os.Parcelable
import android.util.Log
import be.mygod.librootkotlinx.RootCommand
import be.mygod.librootkotlinx.RootCommandOneWay
import be.mygod.vpnhotspot.net.Routing
import kotlinx.android.parcel.Parcelize
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
object RoutingCommands {
@Parcelize
class Clean : RootCommandOneWay {
@Suppress("BlockingMethodInNonBlockingContext")
override suspend fun execute() = withContext(Dispatchers.IO) {
val process = ProcessBuilder("sh").redirectErrorStream(true).start()
process.outputStream.bufferedWriter().use(Routing.Companion::appendCleanCommands)
when (val code = process.waitFor()) {
0 -> { }
else -> Log.d("RoutingCommands.Clean", "Unexpected exit code $code")
}
check(process.waitFor() == 0)
}
}
class UnexpectedOutputException(msg: String, val result: ProcessResult) : RuntimeException(msg)
@Parcelize
data class ProcessResult(val exit: Int, val out: String, val err: String) : Parcelable {
fun message(command: List<String>, out: Boolean = this.out.isNotEmpty(),
err: Boolean = this.err.isNotEmpty()): String? {
val msg = StringBuilder("${command.joinToString(" ")} exited with $exit")
if (out) msg.append("\n${this.out}")
if (err) msg.append("\n=== stderr ===\n${this.err}")
return if (exit != 0 || out || err) msg.toString() else null
}
fun check(command: List<String>, out: Boolean = this.out.isNotEmpty(),
err: Boolean = this.err.isNotEmpty()) = message(command, out, err)?.let { msg ->
throw UnexpectedOutputException(msg, this)
}
}
@Parcelize
class Process(val command: List<String>, private val redirect: Boolean = false) : RootCommand<ProcessResult> {
@Suppress("BlockingMethodInNonBlockingContext")
override suspend fun execute() = withContext(Dispatchers.IO) {
val process = ProcessBuilder(command).redirectErrorStream(redirect).start()
coroutineScope {
val output = async { process.inputStream.bufferedReader().readText() }
val error = async { if (redirect) "" else process.errorStream.bufferedReader().readText() }
ProcessResult(process.waitFor(), output.await(), error.await())
}
}
}
}

View File

@@ -0,0 +1,19 @@
package be.mygod.vpnhotspot.root
import be.mygod.librootkotlinx.ParcelableBoolean
import be.mygod.librootkotlinx.RootCommand
import be.mygod.vpnhotspot.net.wifi.SoftApConfigurationCompat
import be.mygod.vpnhotspot.net.wifi.WifiApManager
import kotlinx.android.parcel.Parcelize
object WifiApCommands {
@Parcelize
class GetConfiguration : RootCommand<SoftApConfigurationCompat> {
override suspend fun execute() = WifiApManager.configuration
}
@Parcelize
data class SetConfiguration(val configuration: SoftApConfigurationCompat) : RootCommand<ParcelableBoolean> {
override suspend fun execute() = ParcelableBoolean(WifiApManager.setConfiguration(configuration))
}
}

View File

@@ -1,127 +1,50 @@
package be.mygod.vpnhotspot.util
import android.os.Looper
import androidx.annotation.WorkerThread
import com.topjohnwu.superuser.Shell
import kotlinx.coroutines.*
import be.mygod.librootkotlinx.RootServer
import be.mygod.vpnhotspot.root.RootManager
import be.mygod.vpnhotspot.root.RoutingCommands
import kotlinx.coroutines.runBlocking
import timber.log.Timber
import java.util.*
import java.util.concurrent.TimeUnit
import java.util.concurrent.locks.ReentrantLock
import kotlin.collections.ArrayList
import kotlin.concurrent.withLock
class RootSession : AutoCloseable {
companion object {
private val monitor = ReentrantLock()
private fun onUnlock() {
if (monitor.holdCount == 1) instance?.startTimeoutLocked()
}
private fun unlock() {
onUnlock()
monitor.unlock()
}
private var instance: RootSession? = null
private fun ensureInstance(): RootSession {
var instance = instance
if (instance == null || !instance.isAlive) instance = RootSession().also { RootSession.instance = it }
return instance
}
fun <T> use(operation: (RootSession) -> T) = monitor.withLock {
val instance = ensureInstance()
instance.haltTimeoutLocked()
operation(instance).also { onUnlock() }
}
fun <T> use(operation: (RootSession) -> T) = monitor.withLock { operation(RootSession()) }
fun beginTransaction(): Transaction {
monitor.lock()
val instance = try {
ensureInstance()
RootSession()
} catch (e: RuntimeException) {
unlock()
monitor.unlock()
throw e
}
instance.haltTimeoutLocked()
return instance.Transaction()
}
@WorkerThread
fun trimMemory() = monitor.withLock {
val instance = instance ?: return
instance.haltTimeoutLocked()
instance.close()
}
fun checkOutput(command: String, result: Shell.Result, out: Boolean = result.out.isNotEmpty(),
err: Boolean = result.err.isNotEmpty()): String {
val msg = StringBuilder("$command exited with ${result.code}")
if (out) result.out.forEach { msg.append("\n$it") }
if (err) result.err.forEach { msg.append("\nE $it") }
if (!result.isSuccess || out || err) throw UnexpectedOutputException(msg.toString(), result)
return msg.toString()
}
}
class UnexpectedOutputException(msg: String, val result: Shell.Result) : RuntimeException(msg)
init {
check(Looper.getMainLooper().thread != Thread.currentThread()) {
"Unable to initialize shell in main thread" // https://github.com/topjohnwu/libsu/issues/33
}
}
private val shell = Shell.newInstance("su")
private val stdout = ArrayList<String>()
private val stderr = ArrayList<String>()
private val isAlive get() = shell.isAlive
private var server: RootServer? = runBlocking { RootManager.acquire() }
override fun close() {
shell.close()
if (instance == this) instance = null
}
private var timeoutJob: Job? = null
private fun startTimeoutLocked() {
check(timeoutJob == null)
timeoutJob = GlobalScope.launch(start = CoroutineStart.UNDISPATCHED) {
delay(TimeUnit.MINUTES.toMillis(5))
monitor.withLock {
close()
timeoutJob = null
}
}
}
private fun haltTimeoutLocked() {
timeoutJob?.cancel()
timeoutJob = null
server = null
server?.let { runBlocking { RootManager.release(it) } }
}
/**
* Don't care about the results, but still sync.
*/
fun submit(command: String) {
val result = execQuiet(command)
val err = result.err.joinToString("\n") { "E $it" }.trim()
val out = result.out.joinToString("\n").trim()
if (result.code != 0 || err.isNotEmpty() || out.isNotEmpty()) {
Timber.v("$command exited with ${result.code}")
if (err.isNotEmpty()) Timber.v(err)
if (out.isNotEmpty()) Timber.v(out)
}
}
fun submit(command: String) = execQuiet(command).message(listOf(command))?.let { Timber.v(it) }
fun execQuiet(command: String, redirect: Boolean = false): Shell.Result {
stdout.clear()
return shell.newJob().add(command).to(stdout, if (redirect) stdout else {
stderr.clear()
stderr
}).exec()
fun execQuiet(command: String, redirect: Boolean = false) = runBlocking {
server!!.execute(RoutingCommands.Process(listOf("sh", "-c", command), redirect))
}
fun exec(command: String) = checkOutput(command, execQuiet(command))
fun exec(command: String) = execQuiet(command).check(listOf(command))
fun execOut(command: String): String {
val result = execQuiet(command)
checkOutput(command, result, false)
return result.out.joinToString("\n")
result.check(listOf(command), false)
return result.out
}
/**
@@ -130,13 +53,13 @@ class RootSession : AutoCloseable {
inner class Transaction {
private val revertCommands = LinkedList<String>()
fun exec(command: String, revert: String? = null) = checkOutput(command, execQuiet(command, revert))
fun execQuiet(command: String, revert: String? = null): Shell.Result {
fun exec(command: String, revert: String? = null) = execQuiet(command, revert).check(listOf(command))
fun execQuiet(command: String, revert: String? = null): RoutingCommands.ProcessResult {
if (revert != null) revertCommands.addFirst(revert) // add first just in case exec fails
return this@RootSession.execQuiet(command)
}
fun commit() = unlock()
fun commit() = monitor.unlock()
fun revert() {
if (revertCommands.isEmpty()) return
@@ -145,15 +68,14 @@ class RootSession : AutoCloseable {
val shell = if (locked) this@RootSession else {
monitor.lock()
locked = true
ensureInstance()
RootSession()
}
shell.haltTimeoutLocked()
revertCommands.forEach { shell.submit(it) }
} catch (e: RuntimeException) { // if revert fails, it should fail silently
Timber.d(e)
} finally {
revertCommands.clear()
if (locked) unlock() // commit
if (locked) monitor.unlock() // commit
}
}

View File

@@ -0,0 +1,29 @@
package be.mygod.vpnhotspot.util
import android.annotation.SuppressLint
import android.content.Context
import android.net.ConnectivityManager
import android.net.wifi.WifiManager
import android.net.wifi.p2p.WifiP2pManager
import android.util.Log
import androidx.core.content.getSystemService
import timber.log.Timber
@SuppressLint("LogNotTimber")
object Services {
lateinit var context: Context
fun init(context: Context) {
this.context = context
}
val connectivity by lazy { context.getSystemService<ConnectivityManager>()!! }
val p2p by lazy {
try {
context.getSystemService<WifiP2pManager>()
} catch (e: RuntimeException) {
if (android.os.Process.myUid() == 0) Log.w("WifiP2pManager", e) else Timber.w(e)
null
}
}
val wifi by lazy { context.getSystemService<WifiManager>()!! }
}

View File

@@ -25,12 +25,15 @@ import be.mygod.vpnhotspot.net.MacAddressCompat
import be.mygod.vpnhotspot.widget.SmartSnackbar
import java.lang.invoke.MethodHandles
import java.lang.reflect.InvocationHandler
import java.lang.reflect.InvocationTargetException
import java.lang.reflect.Method
import java.net.InetAddress
import java.net.NetworkInterface
import java.net.SocketException
val Throwable.readableMessage get() = localizedMessage ?: javaClass.name
val Throwable.readableMessage: String get() = if (this is InvocationTargetException) {
targetException.readableMessage
} else localizedMessage ?: javaClass.name
/**
* This is a hack: we wrap longs around in 1 billion and such. Hopefully every language counts in base 10 and this works

View File

@@ -11,7 +11,6 @@ import androidx.lifecycle.findViewTreeLifecycleOwner
import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.util.readableMessage
import com.google.android.material.snackbar.Snackbar
import com.topjohnwu.superuser.NoShellException
import java.util.concurrent.atomic.AtomicReference
sealed class SmartSnackbar {
@@ -26,10 +25,7 @@ sealed class SmartSnackbar {
ToastWrapper(Toast.makeText(app, text, Toast.LENGTH_LONG))
} else SnackbarWrapper(Snackbar.make(holder, text, Snackbar.LENGTH_LONG))
}
fun make(e: Throwable) = make(when (e) {
is NoShellException -> e.cause ?: e
else -> e
}.readableMessage)
fun make(e: Throwable) = make(e.readableMessage)
}
class Register(private val view: View) : DefaultLifecycleObserver {