diff --git a/app/src/main/java/app/simple/inure/adapters/viewers/AdapterPermissions.kt b/app/src/main/java/app/simple/inure/adapters/viewers/AdapterPermissions.kt index b5d8092da..4dd6800b2 100644 --- a/app/src/main/java/app/simple/inure/adapters/viewers/AdapterPermissions.kt +++ b/app/src/main/java/app/simple/inure/adapters/viewers/AdapterPermissions.kt @@ -151,6 +151,17 @@ class AdapterPermissions(private val permissions: MutableList, 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, @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, 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) diff --git a/app/src/main/java/app/simple/inure/ui/viewers/Permissions.kt b/app/src/main/java/app/simple/inure/ui/viewers/Permissions.kt index 47ab7ad22..f1c7bacff 100644 --- a/app/src/main/java/app/simple/inure/ui/viewers/Permissions.kt +++ b/app/src/main/java/app/simple/inure/ui/viewers/Permissions.kt @@ -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) { diff --git a/app/src/main/java/app/simple/inure/viewmodels/viewers/PermissionsViewModel.kt b/app/src/main/java/app/simple/inure/viewmodels/viewers/PermissionsViewModel.kt index 1c58ad3fb..2f48b8b85 100644 --- a/app/src/main/java/app/simple/inure/viewmodels/viewers/PermissionsViewModel.kt +++ b/app/src/main/java/app/simple/inure/viewmodels/viewers/PermissionsViewModel.kt @@ -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> by lazy { - MutableLiveData>().also { - if (SearchPreferences.isSearchKeywordModeEnabled()) { - Log.d("PermissionsViewModel", "Loading permission data with keyword: ${SearchPreferences.getLastSearchKeyword()}") - loadPermissionData(SearchPreferences.getLastSearchKeyword()) - } else { - loadPermissionData("") - } + private val _permissions = MutableStateFlow>(mutableListOf()) + val permissions: StateFlow> = _permissions.asStateFlow() + + // Track the last requested permission change + private val _lastPermissionChangeRequest = MutableStateFlow(null) + val lastPermissionChangeRequest: StateFlow = _lastPermissionChangeRequest.asStateFlow() + + // Track permission change results + private val _permissionChangeResult = MutableStateFlow(null) + val permissionChangeResult: StateFlow = _permissionChangeResult.asStateFlow() + + // Single permission update event + private val _singlePermissionUpdate = MutableSharedFlow() + val singlePermissionUpdate: SharedFlow = _singlePermissionUpdate.asSharedFlow() + + init { + if (SearchPreferences.isSearchKeywordModeEnabled()) { + Log.d("PermissionsViewModel", "Loading permission data with keyword: ${SearchPreferences.getLastSearchKeyword()}") + loadPermissionData(SearchPreferences.getLastSearchKeyword()) + } else { + loadPermissionData("") } } - fun getPermissions(): LiveData> { - 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()) }