diff --git a/README.md b/README.md
index d0ba778f..ebd95004 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/build.gradle.kts b/build.gradle.kts
index b878f78f..86d3c69d 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -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")
}
diff --git a/mobile/build.gradle.kts b/mobile/build.gradle.kts
index 6c105823..ce04df99 100644
--- a/mobile/build.gradle.kts
+++ b/mobile/build.gradle.kts
@@ -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")
diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml
index c922ce56..3776c5f2 100644
--- a/mobile/src/main/AndroidManifest.xml
+++ b/mobile/src/main/AndroidManifest.xml
@@ -36,14 +36,14 @@
tools:ignore="ProtectedPermissions" />
-
+
diff --git a/mobile/src/main/java/be/mygod/librootkotlinx/RootServer.kt b/mobile/src/main/java/be/mygod/librootkotlinx/RootServer.kt
new file mode 100644
index 00000000..f864c3cf
--- /dev/null
+++ b/mobile/src/main/java/be/mygod/librootkotlinx/RootServer.kt
@@ -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) : 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(classLoader) as Throwable?))
+ else -> throw IllegalArgumentException("Unexpected result $result")
+ }
+ }
+ }
+
+ class Channel(private val classLoader: ClassLoader?,
+ private val channel: SendChannel,
+ private val server: RootServer,
+ private val index: Long) : Callback() {
+ var active = true
+ val finish: CompletableDeferred = 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(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()
+ private val callbackLookup = LongSparseArray()
+ 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()
+ 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 execute(command: RootCommand) =
+ execute(command, T::class.java.classLoader)
+ @Throws(RemoteException::class)
+ suspend fun execute(command: RootCommand, classLoader: ClassLoader?): T {
+ val future = CompletableDeferred()
+ mutex.withLock {
+ if (active) {
+ @Suppress("UNCHECKED_CAST")
+ callbackLookup[counter] = Callback.Ordinary(classLoader, future as CompletableDeferred)
+ sendLocked(command)
+ } else future.cancel()
+ }
+ return future.await()
+ }
+
+ @ExperimentalCoroutinesApi
+ @Throws(RemoteException::class)
+ inline fun create(command: RootCommandChannel, scope: CoroutineScope) =
+ create(command, scope, T::class.java.classLoader)
+ @ExperimentalCoroutinesApi
+ @Throws(RemoteException::class)
+ fun create(command: RootCommandChannel, scope: CoroutineScope,
+ classLoader: ClassLoader?) = scope.produce(
+ 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, 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 DataInputStream.readParcelable(
+ classLoader: ClassLoader? = T::class.java.classLoader
+ ) = ByteArray(readInt()).also { readFully(it) }.toParcelable(classLoader)
+ private fun DataOutputStream.writeParcelable(data: Parcelable?, parcelableFlags: Int = 0) {
+ val bytes = data.toByteArray(parcelableFlags)
+ writeInt(bytes.size)
+ write(bytes)
+ }
+
+ private inline fun Mutex.synchronized(crossinline block: () -> T): T = runBlocking {
+ withLock { block() }
+ }
+
+ @JvmStatic
+ fun main(args: Array) {
+ 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) {
+ 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>>()
+
+ // 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(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")
+ }
+ }
+}
diff --git a/mobile/src/main/java/be/mygod/librootkotlinx/RootSession.kt b/mobile/src/main/java/be/mygod/librootkotlinx/RootSession.kt
new file mode 100644
index 00000000..d2c7d24d
--- /dev/null
+++ b/mobile/src/main/java/be/mygod/librootkotlinx/RootSession.kt
@@ -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 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()
+ }
+ }
+}
diff --git a/mobile/src/main/java/be/mygod/librootkotlinx/ServerCommands.kt b/mobile/src/main/java/be/mygod/librootkotlinx/ServerCommands.kt
new file mode 100644
index 00000000..afbe7d6b
--- /dev/null
+++ b/mobile/src/main/java/be/mygod/librootkotlinx/ServerCommands.kt
@@ -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 : 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
+
+/**
+ * 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 : 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
+}
+
+@Parcelize
+internal class ChannelClosed(val index: Long) : RootCommandOneWay {
+ override suspend fun execute() = throw IllegalStateException("Internal implementation")
+}
+
+@Parcelize
+internal class Shutdown : Parcelable
diff --git a/mobile/src/main/java/be/mygod/librootkotlinx/Utils.kt b/mobile/src/main/java/be/mygod/librootkotlinx/Utils.kt
new file mode 100644
index 00000000..f500a617
--- /dev/null
+++ b/mobile/src/main/java/be/mygod/librootkotlinx/Utils.kt
@@ -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) : 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) : 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 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 ByteArray.toParcelable(classLoader: ClassLoader? = T::class.java.classLoader) =
+ useParcel { p ->
+ p.unmarshall(this, 0, size)
+ p.setDataPosition(0)
+ p.readParcelable(classLoader)
+ }
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/App.kt b/mobile/src/main/java/be/mygod/vpnhotspot/App.kt
index 9e26c626..1baa6f11 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/App.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/App.kt
@@ -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()!! }
val clipboard by lazy { getSystemService()!! }
- val uiMode by lazy { getSystemService()!! }
- val wifi by lazy { getSystemService()!! }
val hasTouch by lazy { packageManager.hasSystemFeature("android.hardware.faketouch") }
val customTabsIntent by lazy {
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/BootReceiver.kt b/mobile/src/main/java/be/mygod/vpnhotspot/BootReceiver.kt
index 86581a2c..7bdd97aa 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/BootReceiver.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/BootReceiver.kt
@@ -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))
}
}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/IpNeighbourMonitoringService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/IpNeighbourMonitoringService.kt
index 7ec88494..f49f7608 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/IpNeighbourMonitoringService.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/IpNeighbourMonitoringService.kt
@@ -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 = 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
}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt
index 22ad60f0..e766e626 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt
@@ -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) {
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
})
}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/MainActivity.kt b/mobile/src/main/java/be/mygod/vpnhotspot/MainActivity.kt
index 65a2810d..dc0c0839 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/MainActivity.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/MainActivity.kt
@@ -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()
- 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 }
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt
index 4813ea15..e844e289 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt
@@ -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()
- } 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
- } catch (e: RuntimeException) {
- Timber.d(e)
- null
- }
+ 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()
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/RoutingManager.kt b/mobile/src/main/java/be/mygod/vpnhotspot/RoutingManager.kt
index e8c7f6cb..7f523382 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/RoutingManager.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/RoutingManager.kt
@@ -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()
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/SettingsPreferenceFragment.kt b/mobile/src/main/java/be/mygod/vpnhotspot/SettingsPreferenceFragment.kt
index 265e940f..5e13c477 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/SettingsPreferenceFragment.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/SettingsPreferenceFragment.kt
@@ -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
- } catch (e: Exception) {
- Timber.d(e)
- SmartSnackbar.make(e).show()
- }
- withContext(Dispatchers.Main) {
- isChecked = TetherOffloadManager.enabled
- isEnabled = true
- }
+ try {
+ TetherOffloadManager.setEnabled(newValue as Boolean)
+ } catch (e: Exception) {
+ Timber.w(e)
+ SmartSnackbar.make(e).show()
}
+ isChecked = TetherOffloadManager.enabled
+ isEnabled = true
}
false
}
} else parent!!.removePreference(this)
}
val boot = findPreference("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(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,57 +104,19 @@ 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) }
- } catch (e: Exception) {
- e.printStackTrace(writer)
- Timber.i(e)
- }
}
}
+ try {
+ RootManager.use {
+ it.execute(Dump(logFile.absolutePath))
+ }
+ } catch (e: Exception) {
+ Timber.w(e)
+ PrintWriter(FileOutputStream(logFile, true)).use { e.printStackTrace(it) }
+ }
context.startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND)
.setType("text/x-log")
.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
@@ -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)
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt
index 08939003..b13934e6 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt
@@ -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)
}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientsFragment.kt b/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientsFragment.kt
index 3d788862..911b47ec 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientsFragment.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientsFragment.kt
@@ -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().value.clients.observe(viewLifecycleOwner) {
adapter.submitList(it.toMutableList())
}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/manage/BluetoothTethering.kt b/mobile/src/main/java/be/mygod/vpnhotspot/manage/BluetoothTethering.kt
index 6ba55ed1..75d6aab5 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/manage/BluetoothTethering.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/manage/BluetoothTethering.kt
@@ -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()
}
}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/manage/IpNeighbourMonitoringTileService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/manage/IpNeighbourMonitoringTileService.kt
index 0e457988..31308799 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/manage/IpNeighbourMonitoringTileService.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/manage/IpNeighbourMonitoringTileService.kt
@@ -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(
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/manage/RepeaterManager.kt b/mobile/src/main/java/be/mygod/vpnhotspot/manage/RepeaterManager.kt
index 8312ed70..bdda7317 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/manage/RepeaterManager.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/manage/RepeaterManager.kt
@@ -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
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/manage/RepeaterTileService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/manage/RepeaterTileService.kt
index 28d82cd5..d33ed8c9 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/manage/RepeaterTileService.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/manage/RepeaterTileService.kt
@@ -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()
}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetherManager.kt b/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetherManager.kt
index 097c9909..4dd00682 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetherManager.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetherManager.kt
@@ -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")
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringFragment.kt b/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringFragment.kt
index f42baa76..bd1d6c43 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringFragment.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringFragment.kt
@@ -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()
- 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)
+ 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)
+ 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
- } catch (e: InvocationTargetException) {
- if (e.targetException !is SecurityException) Timber.w(e)
- SmartSnackbar.make(e.targetException).show()
- false
}
else -> false
}
@@ -187,13 +202,20 @@ class TetheringFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClick
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
AlertDialogFragment.setResultListener(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()
- } catch (e: InvocationTargetException) {
- SmartSnackbar.make(e.targetException).show()
+ if (which == DialogInterface.BUTTON_POSITIVE) viewLifecycleOwner.lifecycleScope.launchWhenCreated {
+ val success = try {
+ WifiApManager.setConfiguration(ret!!.configuration)
+ } catch (e: InvocationTargetException) {
+ 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)
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringTileService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringTileService.kt
index 1b1a7d76..ecb7ab0e 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringTileService.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringTileService.kt
@@ -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")
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/DhcpWorkaround.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/DhcpWorkaround.kt
index 52ada7f7..4d65de9d 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/net/DhcpWorkaround.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/DhcpWorkaround.kt
@@ -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()
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbour.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbour.kt
index 6cee9471..c93b4ad1 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbour.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbour.kt
@@ -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 {
+ 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>()
private var arpCacheTime = -ARP_CACHE_EXPIRE
+ private fun Sequence.makeArp() = this
+ .map { it.split(spaces) }
+ .drop(1)
+ .filter { it.size >= 6 && mac.matcher(it[ARP_HW_ADDRESS]).matches() }
+ .toList()
private fun arp(): List> {
if (System.nanoTime() - arpCacheTime >= ARP_CACHE_EXPIRE) try {
- arpCache = File("/proc/net/arp").bufferedReader().readLines()
- .asSequence()
- .map { it.split(spaces) }
- .drop(1)
- .filter { it.size >= 6 && mac.matcher(it[ARP_HW_ADDRESS]).matches() }
- .toList()
+ 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
}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/Routing.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/Routing.kt
index 47dd8c5b..33296052 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/net/Routing.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/Routing.kt
@@ -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 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() {
+ 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()
- 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")
- }
+ 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")
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/TetherOffloadManager.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/TetherOffloadManager.kt
index c1dc9e54..ab8337ea 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/net/TetherOffloadManager.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/TetherOffloadManager.kt
@@ -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
}
}
+ }
}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/TetheringManager.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/TetheringManager.kt
index aec8f304..ae35f31d 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/net/TetheringManager.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/TetheringManager.kt
@@ -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?): 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)
+ }
+ return@launch
+ }
+ if (result == null) callback.onTetheringStarted()
+ else callback.onTetheringFailed(result.value)
+ } else callback.onException(e1)
}
- val proxy = Proxy.newProxyInstance(interfaceStartTetheringCallback.classLoader,
- arrayOf(interfaceStartTetheringCallback), object : InvocationHandler {
- override fun invoke(proxy: Any, method: Method, args: Array?): 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?.getOrNull(0) as? Int?)
- }
- 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",
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/DefaultNetworkMonitor.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/DefaultNetworkMonitor.kt
index 1d002c11..8c28e629 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/DefaultNetworkMonitor.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/DefaultNetworkMonitor.kt
@@ -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
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/InterfaceMonitor.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/InterfaceMonitor.kt
index f053c2d2..76929848 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/InterfaceMonitor.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/InterfaceMonitor.kt
@@ -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) {
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/IpLinkMonitor.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/IpLinkMonitor.kt
index c921c1a3..918e00a4 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/IpLinkMonitor.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/IpLinkMonitor.kt
@@ -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
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/IpMonitor.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/IpMonitor.kt
index 5c5a2494..4ede8cc5 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/IpMonitor.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/IpMonitor.kt
@@ -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) {
+ 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()
}
}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/IpNeighbourMonitor.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/IpNeighbourMonitor.kt
index 3537e417..a2d2928e 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/IpNeighbourMonitor.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/IpNeighbourMonitor.kt
@@ -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) }
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/TetherTimeoutMonitor.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/TetherTimeoutMonitor.kt
index bbb90cd7..99a2436f 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/TetherTimeoutMonitor.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/TetherTimeoutMonitor.kt
@@ -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")
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/TrafficRecorder.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/TrafficRecorder.kt
index eb60b046..5ec9ae18 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/TrafficRecorder.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/TrafficRecorder.kt
@@ -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)
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/VpnMonitor.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/VpnMonitor.kt
index 91cdc953..b364b1e6 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/VpnMonitor.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/monitor/VpnMonitor.kt
@@ -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
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/P2pSupplicantConfiguration.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/P2pSupplicantConfiguration.kt
index 77e00d46..2ee6c9f2 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/P2pSupplicantConfiguration.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/P2pSupplicantConfiguration.kt
@@ -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) {
- private val iterator = lines.iterator()
+ private class Parser(val lines: Iterator) {
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, 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()
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)) }
}
}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/SoftApConfigurationCompat.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/SoftApConfigurationCompat.kt
index ac56936e..b2058519 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/SoftApConfigurationCompat.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/SoftApConfigurationCompat.kt
@@ -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)
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiApManager.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiApManager.kt
index 1637077f..19b462ef 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiApManager.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiApManager.kt
@@ -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()
- ?: 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"
- }
+ val configuration get() = if (Build.VERSION.SDK_INT < 30) @Suppress("DEPRECATION") {
+ (getWifiApConfiguration(Services.wifi) as android.net.wifi.WifiConfiguration?)?.toCompat()
+ ?: SoftApConfigurationCompat.empty()
+ } 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
}
}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiDoubleLock.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiDoubleLock.kt
index 4ce1be0c..b019ff43 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiDoubleLock.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiDoubleLock.kt
@@ -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() }
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/root/MiscCommands.kt b/mobile/src/main/java/be/mygod/vpnhotspot/root/MiscCommands.kt
new file mode 100644
index 00000000..d907198e
--- /dev/null
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/root/MiscCommands.kt
@@ -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 {
+ 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 {
+ 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 {
+ override suspend fun execute(): ParcelableInt? {
+ val future = CompletableDeferred()
+ 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 {
+ override suspend fun execute(): ParcelableBoolean {
+ val future = CompletableDeferred()
+ 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
+ }
+}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/root/RepeaterCommands.kt b/mobile/src/main/java/be/mygod/vpnhotspot/root/RepeaterCommands.kt
new file mode 100644
index 00000000..cc949fc3
--- /dev/null
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/root/RepeaterCommands.kt
@@ -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 {
+ 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()
+ 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 {
+ 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
+}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/root/RootManager.kt b/mobile/src/main/java/be/mygod/vpnhotspot/root/RootManager.kt
new file mode 100644
index 00000000..f1e8af4f
--- /dev/null
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/root/RootManager.kt
@@ -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())
+ }
+}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/root/RoutingCommands.kt b/mobile/src/main/java/be/mygod/vpnhotspot/root/RoutingCommands.kt
new file mode 100644
index 00000000..cab9118a
--- /dev/null
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/root/RoutingCommands.kt
@@ -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, 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, 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, private val redirect: Boolean = false) : RootCommand {
+ @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())
+ }
+ }
+ }
+}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/root/WifiApCommands.kt b/mobile/src/main/java/be/mygod/vpnhotspot/root/WifiApCommands.kt
new file mode 100644
index 00000000..ee3527ec
--- /dev/null
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/root/WifiApCommands.kt
@@ -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 {
+ override suspend fun execute() = WifiApManager.configuration
+ }
+
+ @Parcelize
+ data class SetConfiguration(val configuration: SoftApConfigurationCompat) : RootCommand {
+ override suspend fun execute() = ParcelableBoolean(WifiApManager.setConfiguration(configuration))
+ }
+}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/util/RootSession.kt b/mobile/src/main/java/be/mygod/vpnhotspot/util/RootSession.kt
index 25ac9a51..1dd53fde 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/util/RootSession.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/util/RootSession.kt
@@ -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 use(operation: (RootSession) -> T) = monitor.withLock {
- val instance = ensureInstance()
- instance.haltTimeoutLocked()
- operation(instance).also { onUnlock() }
- }
+ fun 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()
- private val stderr = ArrayList()
-
- 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()
- 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
+ } 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
}
}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/util/Services.kt b/mobile/src/main/java/be/mygod/vpnhotspot/util/Services.kt
new file mode 100644
index 00000000..87750f25
--- /dev/null
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/util/Services.kt
@@ -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()!! }
+ val p2p by lazy {
+ try {
+ context.getSystemService()
+ } catch (e: RuntimeException) {
+ if (android.os.Process.myUid() == 0) Log.w("WifiP2pManager", e) else Timber.w(e)
+ null
+ }
+ }
+ val wifi by lazy { context.getSystemService()!! }
+}
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/util/Utils.kt b/mobile/src/main/java/be/mygod/vpnhotspot/util/Utils.kt
index bb4314f7..753cd68d 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/util/Utils.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/util/Utils.kt
@@ -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
diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/widget/SmartSnackbar.kt b/mobile/src/main/java/be/mygod/vpnhotspot/widget/SmartSnackbar.kt
index 148cb766..950872aa 100644
--- a/mobile/src/main/java/be/mygod/vpnhotspot/widget/SmartSnackbar.kt
+++ b/mobile/src/main/java/be/mygod/vpnhotspot/widget/SmartSnackbar.kt
@@ -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 {