improve: validate invalid permission state #467

This commit is contained in:
Hamza Rizwan
2026-02-04 11:22:13 +05:30
parent 6cd0bc2adf
commit 038fcde507
3 changed files with 224 additions and 75 deletions

View File

@@ -151,6 +151,17 @@ class AdapterPermissions(private val permissions: MutableList<PermissionInfo>, p
notifyItemChanged(position)
}
/**
* Update the adapter's data with new permissions list
* This is used when the entire list needs to be refreshed (e.g., after search)
*/
@Suppress("NotifyDataSetChanged")
fun updateData(newPermissions: MutableList<PermissionInfo>, @Suppress("UNUSED_PARAMETER") newKeyword: String) {
permissions.clear()
permissions.addAll(newPermissions)
notifyDataSetChanged()
}
fun update() {
permissionLabelMode = PermissionPreferences.getLabelType()
for (i in permissions.indices) notifyItemChanged(i)
@@ -160,7 +171,7 @@ class AdapterPermissions(private val permissions: MutableList<PermissionInfo>, p
this.permissionCallbacks = permissionCallbacks
}
inner class Holder(itemView: View) : VerticalListViewHolder(itemView) {
class Holder(itemView: View) : VerticalListViewHolder(itemView) {
val name: TypeFaceTextView = itemView.findViewById(R.id.adapter_permissions_name)
val status: TypeFaceTextView = itemView.findViewById(R.id.adapter_permissions_status)
val desc: TypeFaceTextView = itemView.findViewById(R.id.adapter_permissions_desc)

View File

@@ -28,6 +28,7 @@ import app.simple.inure.viewmodels.viewers.PermissionsViewModel
import com.anggrayudi.storage.extension.postToUi
import com.topjohnwu.superuser.Shell
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import rikka.shizuku.Shizuku
@@ -64,77 +65,119 @@ class Permissions : SearchBarScopedFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
permissionsViewModel.getPermissions().observe(viewLifecycleOwner) { permissionInfos ->
adapterPermissions = AdapterPermissions(permissionInfos, searchBox.text.toString().trim(), isPackageInstalled)
setCount(permissionInfos.size)
adapterPermissions.setOnPermissionCallbacksListener(object : AdapterPermissions.Companion.PermissionCallbacks {
override fun onPermissionClicked(container: View, permissionInfo: PermissionInfo, position: Int) {
childFragmentManager.showPermissionStatus(packageInfo, permissionInfo)
.setOnPermissionStatusCallbackListener(object : PermissionStatus.Companion.PermissionStatusCallbacks {
override fun onSuccess(grantedStatus: Boolean) {
adapterPermissions.permissionStatusChanged(position, if (grantedStatus) 1 else 0)
}
})
viewLifecycleOwner.lifecycleScope.launch {
permissionsViewModel.permissions.collectLatest { permissionInfos ->
if (permissionInfos.isEmpty() && !::adapterPermissions.isInitialized) {
return@collectLatest
}
override fun onPermissionSwitchClicked(checked: Boolean, permissionInfo: PermissionInfo, position: Int) {
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
val mode = if (checked) "grant" else "revoke"
if (!::adapterPermissions.isInitialized) {
adapterPermissions = AdapterPermissions(permissionInfos, searchBox.text.toString().trim(), isPackageInstalled)
if (ConfigurationPreferences.isUsingRoot()) {
kotlin.runCatching {
Shell.cmd("pm $mode ${packageInfo.packageName} ${permissionInfo.name}").exec().let {
if (it.isSuccess) {
withContext(Dispatchers.Main) {
adapterPermissions.permissionStatusChanged(position, if (permissionInfo.isGranted == 1) 0 else 1)
adapterPermissions.setOnPermissionCallbacksListener(object : AdapterPermissions.Companion.PermissionCallbacks {
override fun onPermissionClicked(container: View, permissionInfo: PermissionInfo, position: Int) {
childFragmentManager.showPermissionStatus(packageInfo, permissionInfo)
.setOnPermissionStatusCallbackListener(object : PermissionStatus.Companion.PermissionStatusCallbacks {
override fun onSuccess(grantedStatus: Boolean) {
// Record the expected change
val expectedStatus = if (grantedStatus) 1 else 0
permissionsViewModel.recordPermissionChangeRequest(permissionInfo.name, position, expectedStatus)
// Optimistically update UI
adapterPermissions.permissionStatusChanged(position, expectedStatus)
// Schedule a delayed refresh to verify the change
viewLifecycleOwner.lifecycleScope.launch {
permissionsViewModel.refreshPermissionStatus(permissionInfo.name, position)
}
} else {
}
})
}
override fun onPermissionSwitchClicked(checked: Boolean, permissionInfo: PermissionInfo, position: Int) {
val expectedStatus = if (checked) 1 else 0
// Record the expected change
permissionsViewModel.recordPermissionChangeRequest(permissionInfo.name, position, expectedStatus)
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
val mode = if (checked) "grant" else "revoke"
if (ConfigurationPreferences.isUsingRoot()) {
kotlin.runCatching {
Shell.cmd("pm $mode ${packageInfo.packageName} ${permissionInfo.name}").exec().let {
// Refresh to get actual status
permissionsViewModel.refreshPermissionStatus(permissionInfo.name, position)
}
}.getOrElse {
withContext(Dispatchers.Main) {
showWarning("ERR: failed to $mode permission", goBack = false)
showWarning("failed to acquire root", goBack = false)
adapterPermissions.permissionStatusChanged(position, permissionInfo.isGranted)
}
}
} else if (ConfigurationPreferences.isUsingShizuku()) {
kotlin.runCatching {
if (Shizuku.pingBinder()) {
ShizukuServiceHelper.getInstance().getBoundService { shizukuService ->
shizukuService.simpleExecute("pm $mode ${packageInfo.packageName} ${permissionInfo.name}").let {
// Wait a bit for the system to process
Thread.sleep(500)
// Refresh to get actual status
permissionsViewModel.refreshPermissionStatus(permissionInfo.name, position)
}
}
} else {
postToUi {
showWarning("failed to acquire Shizuku", goBack = false)
adapterPermissions.permissionStatusChanged(position, permissionInfo.isGranted)
}
}
}.getOrElse {
postToUi {
showWarning("failed to acquire Shizuku", goBack = false)
adapterPermissions.permissionStatusChanged(position, permissionInfo.isGranted)
}
}
}
}.getOrElse {
withContext(Dispatchers.Main) {
showWarning("ERR: failed to acquire root", goBack = false)
adapterPermissions.permissionStatusChanged(position, permissionInfo.isGranted)
}
}
} else if (ConfigurationPreferences.isUsingShizuku()) {
kotlin.runCatching {
if (Shizuku.pingBinder()) {
ShizukuServiceHelper.getInstance().getBoundService { shizukuService ->
shizukuService.simpleExecute("pm $mode ${packageInfo.packageName} ${permissionInfo.name}").let {
postToUi {
if (it.isSuccess) {
adapterPermissions.permissionStatusChanged(position, if (permissionInfo.isGranted == 1) 0 else 1)
} else {
showWarning("ERR: failed to $mode permission", goBack = false)
adapterPermissions.permissionStatusChanged(position, permissionInfo.isGranted)
}
}
}
}
} else {
postToUi {
showWarning("ERR: failed to acquire Shizuku", goBack = false)
adapterPermissions.permissionStatusChanged(position, permissionInfo.isGranted)
}
}
}.getOrElse {
postToUi {
showWarning("ERR: failed to acquire Shizuku", goBack = false)
adapterPermissions.permissionStatusChanged(position, permissionInfo.isGranted)
}
}
}
}
}
})
})
recyclerView.setExclusiveAdapter(adapterPermissions)
recyclerView.setExclusiveAdapter(adapterPermissions)
} else {
// Update existing adapter with new data
adapterPermissions.updateData(permissionInfos, searchBox.text.toString().trim())
}
setCount(permissionInfos.size)
}
}
// Collect single permission updates
viewLifecycleOwner.lifecycleScope.launch {
permissionsViewModel.singlePermissionUpdate.collect { update ->
if (::adapterPermissions.isInitialized) {
adapterPermissions.permissionStatusChanged(update.position, update.newStatus)
}
}
}
// Collect permission change results
viewLifecycleOwner.lifecycleScope.launch {
permissionsViewModel.permissionChangeResult.collectLatest { result ->
result?.let {
if (!it.success) {
val expectedStatusText = if (permissionsViewModel.lastPermissionChangeRequest.value?.expectedStatus == 1) "granted" else "revoked"
val actualStatusText = if (it.actualStatus == 1) "granted" else "revoked"
// Permission change failed - show warning
showWarning("Failed to change permission state. Expected: $expectedStatusText, Actual: " +
"$actualStatusText. The system maybe disallowing permission change for this app.", goBack = false)
}
// Clear the result after handling
permissionsViewModel.clearPermissionChangeResult()
}
}
}
permissionsViewModel.getError().observe(viewLifecycleOwner) {

View File

@@ -4,8 +4,6 @@ import android.app.Application
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import app.simple.inure.R
import app.simple.inure.apk.utils.PackageUtils.isPackageInstalled
@@ -16,25 +14,59 @@ import app.simple.inure.models.PermissionInfo
import app.simple.inure.preferences.SearchPreferences
import app.simple.inure.util.StringUtils.capitalizeFirstLetter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.util.Locale
class PermissionsViewModel(application: Application, val packageInfo: PackageInfo) : WrappedViewModel(application) {
private val permissions: MutableLiveData<MutableList<PermissionInfo>> by lazy {
MutableLiveData<MutableList<PermissionInfo>>().also {
if (SearchPreferences.isSearchKeywordModeEnabled()) {
Log.d("PermissionsViewModel", "Loading permission data with keyword: ${SearchPreferences.getLastSearchKeyword()}")
loadPermissionData(SearchPreferences.getLastSearchKeyword())
} else {
loadPermissionData("")
}
private val _permissions = MutableStateFlow<MutableList<PermissionInfo>>(mutableListOf())
val permissions: StateFlow<MutableList<PermissionInfo>> = _permissions.asStateFlow()
// Track the last requested permission change
private val _lastPermissionChangeRequest = MutableStateFlow<PermissionChangeRequest?>(null)
val lastPermissionChangeRequest: StateFlow<PermissionChangeRequest?> = _lastPermissionChangeRequest.asStateFlow()
// Track permission change results
private val _permissionChangeResult = MutableStateFlow<PermissionChangeResult?>(null)
val permissionChangeResult: StateFlow<PermissionChangeResult?> = _permissionChangeResult.asStateFlow()
// Single permission update event
private val _singlePermissionUpdate = MutableSharedFlow<PermissionUpdate>()
val singlePermissionUpdate: SharedFlow<PermissionUpdate> = _singlePermissionUpdate.asSharedFlow()
init {
if (SearchPreferences.isSearchKeywordModeEnabled()) {
Log.d("PermissionsViewModel", "Loading permission data with keyword: ${SearchPreferences.getLastSearchKeyword()}")
loadPermissionData(SearchPreferences.getLastSearchKeyword())
} else {
loadPermissionData("")
}
}
fun getPermissions(): LiveData<MutableList<PermissionInfo>> {
return permissions
}
data class PermissionChangeRequest(
val permissionName: String,
val position: Int,
val expectedStatus: Int, // 0 = revoked, 1 = granted
val timestamp: Long = System.currentTimeMillis()
)
data class PermissionChangeResult(
val permissionName: String,
val position: Int,
val success: Boolean,
val actualStatus: Int
)
data class PermissionUpdate(
val position: Int,
val newStatus: Int
)
fun loadPermissionData(keyword: String) {
viewModelScope.launch(Dispatchers.Default) {
@@ -115,11 +147,11 @@ class PermissionsViewModel(application: Application, val packageInfo: PackageInf
}
*/
this@PermissionsViewModel.permissions.postValue(permissions.apply {
_permissions.value = permissions.apply {
sortBy {
it.name.lowercase(Locale.getDefault())
}
})
}
}.getOrElse {
if (it is java.lang.NullPointerException) {
postWarning(getString(R.string.this_app_doesnt_require_any_permissions))
@@ -130,6 +162,69 @@ class PermissionsViewModel(application: Application, val packageInfo: PackageInf
}
}
/**
* Record a permission change request before attempting to change it
*/
fun recordPermissionChangeRequest(permissionName: String, position: Int, expectedStatus: Int) {
_lastPermissionChangeRequest.value = PermissionChangeRequest(
permissionName = permissionName,
position = position,
expectedStatus = expectedStatus
)
}
/**
* Refresh a single permission's status and verify if the change was successful
*/
fun refreshPermissionStatus(permissionName: String, position: Int) {
viewModelScope.launch(Dispatchers.Default) {
kotlin.runCatching {
if (packageManager.isPackageInstalled(packageInfo.packageName)) {
val appPackageInfo = packageManager.getPackageInfo(packageInfo.packageName, PackageManager.GET_PERMISSIONS)!!
val permissionIndex = appPackageInfo.requestedPermissions?.indexOf(permissionName) ?: -1
if (permissionIndex != -1) {
val actualStatus = if (appPackageInfo.requestedPermissionsFlags!![permissionIndex] and PackageInfo.REQUESTED_PERMISSION_GRANTED != 0) {
1 // Granted
} else {
0 // Revoked
}
// Update the permission in the internal list
val currentPermissions = _permissions.value
if (position >= 0 && position < currentPermissions.size) {
currentPermissions[position].isGranted = actualStatus
// Emit single permission update event instead of recreating entire list
_singlePermissionUpdate.emit(PermissionUpdate(position, actualStatus))
}
// Check if the change was successful
val lastRequest = _lastPermissionChangeRequest.value
if (lastRequest != null && lastRequest.permissionName == permissionName) {
val success = actualStatus == lastRequest.expectedStatus
_permissionChangeResult.value = PermissionChangeResult(
permissionName = permissionName,
position = position,
success = success,
actualStatus = actualStatus
)
}
}
}
}.getOrElse {
Log.e("PermissionsViewModel", "Failed to refresh permission status", it)
}
}
}
/**
* Clear the last permission change result
*/
fun clearPermissionChangeResult() {
_permissionChangeResult.value = null
}
private fun isKeywordMatched(keyword: String, name: String, loadLabel: String): Boolean {
return name.lowercase().contains(keyword.lowercase()) || loadLabel.lowercase().contains(keyword.lowercase())
}