From ad218d7ec67347e18ddbd7934a0900a2da6073c8 Mon Sep 17 00:00:00 2001 From: Mygod Date: Sun, 21 Jun 2020 05:33:39 +0800 Subject: [PATCH] librootkotlinx Fixes #14, #27, #114, #117. --- README.md | 10 +- build.gradle.kts | 2 +- mobile/build.gradle.kts | 4 +- mobile/src/main/AndroidManifest.xml | 4 +- .../be/mygod/librootkotlinx/RootServer.kt | 427 ++++++++++++++++++ .../be/mygod/librootkotlinx/RootSession.kt | 106 +++++ .../be/mygod/librootkotlinx/ServerCommands.kt | 47 ++ .../java/be/mygod/librootkotlinx/Utils.kt | 201 +++++++++ .../src/main/java/be/mygod/vpnhotspot/App.kt | 22 +- .../java/be/mygod/vpnhotspot/BootReceiver.kt | 3 +- .../IpNeighbourMonitoringService.kt | 3 +- .../vpnhotspot/LocalOnlyHotspotService.kt | 7 +- .../java/be/mygod/vpnhotspot/MainActivity.kt | 3 +- .../be/mygod/vpnhotspot/RepeaterService.kt | 59 ++- .../be/mygod/vpnhotspot/RoutingManager.kt | 3 +- .../vpnhotspot/SettingsPreferenceFragment.kt | 93 +--- .../be/mygod/vpnhotspot/TetheringService.kt | 4 +- .../vpnhotspot/client/ClientsFragment.kt | 8 +- .../vpnhotspot/manage/BluetoothTethering.kt | 53 +-- .../IpNeighbourMonitoringTileService.kt | 3 +- .../vpnhotspot/manage/RepeaterManager.kt | 5 +- .../vpnhotspot/manage/RepeaterTileService.kt | 5 +- .../mygod/vpnhotspot/manage/TetherManager.kt | 45 +- .../vpnhotspot/manage/TetheringFragment.kt | 66 ++- .../vpnhotspot/manage/TetheringTileService.kt | 55 ++- .../be/mygod/vpnhotspot/net/DhcpWorkaround.kt | 9 +- .../be/mygod/vpnhotspot/net/IpNeighbour.kt | 27 +- .../java/be/mygod/vpnhotspot/net/Routing.kt | 67 ++- .../vpnhotspot/net/TetherOffloadManager.kt | 21 +- .../mygod/vpnhotspot/net/TetheringManager.kt | 173 +++++-- .../net/monitor/DefaultNetworkMonitor.kt | 15 +- .../net/monitor/InterfaceMonitor.kt | 6 +- .../vpnhotspot/net/monitor/IpLinkMonitor.kt | 2 +- .../mygod/vpnhotspot/net/monitor/IpMonitor.kt | 113 +++-- .../net/monitor/IpNeighbourMonitor.kt | 2 +- .../net/monitor/TetherTimeoutMonitor.kt | 1 + .../vpnhotspot/net/monitor/TrafficRecorder.kt | 10 +- .../vpnhotspot/net/monitor/VpnMonitor.kt | 13 +- .../net/wifi/P2pSupplicantConfiguration.kt | 61 +-- .../net/wifi/SoftApConfigurationCompat.kt | 26 +- .../vpnhotspot/net/wifi/WifiApManager.kt | 30 +- .../vpnhotspot/net/wifi/WifiDoubleLock.kt | 3 +- .../be/mygod/vpnhotspot/root/MiscCommands.kt | 185 ++++++++ .../mygod/vpnhotspot/root/RepeaterCommands.kt | 78 ++++ .../be/mygod/vpnhotspot/root/RootManager.kt | 33 ++ .../mygod/vpnhotspot/root/RoutingCommands.kt | 59 +++ .../mygod/vpnhotspot/root/WifiApCommands.kt | 19 + .../be/mygod/vpnhotspot/util/RootSession.kt | 122 +---- .../java/be/mygod/vpnhotspot/util/Services.kt | 29 ++ .../java/be/mygod/vpnhotspot/util/Utils.kt | 5 +- .../mygod/vpnhotspot/widget/SmartSnackbar.kt | 6 +- 51 files changed, 1780 insertions(+), 573 deletions(-) create mode 100644 mobile/src/main/java/be/mygod/librootkotlinx/RootServer.kt create mode 100644 mobile/src/main/java/be/mygod/librootkotlinx/RootSession.kt create mode 100644 mobile/src/main/java/be/mygod/librootkotlinx/ServerCommands.kt create mode 100644 mobile/src/main/java/be/mygod/librootkotlinx/Utils.kt create mode 100644 mobile/src/main/java/be/mygod/vpnhotspot/root/MiscCommands.kt create mode 100644 mobile/src/main/java/be/mygod/vpnhotspot/root/RepeaterCommands.kt create mode 100644 mobile/src/main/java/be/mygod/vpnhotspot/root/RootManager.kt create mode 100644 mobile/src/main/java/be/mygod/vpnhotspot/root/RoutingCommands.kt create mode 100644 mobile/src/main/java/be/mygod/vpnhotspot/root/WifiApCommands.kt create mode 100644 mobile/src/main/java/be/mygod/vpnhotspot/util/Services.kt 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,56 +104,18 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() { Runtime.getRuntime().exec(arrayOf("logcat", "-d")).inputStream.use { it.copyTo(out) } } catch (e: IOException) { Timber.w(e) - } - 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) } + writer.println() + } + } + 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") @@ -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) - } - 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?) + 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) } - else -> callSuper(interfaceStartTetheringCallback, proxy, method, args) - } - } - }) - 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) + return@launch } + if (result == null) callback.onTetheringStarted() + else callback.onTetheringFailed(result.value) + } else callback.onException(e1) + } + } 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 } - }.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 {