feat: add shizuku support (#852)

This commit is contained in:
Zane Schepke
2025-07-05 20:49:02 -04:00
committed by GitHub
parent c8b65fb7fa
commit a223289949
16 changed files with 272 additions and 89 deletions
+4
View File
@@ -219,6 +219,10 @@ dependencies {
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.slf4j.android)
// shizuku
implementation(libs.shizuku.api)
implementation(libs.shizuku.provider)
}
tasks.register<Copy>("copyLicenseeJsonToAssets") {
@@ -87,6 +87,8 @@ class MainActivity : AppCompatActivity() {
private var lastLocationPermissionState: Boolean? = null
val REQUEST_CODE = 123
@SuppressLint("BatteryLife")
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge(
@@ -24,22 +24,10 @@ import com.zaneschepke.wireguardautotunnel.util.extensions.distinctByKeys
import dagger.hilt.android.AndroidEntryPoint
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import timber.log.Timber
@AndroidEntryPoint
@@ -29,21 +29,8 @@ import com.zaneschepke.wireguardautotunnel.util.extensions.Tunnels
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import javax.inject.Provider
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import timber.log.Timber
@AndroidEntryPoint
@@ -21,7 +21,9 @@ import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.map
import org.amnezia.awg.backend.Backend
import org.amnezia.awg.backend.GoBackend
import org.amnezia.awg.backend.RootTunnelActionHandler
@@ -112,11 +114,22 @@ class TunnelModule {
fun provideNetworkMonitor(
@ApplicationContext context: Context,
settingsRepository: AppSettingRepository,
@ApplicationScope applicationScope: CoroutineScope,
@AppShell appShell: RootShell,
): NetworkMonitor {
val method = runBlocking { settingsRepository.get().wifiDetectionMethod }
return AndroidNetworkMonitor(
context,
AndroidNetworkMonitor.WifiDetectionMethod.fromValue(method.value),
object : AndroidNetworkMonitor.ConfigurationListener {
override val detectionMethod: Flow<AndroidNetworkMonitor.WifiDetectionMethod>
get() =
settingsRepository.flow
.distinctUntilChangedBy { it.wifiDetectionMethod }
.map { it.wifiDetectionMethod }
override val rootShell: RootShell
get() = appShell
},
applicationScope,
)
}
@@ -204,6 +204,8 @@ fun WifiTunnelingItems(
if (
uiState.appSettings.wifiDetectionMethod ==
AndroidNetworkMonitor.WifiDetectionMethod.ROOT ||
uiState.appSettings.wifiDetectionMethod ==
AndroidNetworkMonitor.WifiDetectionMethod.SHIZUKU ||
isWifiNameReadable()
) {
viewModel.handleEvent(AppEvent.SaveTrustedSSID(ssid))
@@ -28,8 +28,6 @@ fun WifiDetectionMethodScreen(uiState: AppUiState, viewModel: AppViewModel) {
enumValues<AndroidNetworkMonitor.WifiDetectionMethod>().forEach {
val title = it.asString(context)
val description = it.asDescriptionString(context)
// TODO skip shizuku for now
if (it == AndroidNetworkMonitor.WifiDetectionMethod.SHIZUKU) return@forEach
IconSurfaceButton(
title = title,
onClick = { viewModel.handleEvent(AppEvent.SetDetectionMethod(it)) },
@@ -1,5 +1,6 @@
package com.zaneschepke.wireguardautotunnel.viewmodel
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.net.Uri
import android.os.Build
import androidx.lifecycle.ViewModel
@@ -46,6 +47,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.amnezia.awg.config.BadConfigException
import org.amnezia.awg.config.Config
import rikka.shizuku.Shizuku
import timber.log.Timber
import xyz.teamgravity.pin_lock_compose.PinManager
@@ -223,16 +225,31 @@ constructor(
) {
if (detectionMethod == appSettings.wifiDetectionMethod) return
when (detectionMethod) {
AndroidNetworkMonitor.WifiDetectionMethod.ROOT -> {
val allowed = requestRoot()
if (!allowed) return
AndroidNetworkMonitor.WifiDetectionMethod.ROOT -> if (!requestRoot()) return
AndroidNetworkMonitor.WifiDetectionMethod.SHIZUKU -> {
Shizuku.addRequestPermissionResultListener(
Shizuku.OnRequestPermissionResultListener { requestCode: Int, grantResult: Int
->
if (grantResult != PERMISSION_GRANTED)
return@OnRequestPermissionResultListener
viewModelScope.launch {
saveSettings(appSettings.copy(wifiDetectionMethod = detectionMethod))
}
}
)
try {
if (Shizuku.checkSelfPermission() != PERMISSION_GRANTED)
return Shizuku.requestPermission(123)
} catch (e: Exception) {
Timber.e(e)
return handleShowMessage(
StringValue.StringResource(R.string.shizuku_not_detected)
)
}
}
// TODO check if shizuku available
AndroidNetworkMonitor.WifiDetectionMethod.SHIZUKU -> Unit
else -> Unit
}
saveSettings(appSettings.copy(wifiDetectionMethod = detectionMethod))
handleShowMessage(StringValue.StringResource(R.string.app_restart_required))
}
private fun handleToggleSelectAllTunnels(tunnels: List<TunnelConf>) =
+1
View File
@@ -280,4 +280,5 @@
Android version
</string>
<string name="release_notes">Release notes</string>
<string name="shizuku_not_detected">Shizuku not detected</string>
</resources>
+1 -1
View File
@@ -13,5 +13,5 @@ object Constants {
const val PRERELEASE = "prerelease"
val allowedLicenses = listOf("MIT", "Apache-2.0", "BSD-3-Clause")
val allowedLicenseUrls = listOf("https://github.com/journeyapps/zxing-android-embedded/blob/master/COPYING")
val allowedLicenseUrls = listOf("https://github.com/journeyapps/zxing-android-embedded/blob/master/COPYING", "https://github.com/RikkaApps/Shizuku-API/blob/master/LICENSE")
}
+3
View File
@@ -3,6 +3,7 @@ accompanist = "0.37.3"
activityCompose = "1.10.1"
amneziawgAndroid = "1.4.0"
androidx-junit = "1.2.1"
shizuku = "13.1.5"
appcompat = "1.7.1"
biometricKtx = "1.2.0-alpha05"
coreKtx = "1.16.0"
@@ -100,6 +101,8 @@ material-icons-extended = { module = "androidx.compose.material:material-icons-e
# util
pin-lock-compose = { module = "com.zaneschepke:pin_lock_compose", version.ref = "pinLockCompose" }
shizuku-api = { module = "dev.rikka.shizuku:api", version.ref = "shizuku" }
shizuku-provider = { module = "dev.rikka.shizuku:provider", version.ref = "shizuku" }
qrose = { module = "io.github.alexzhirkevich:qrose", version.ref = "qrose" }
semver4j = { module = "com.vdurmont:semver4j", version.ref = "semver4j" }
slf4j-android = { module = "org.slf4j:slf4j-android", version.ref = "slf4jAndroid" }
+4
View File
@@ -43,5 +43,9 @@ dependencies {
implementation(libs.tunnel)
// shizuku
implementation(libs.shizuku.api)
implementation(libs.shizuku.provider)
implementation(libs.timber)
}
@@ -7,5 +7,15 @@
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"
tools:targetApi="29" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<application>
<provider
android:name="rikka.shizuku.ShizukuProvider"
android:authorities="${applicationId}.shizuku"
android:enabled="true"
android:exported="true"
android:multiprocess="false"
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
</application>
</manifest>
@@ -12,25 +12,29 @@ import android.net.NetworkRequest
import android.net.wifi.WifiManager
import android.os.Build
import com.wireguard.android.util.RootShell
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import com.zaneschepke.networkmonitor.shizuku.ShizukuShell
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import timber.log.Timber
class AndroidNetworkMonitor(context: Context, val wifiDetectionMethod: WifiDetectionMethod) :
NetworkMonitor {
class AndroidNetworkMonitor(
private val appContext: Context,
private val configurationListener: ConfigurationListener,
private val applicationScope: CoroutineScope,
) : NetworkMonitor {
interface ConfigurationListener {
val detectionMethod: Flow<WifiDetectionMethod>
val rootShell: RootShell
}
companion object {
const val LOCATION_GRANTED = "LOCATION_PERMISSIONS_GRANTED"
const val LOCATION_SERVICES_FILTER = "android.location.PROVIDERS_CHANGED"
const val ANDROID_UNKNOWN_SSID = "<unknown ssid>"
}
enum class WifiDetectionMethod(val value: Int) {
@@ -45,14 +49,12 @@ class AndroidNetworkMonitor(context: Context, val wifiDetectionMethod: WifiDetec
}
}
private val appContext = context.applicationContext
private val packageName = appContext.packageName
private val connectivityManager =
appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val wifiManager = appContext.getSystemService(Context.WIFI_SERVICE) as WifiManager?
private val locationManager =
appContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager
private val rootShell = RootShell(context)
private val wifiMutex = Mutex()
@@ -74,16 +76,26 @@ class AndroidNetworkMonitor(context: Context, val wifiDetectionMethod: WifiDetec
data class TransportState(val connected: Boolean = false)
private val wifiFlow: Flow<WifiState> = callbackFlow {
@OptIn(ExperimentalCoroutinesApi::class)
private val wifiFlow: Flow<WifiState> =
configurationListener.detectionMethod.flatMapLatest { detectionMethod
-> // cancels previous flow
createWifiNetworkCallbackFlow(detectionMethod) // Create a new flow for each new method
}
private fun createWifiNetworkCallbackFlow(
detectionMethod: WifiDetectionMethod
): Flow<WifiState> = callbackFlow {
@Suppress("DEPRECATION")
suspend fun getWifiSsid(): String? {
suspend fun getWifiSsid(): String {
return withContext(ioDispatcher) {
if (wifiManager == null) return@withContext null
if (wifiManager == null) return@withContext ANDROID_UNKNOWN_SSID
try {
wifiManager.connectionInfo?.ssid?.trim('"')?.takeIf { it.isNotEmpty() }
?: ANDROID_UNKNOWN_SSID
} catch (e: Exception) {
Timber.e(e)
null
ANDROID_UNKNOWN_SSID
}
}
}
@@ -93,7 +105,7 @@ class AndroidNetworkMonitor(context: Context, val wifiDetectionMethod: WifiDetec
val newSsid = getWifiSsid()
val securityType = wifiManager?.getCurrentSecurityType()
// Only update if new SSID is valid; preserve existing valid SSID otherwise
if (newSsid != null && newSsid != WifiManager.UNKNOWN_SSID) {
if (newSsid != WifiManager.UNKNOWN_SSID) {
currentSsid = newSsid
trySend(WifiState(wifiConnected, currentSsid, securityType))
} else if (currentSsid == null || currentSsid == WifiManager.UNKNOWN_SSID) {
@@ -113,7 +125,7 @@ class AndroidNetworkMonitor(context: Context, val wifiDetectionMethod: WifiDetec
Timber.d(
"Received update: Precise and all-the-time location permissions are enabled"
)
launch { handleUnknownWifi() }
applicationScope.launch { handleUnknownWifi() }
}
}
}
@@ -130,7 +142,8 @@ class AndroidNetworkMonitor(context: Context, val wifiDetectionMethod: WifiDetec
Timber.d(
"Location Services state changed. Enabled: $isLocationServicesEnabled, GPS: $isGpsEnabled, Network: $isNetworkEnabled"
)
if (isLocationServicesEnabled) launch { handleUnknownWifi() }
if (isLocationServicesEnabled)
applicationScope.launch { handleUnknownWifi() }
}
}
}
@@ -180,22 +193,24 @@ class AndroidNetworkMonitor(context: Context, val wifiDetectionMethod: WifiDetec
Timber.d("Wi-Fi onAvailable: network=$network")
activeWifiNetworks.add(network.toString())
currentSsid =
when (wifiDetectionMethod) {
WifiDetectionMethod.DEFAULT -> {
if (networkCapabilities == null) {
Timber.d("Capabilities not available, getting SSID via legacy API")
getWifiSsid()
} else
networkCapabilities.getWifiSsid().also {
Timber.d("Got SSID from capabilities")
try {
when (detectionMethod) {
WifiDetectionMethod.DEFAULT ->
networkCapabilities?.getWifiSsid() ?: getWifiSsid()
WifiDetectionMethod.LEGACY -> getWifiSsid()
WifiDetectionMethod.ROOT ->
configurationListener.rootShell.getCurrentWifiName()
WifiDetectionMethod.SHIZUKU ->
ShizukuShell(applicationScope)
.singleResponseCommand(WIFI_SSID_SHELL_COMMAND)
}
.trim()
.replace(Regex("[\n\r]"), "")
} catch (e: Exception) {
Timber.e(e)
ANDROID_UNKNOWN_SSID
}
WifiDetectionMethod.LEGACY -> getWifiSsid()
WifiDetectionMethod.ROOT -> rootShell.getCurrentWifiName()
// TODO implement Shizuku
else -> networkCapabilities?.getWifiSsid() ?: getWifiSsid()
}
.also { Timber.d("Current SSID via ${detectionMethod.name}: $it") }
securityType = wifiManager?.getCurrentSecurityType()
wifiConnected = true
trySend(
@@ -206,30 +221,37 @@ class AndroidNetworkMonitor(context: Context, val wifiDetectionMethod: WifiDetec
val callback =
when {
wifiDetectionMethod == WifiDetectionMethod.LEGACY ||
detectionMethod == WifiDetectionMethod.LEGACY ||
Build.VERSION.SDK_INT < Build.VERSION_CODES.S ->
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
Timber.i("Wi-Fi change detected using old API")
launch { handleOnWifiAvailable(network, null) }
applicationScope.launch { handleOnWifiAvailable(network, null) }
}
override fun onLost(network: Network) {
launch { handleOnWifiLost(network) }
applicationScope.launch { handleOnWifiLost(network) }
}
}
else ->
object : ConnectivityManager.NetworkCallback(FLAG_INCLUDE_LOCATION_INFO) {
override fun onAvailable(network: Network) {
if (detectionMethod != WifiDetectionMethod.DEFAULT)
applicationScope.launch { handleOnWifiAvailable(network, null) }
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities,
) {
launch { handleOnWifiAvailable(network, networkCapabilities) }
if (detectionMethod == WifiDetectionMethod.DEFAULT)
applicationScope.launch {
handleOnWifiAvailable(network, networkCapabilities)
}
}
override fun onLost(network: Network) {
launch { handleOnWifiLost(network) }
applicationScope.launch { handleOnWifiLost(network) }
}
}
}
@@ -244,7 +266,14 @@ class AndroidNetworkMonitor(context: Context, val wifiDetectionMethod: WifiDetec
trySend(WifiState())
awaitClose {
connectivityManager.unregisterNetworkCallback(callback)
try {
connectivityManager.unregisterNetworkCallback(callback)
} catch (e: IllegalArgumentException) {
Timber.e(
e,
"Flow failed to unregister NetworkCallback, was already unregistered or not registered correctly.",
)
}
appContext.unregisterReceiver(locationPermissionReceiver)
appContext.unregisterReceiver(locationServicesReceiver)
}
@@ -319,6 +348,7 @@ class AndroidNetworkMonitor(context: Context, val wifiDetectionMethod: WifiDetec
.also { Timber.d("NetworkStatus: $it") }
}
.distinctUntilChanged()
.shareIn(applicationScope, SharingStarted.WhileSubscribed(5000), replay = 1)
override fun sendLocationPermissionsGrantedBroadcast() {
val action = "$packageName.$LOCATION_GRANTED"
@@ -5,14 +5,15 @@ import android.net.wifi.WifiInfo
import android.net.wifi.WifiManager
import android.os.Build
import com.wireguard.android.util.RootShell
import com.zaneschepke.networkmonitor.AndroidNetworkMonitor.Companion.ANDROID_UNKNOWN_SSID
fun RootShell.getCurrentWifiName(): String? {
const val WIFI_SSID_SHELL_COMMAND =
"dumpsys wifi | grep 'Supplicant state: COMPLETED' | grep -o 'SSID: \"[^\"]*\"' | cut -d '\"' -f2"
fun RootShell.getCurrentWifiName(): String {
val response = mutableListOf<String>()
this.run(
response,
"dumpsys wifi | grep 'Supplicant state: COMPLETED' | grep -o 'SSID: [^,]*' | cut -d ' ' -f2- | tr -d '\"'",
)
return response.firstOrNull()
run(response, WIFI_SSID_SHELL_COMMAND)
return response.firstOrNull() ?: ANDROID_UNKNOWN_SSID
}
@Suppress("DEPRECATION")
@@ -24,7 +25,7 @@ fun WifiManager.getCurrentSecurityType(): WifiSecurityType? {
}
}
fun NetworkCapabilities.getWifiSsid(): String? {
fun NetworkCapabilities.getWifiSsid(): String {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val info: WifiInfo
if (transportInfo is WifiInfo) {
@@ -32,5 +33,5 @@ fun NetworkCapabilities.getWifiSsid(): String? {
return info.ssid.removeSurrounding("\"").trim()
}
}
return null
return ANDROID_UNKNOWN_SSID
}
@@ -0,0 +1,123 @@
package com.zaneschepke.networkmonitor.shizuku
import android.os.ParcelFileDescriptor
import java.io.BufferedReader
import java.io.FileInputStream
import java.io.InputStreamReader
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import moe.shizuku.server.IRemoteProcess
import moe.shizuku.server.IShizukuService
import rikka.shizuku.Shizuku
import timber.log.Timber
class ShizukuShell(private val applicationScope: CoroutineScope) {
interface CommandResultListener {
/*
* Runs after the command executes, at least partially. Does not run with 'done' if the command throws an error.
* output: The output of the command
* done: If the command has finished executing
*/
fun onCommandResult(output: String, done: Boolean) {}
/*
* Runs if the command throws an error.
* error: The error message
*/
fun onCommandError(error: String) {}
}
fun command(command: String, listener: CommandResultListener, lineBundle: Int = 50) {
applicationScope.launch {
var process: IRemoteProcess? = null
var inputStreamPfd: ParcelFileDescriptor? = null
var errorStreamPfd: ParcelFileDescriptor? = null
try {
process =
IShizukuService.Stub.asInterface(Shizuku.getBinder())
.newProcess(arrayOf("sh", "-c", command), null, null)
inputStreamPfd = process.inputStream
errorStreamPfd = process.errorStream
FileInputStream(inputStreamPfd.fileDescriptor).use { inputStream ->
FileInputStream(errorStreamPfd.fileDescriptor).use { errorStream ->
BufferedReader(InputStreamReader(inputStream)).use { reader ->
BufferedReader(InputStreamReader(errorStream)).use { err ->
val output = StringBuilder()
val errorData = StringBuilder()
var line: String?
var lineCount = 0
// Read output first
while (reader.readLine().also { line = it } != null) {
lineCount++
output.append(line).append("\n")
if (lineCount == lineBundle) {
lineCount = 0
listener.onCommandResult(
output.toString().trim().replace(Regex("[\n\r]"), ""),
false,
)
output.clear()
}
}
// Send any remaining buffered output
if (output.isNotBlank()) {
listener.onCommandResult(
output.toString().trim().replace(Regex("[\n\r]"), ""),
false,
)
}
// Read error stream
while (err.readLine().also { line = it } != null) {
errorData.append(line).append("\n")
}
if (errorData.isNotBlank()) {
listener.onCommandError(
errorData.toString().trim().replace(Regex("[\n\r]"), "")
)
} else {
listener.onCommandResult(
output.toString().trim().replace(Regex("[\n\r]"), ""),
true,
)
}
// Wait for the process to complete
process.waitFor()
}
}
}
}
} catch (e: Exception) {
Timber.e(e, "ShizukuShell command failed for command: $command")
listener.onCommandError(e.message ?: "Unknown Shizuku command error")
} finally {
inputStreamPfd?.close()
errorStreamPfd?.close()
process?.destroy()
}
}
}
suspend fun singleResponseCommand(command: String) =
suspendCancellableCoroutine { continuation ->
command(
command,
object : CommandResultListener {
override fun onCommandResult(output: String, done: Boolean) {
if (done) continuation.resumeWith(Result.success(output))
}
override fun onCommandError(error: String) {
continuation.resumeWithException(Exception(error))
}
},
)
}
}