From 2c1c312a1e3adc75b343735b21a66447c7d6cc55 Mon Sep 17 00:00:00 2001 From: Kirtan Jain Date: Sun, 25 Oct 2026 21:46:10 +0530 Subject: [PATCH] fix: ask Shizuku for permission when Retry is tapped Nothing in the app called Shizuku.requestPermission(), so a fresh install could never get Shizuku access: Retry only re-ran the compatibility check, which flashed the loading state and went back to "Shizuku Access Denied". Retry now shows Shizuku's permission dialog when the permission is missing, waits for the answer, then checks again. Root keeps its existing check. Repeated taps while a request is open are ignored, and a Shizuku binder error while checking is treated as "not granted" instead of crashing. Fixes #36 --- .../NetworkControlRepositoryImpl.kt | 4 ++ .../data/source/NetworkControlDataSource.kt | 1 + .../source/RootNetworkControlDataSource.kt | 2 + .../source/ShizukuNetworkControlDataSource.kt | 39 +++++++++++++++++++ .../domain/repository/Repositories.kt | 5 +++ .../domain/usecase/NetworkUseCases.kt | 8 ++++ .../presentation/viewmodel/MainViewModel.kt | 16 +++++++- .../viewmodel/SettingsViewModel.kt | 13 ++++++- .../viewmodel/SettingsViewModelTest.kt | 37 ++++++++++++++++++ 9 files changed, 122 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/supernova/networkswitch/data/repository/NetworkControlRepositoryImpl.kt b/app/src/main/java/com/supernova/networkswitch/data/repository/NetworkControlRepositoryImpl.kt index 3881e56..20acefb 100644 --- a/app/src/main/java/com/supernova/networkswitch/data/repository/NetworkControlRepositoryImpl.kt +++ b/app/src/main/java/com/supernova/networkswitch/data/repository/NetworkControlRepositoryImpl.kt @@ -27,6 +27,10 @@ class NetworkControlRepositoryImpl @Inject constructor( return dataSource.checkCompatibility(subId) } + override suspend fun requestPermission(method: ControlMethod): Boolean { + return getDataSource(method).requestPermission() + } + override suspend fun getCurrentNetworkMode(subId: Int): NetworkMode? { return try { val method = preferencesRepository.getControlMethod() diff --git a/app/src/main/java/com/supernova/networkswitch/data/source/NetworkControlDataSource.kt b/app/src/main/java/com/supernova/networkswitch/data/source/NetworkControlDataSource.kt index d6fa53c..9ce4747 100644 --- a/app/src/main/java/com/supernova/networkswitch/data/source/NetworkControlDataSource.kt +++ b/app/src/main/java/com/supernova/networkswitch/data/source/NetworkControlDataSource.kt @@ -5,6 +5,7 @@ import com.supernova.networkswitch.domain.model.NetworkMode interface NetworkControlDataSource { suspend fun checkCompatibility(subId: Int): CompatibilityState + suspend fun requestPermission(): Boolean suspend fun getCurrentNetworkMode(subId: Int): NetworkMode? suspend fun setNetworkMode(subId: Int, mode: NetworkMode) fun isConnected(): Boolean diff --git a/app/src/main/java/com/supernova/networkswitch/data/source/RootNetworkControlDataSource.kt b/app/src/main/java/com/supernova/networkswitch/data/source/RootNetworkControlDataSource.kt index 26b1748..46db920 100644 --- a/app/src/main/java/com/supernova/networkswitch/data/source/RootNetworkControlDataSource.kt +++ b/app/src/main/java/com/supernova/networkswitch/data/source/RootNetworkControlDataSource.kt @@ -34,6 +34,8 @@ class RootNetworkControlDataSource @Inject constructor( } } + override suspend fun requestPermission(): Boolean = Utils.isRootGranted() + override suspend fun getCurrentNetworkMode(subId: Int): NetworkMode? { return try { val controller = getNetworkController() diff --git a/app/src/main/java/com/supernova/networkswitch/data/source/ShizukuNetworkControlDataSource.kt b/app/src/main/java/com/supernova/networkswitch/data/source/ShizukuNetworkControlDataSource.kt index 3cf8e39..5f4e835 100644 --- a/app/src/main/java/com/supernova/networkswitch/data/source/ShizukuNetworkControlDataSource.kt +++ b/app/src/main/java/com/supernova/networkswitch/data/source/ShizukuNetworkControlDataSource.kt @@ -57,6 +57,45 @@ class ShizukuNetworkControlDataSource @Inject constructor( } } + /** + * Show Shizuku's permission dialog and wait for the answer + */ + override suspend fun requestPermission(): Boolean { + try { + if (!Shizuku.pingBinder()) return false + if (Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) return true + // The user chose "Deny" before: Shizuku won't show the dialog again, + // the app has to be allowed from Shizuku's own app list + if (Shizuku.shouldShowRequestPermissionRationale()) return false + } catch (e: Exception) { + return false + } + + return suspendCancellableCoroutine { continuation -> + val listener = object : Shizuku.OnRequestPermissionResultListener { + override fun onRequestPermissionResult(requestCode: Int, grantResult: Int) { + if (requestCode != SHIZUKU_PERMISSION_REQUEST_ID) return + Shizuku.removeRequestPermissionResultListener(this) + if (continuation.isActive) { + continuation.resume(grantResult == PackageManager.PERMISSION_GRANTED) + } + } + } + continuation.invokeOnCancellation { + Shizuku.removeRequestPermissionResultListener(listener) + } + Shizuku.addRequestPermissionResultListener(listener) + try { + Shizuku.requestPermission(SHIZUKU_PERMISSION_REQUEST_ID) + } catch (e: Exception) { + Shizuku.removeRequestPermissionResultListener(listener) + if (continuation.isActive) { + continuation.resume(false) + } + } + } + } + override suspend fun getCurrentNetworkMode(subId: Int): NetworkMode? { return if (ensureServiceBinding()) { try { diff --git a/app/src/main/java/com/supernova/networkswitch/domain/repository/Repositories.kt b/app/src/main/java/com/supernova/networkswitch/domain/repository/Repositories.kt index 047b601..94c2ca2 100644 --- a/app/src/main/java/com/supernova/networkswitch/domain/repository/Repositories.kt +++ b/app/src/main/java/com/supernova/networkswitch/domain/repository/Repositories.kt @@ -14,6 +14,11 @@ interface NetworkControlRepository { * Check if network control is compatible with current device/method */ suspend fun checkCompatibility(method: ControlMethod): CompatibilityState + + /** + * Ask for the permission the control method needs (root or Shizuku) + */ + suspend fun requestPermission(method: ControlMethod): Boolean /** * Get current network mode diff --git a/app/src/main/java/com/supernova/networkswitch/domain/usecase/NetworkUseCases.kt b/app/src/main/java/com/supernova/networkswitch/domain/usecase/NetworkUseCases.kt index 2b37404..f2a0087 100644 --- a/app/src/main/java/com/supernova/networkswitch/domain/usecase/NetworkUseCases.kt +++ b/app/src/main/java/com/supernova/networkswitch/domain/usecase/NetworkUseCases.kt @@ -18,6 +18,14 @@ class CheckCompatibilityUseCase @Inject constructor( } } +class RequestPermissionUseCase @Inject constructor( + private val networkControlRepository: NetworkControlRepository +) { + suspend operator fun invoke(method: ControlMethod): Boolean { + return networkControlRepository.requestPermission(method) + } +} + class ToggleNetworkModeUseCase @Inject constructor( private val networkControlRepository: NetworkControlRepository, private val preferencesRepository: PreferencesRepository diff --git a/app/src/main/java/com/supernova/networkswitch/presentation/viewmodel/MainViewModel.kt b/app/src/main/java/com/supernova/networkswitch/presentation/viewmodel/MainViewModel.kt index f59b820..967ad34 100644 --- a/app/src/main/java/com/supernova/networkswitch/presentation/viewmodel/MainViewModel.kt +++ b/app/src/main/java/com/supernova/networkswitch/presentation/viewmodel/MainViewModel.kt @@ -12,10 +12,12 @@ import com.supernova.networkswitch.domain.model.NetworkMode import com.supernova.networkswitch.domain.model.ToggleModeConfig import com.supernova.networkswitch.domain.usecase.CheckCompatibilityUseCase import com.supernova.networkswitch.domain.usecase.GetCurrentNetworkModeUseCase +import com.supernova.networkswitch.domain.usecase.RequestPermissionUseCase import com.supernova.networkswitch.domain.usecase.ToggleNetworkModeUseCase import com.supernova.networkswitch.domain.usecase.UpdateControlMethodUseCase import com.supernova.networkswitch.domain.usecase.GetToggleModeConfigUseCase import com.supernova.networkswitch.domain.repository.PreferencesRepository +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.flow.collectLatest import javax.inject.Inject @@ -28,6 +30,7 @@ class MainViewModel @Inject constructor( private val toggleNetworkModeUseCase: ToggleNetworkModeUseCase, private val updateControlMethodUseCase: UpdateControlMethodUseCase, private val getToggleModeConfigUseCase: GetToggleModeConfigUseCase, + private val requestPermissionUseCase: RequestPermissionUseCase, private val preferencesRepository: PreferencesRepository ) : ViewModel() { @@ -50,6 +53,8 @@ class MainViewModel @Inject constructor( var compatibilityState by mutableStateOf(CompatibilityState.Pending) private set + private var retryJob: Job? = null + init { observeControlMethodPreference() loadToggleModeConfig() @@ -102,10 +107,17 @@ class MainViewModel @Inject constructor( } /** - * Retry compatibility check + * Retry compatibility check, asking for the missing permission first */ fun retryCompatibilityCheck() { - checkCompatibility() + if (retryJob?.isActive == true) return + retryJob = viewModelScope.launch { + val state = compatibilityState + if (state is CompatibilityState.PermissionDenied && state.method == ControlMethod.SHIZUKU) { + requestPermissionUseCase(state.method) + } + checkCompatibility() + } } /** diff --git a/app/src/main/java/com/supernova/networkswitch/presentation/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/supernova/networkswitch/presentation/viewmodel/SettingsViewModel.kt index 147885b..abb1360 100644 --- a/app/src/main/java/com/supernova/networkswitch/presentation/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/supernova/networkswitch/presentation/viewmodel/SettingsViewModel.kt @@ -12,6 +12,7 @@ import com.supernova.networkswitch.domain.repository.PreferencesRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.async import javax.inject.Inject @@ -38,6 +39,8 @@ class SettingsViewModel @Inject constructor( var shizukuCompatibility by mutableStateOf(CompatibilityState.Pending) private set + + private var retryJob: Job? = null init { checkAllCompatibility() @@ -50,7 +53,15 @@ class SettingsViewModel @Inject constructor( } fun retryCompatibilityCheck() { - checkAllCompatibility() + if (retryJob?.isActive == true) return + retryJob = viewModelScope.launch { + if (shizukuCompatibility is CompatibilityState.PermissionDenied && + preferencesRepository.getControlMethod() == ControlMethod.SHIZUKU + ) { + networkControlRepository.requestPermission(ControlMethod.SHIZUKU) + } + checkAllCompatibility() + } } private fun checkAllCompatibility() { diff --git a/app/src/test/java/com/supernova/networkswitch/presentation/viewmodel/SettingsViewModelTest.kt b/app/src/test/java/com/supernova/networkswitch/presentation/viewmodel/SettingsViewModelTest.kt index b94b532..e43bb5d 100644 --- a/app/src/test/java/com/supernova/networkswitch/presentation/viewmodel/SettingsViewModelTest.kt +++ b/app/src/test/java/com/supernova/networkswitch/presentation/viewmodel/SettingsViewModelTest.kt @@ -77,4 +77,41 @@ class SettingsViewModelTest { coVerify(exactly = 2) { networkControlRepository.checkCompatibility(ControlMethod.ROOT) } coVerify(exactly = 2) { networkControlRepository.checkCompatibility(ControlMethod.SHIZUKU) } } + + @Test + fun `retryCompatibilityCheck requests Shizuku permission when it is denied`() = runTest { + coEvery { preferencesRepository.getControlMethod() } returns ControlMethod.SHIZUKU + coEvery { networkControlRepository.checkCompatibility(ControlMethod.SHIZUKU) } returns + CompatibilityState.PermissionDenied(ControlMethod.SHIZUKU) + createViewModel() + + viewModel.retryCompatibilityCheck() + + coVerify(exactly = 1) { networkControlRepository.requestPermission(ControlMethod.SHIZUKU) } + coVerify(exactly = 2) { networkControlRepository.checkCompatibility(ControlMethod.SHIZUKU) } + } + + @Test + fun `retryCompatibilityCheck does not request permission when root is selected`() = runTest { + coEvery { preferencesRepository.getControlMethod() } returns ControlMethod.ROOT + coEvery { networkControlRepository.checkCompatibility(ControlMethod.SHIZUKU) } returns + CompatibilityState.PermissionDenied(ControlMethod.SHIZUKU) + createViewModel() + + viewModel.retryCompatibilityCheck() + + coVerify(exactly = 0) { networkControlRepository.requestPermission(any()) } + } + + @Test + fun `retryCompatibilityCheck does not request permission when Shizuku is compatible`() = runTest { + coEvery { preferencesRepository.getControlMethod() } returns ControlMethod.SHIZUKU + coEvery { networkControlRepository.checkCompatibility(ControlMethod.SHIZUKU) } returns + CompatibilityState.Compatible + createViewModel() + + viewModel.retryCompatibilityCheck() + + coVerify(exactly = 0) { networkControlRepository.requestPermission(any()) } + } }