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()) } + } }