diff --git a/src/com/android/settings/spa/app/appsettings/AppPermissionPreference.kt b/src/com/android/settings/spa/app/appsettings/AppPermissionPreference.kt new file mode 100644 index 00000000000..ec37f1126ae --- /dev/null +++ b/src/com/android/settings/spa/app/appsettings/AppPermissionPreference.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.spa.app.appsettings + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.android.settings.R +import com.android.settingslib.spa.widget.preference.Preference +import com.android.settingslib.spa.widget.preference.PreferenceModel +import com.android.settingslib.spaprivileged.model.app.userHandle + +private const val TAG = "AppPermissionPreference" +private const val EXTRA_HIDE_INFO_BUTTON = "hideInfoButton" + +@Composable +fun AppPermissionPreference(app: ApplicationInfo) { + val context = LocalContext.current + val summaryLiveData = remember { AppPermissionSummaryLiveData(context, app) } + val summaryState = summaryLiveData.observeAsState(initial = AppPermissionSummaryState( + summary = stringResource(R.string.summary_placeholder), + enabled = false, + )) + Preference( + model = remember { + object : PreferenceModel { + override val title = context.getString(R.string.permissions_label) + override val summary = derivedStateOf { summaryState.value.summary } + override val enabled = derivedStateOf { summaryState.value.enabled } + override val onClick = { startManagePermissionsActivity(context, app) } + } + }, + singleLineSummary = true, + ) +} + +/** Starts new activity to manage app permissions */ +private fun startManagePermissionsActivity(context: Context, app: ApplicationInfo) { + val intent = Intent(Intent.ACTION_MANAGE_APP_PERMISSIONS).apply { + putExtra(Intent.EXTRA_PACKAGE_NAME, app.packageName) + putExtra(EXTRA_HIDE_INFO_BUTTON, true) + } + try { + context.startActivityAsUser(intent, app.userHandle) + } catch (e: ActivityNotFoundException) { + Log.w(TAG, "No app can handle android.intent.action.MANAGE_APP_PERMISSIONS") + } +} diff --git a/src/com/android/settings/spa/app/appsettings/AppPermissionSummary.kt b/src/com/android/settings/spa/app/appsettings/AppPermissionSummary.kt new file mode 100644 index 00000000000..31e0e0e7ad1 --- /dev/null +++ b/src/com/android/settings/spa/app/appsettings/AppPermissionSummary.kt @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.spa.app.appsettings + +import android.content.Context +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager.OnPermissionsChangedListener +import android.icu.text.ListFormatter +import androidx.lifecycle.LiveData +import com.android.settings.R +import com.android.settingslib.applications.PermissionsSummaryHelper +import com.android.settingslib.applications.PermissionsSummaryHelper.PermissionsResultCallback +import com.android.settingslib.spaprivileged.model.app.userHandle + +data class AppPermissionSummaryState( + val summary: String, + val enabled: Boolean, +) + +class AppPermissionSummaryLiveData( + private val context: Context, + private val app: ApplicationInfo, +) : LiveData() { + private val contextAsUser = context.createContextAsUser(app.userHandle, 0) + private val packageManager = contextAsUser.packageManager + + private val onPermissionsChangedListener = OnPermissionsChangedListener { uid -> + if (uid == app.uid) update() + } + + override fun onActive() { + packageManager.addOnPermissionsChangeListener(onPermissionsChangedListener) + update() + } + + override fun onInactive() { + packageManager.removeOnPermissionsChangeListener(onPermissionsChangedListener) + } + + private fun update() { + PermissionsSummaryHelper.getPermissionSummary( + contextAsUser, app.packageName, permissionsCallback + ) + } + + private val permissionsCallback = object : PermissionsResultCallback { + override fun onPermissionSummaryResult( + requestedPermissionCount: Int, + additionalGrantedPermissionCount: Int, + grantedGroupLabels: List, + ) { + if (requestedPermissionCount == 0) { + postValue(noPermissionRequestedState()) + return + } + val labels = getDisplayLabels(additionalGrantedPermissionCount, grantedGroupLabels) + val summary = when { + labels.isEmpty() -> { + context.getString(R.string.runtime_permissions_summary_no_permissions_granted) + } + + else -> ListFormatter.getInstance().format(labels) + } + postValue(AppPermissionSummaryState(summary = summary, enabled = true)) + } + } + + private fun noPermissionRequestedState() = AppPermissionSummaryState( + summary = context.getString(R.string.runtime_permissions_summary_no_permissions_requested), + enabled = false, + ) + + private fun getDisplayLabels( + additionalGrantedPermissionCount: Int, + grantedGroupLabels: List, + ): List = when (additionalGrantedPermissionCount) { + 0 -> grantedGroupLabels + else -> { + grantedGroupLabels + + // N additional permissions. + context.resources.getQuantityString( + R.plurals.runtime_permissions_additional_count, + additionalGrantedPermissionCount, + additionalGrantedPermissionCount, + ) + } + } +} diff --git a/src/com/android/settings/spa/app/appsettings/AppSettings.kt b/src/com/android/settings/spa/app/appsettings/AppSettings.kt index 615fa75d860..c8c585449e0 100644 --- a/src/com/android/settings/spa/app/appsettings/AppSettings.kt +++ b/src/com/android/settings/spa/app/appsettings/AppSettings.kt @@ -90,6 +90,8 @@ private fun AppSettings(packageInfoPresenter: PackageInfoPresenter) { AppButtons(packageInfoPresenter) + AppPermissionPreference(app) + Category(title = stringResource(R.string.advanced_apps)) { DisplayOverOtherAppsAppListProvider.InfoPageEntryItem(app) ModifySystemSettingsAppListProvider.InfoPageEntryItem(app) diff --git a/tests/robotests/Android.bp b/tests/robotests/Android.bp index 5891ca35348..711910091e3 100644 --- a/tests/robotests/Android.bp +++ b/tests/robotests/Android.bp @@ -73,6 +73,7 @@ android_robolectric_test { name: "SettingsRoboTests", srcs: [ "src/**/*.java", + "src/**/*.kt", ], static_libs: [ diff --git a/tests/robotests/src/com/android/settings/spa/app/appsettings/AppPermissionSummaryJavaTest.java b/tests/robotests/src/com/android/settings/spa/app/appsettings/AppPermissionSummaryJavaTest.java new file mode 100644 index 00000000000..d0ee6adb97c --- /dev/null +++ b/tests/robotests/src/com/android/settings/spa/app/appsettings/AppPermissionSummaryJavaTest.java @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.settings.spa.app.appsettings; + +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +// TODO: Remove this class when Kotlin is supported by the Robolectric test. +@RunWith(RobolectricTestRunner.class) +public class AppPermissionSummaryJavaTest extends AppPermissionSummaryTest { +} diff --git a/tests/robotests/src/com/android/settings/spa/app/appsettings/AppPermissionSummaryTest.kt b/tests/robotests/src/com/android/settings/spa/app/appsettings/AppPermissionSummaryTest.kt new file mode 100644 index 00000000000..f09e4fa692b --- /dev/null +++ b/tests/robotests/src/com/android/settings/spa/app/appsettings/AppPermissionSummaryTest.kt @@ -0,0 +1,184 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.settings.spa.app.appsettings + +import android.content.Context +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import androidx.test.core.app.ApplicationProvider +import com.android.settings.R +import com.android.settingslib.applications.PermissionsSummaryHelper +import com.android.settingslib.applications.PermissionsSummaryHelper.PermissionsResultCallback +import com.google.common.truth.Truth.assertThat +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.any +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.eq +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Spy +import org.mockito.junit.MockitoJUnit +import org.mockito.junit.MockitoRule +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.mockito.Mockito.`when` as whenever + +@RunWith(RobolectricTestRunner::class) +@Config(shadows = [ShadowPermissionsSummaryHelper::class]) +open class AppPermissionSummaryTest { + + @JvmField + @Rule + val mockito: MockitoRule = MockitoJUnit.rule() + + @Spy + private var context: Context = ApplicationProvider.getApplicationContext() + + @Mock + private lateinit var packageManager: PackageManager + + private lateinit var summaryLiveData: AppPermissionSummaryLiveData + + @Before + fun setUp() { + doReturn(context).`when`(context).createContextAsUser(any(), eq(0)) + whenever(context.packageManager).thenReturn(packageManager) + + val app = ApplicationInfo().apply { + packageName = PACKAGE_NAME + } + summaryLiveData = AppPermissionSummaryLiveData(context, app) + } + + @Test + open fun permissionsChangeListener() { + summaryLiveData.getOrAwaitValue() { + verify(packageManager).addOnPermissionsChangeListener(any()) + verify(packageManager, never()).removeOnPermissionsChangeListener(any()) + } + verify(packageManager).removeOnPermissionsChangeListener(any()) + } + + @Test + fun summary_noPermissionsRequested() { + ShadowPermissionsSummaryHelper.requestedPermissionCount = 0 + + val (summary, enabled) = summaryLiveData.getOrAwaitValue()!! + + assertThat(summary).isEqualTo( + context.getString(R.string.runtime_permissions_summary_no_permissions_requested) + ) + assertThat(enabled).isFalse() + } + + @Test + fun summary_noPermissionsGranted() { + ShadowPermissionsSummaryHelper.requestedPermissionCount = 1 + ShadowPermissionsSummaryHelper.grantedGroupLabels = emptyList() + + val (summary, enabled) = summaryLiveData.getOrAwaitValue()!! + + assertThat(summary).isEqualTo( + context.getString(R.string.runtime_permissions_summary_no_permissions_granted) + ) + assertThat(enabled).isTrue() + } + + @Test + open fun onPermissionSummaryResult_hasRuntimePermission_shouldSetPermissionAsSummary() { + ShadowPermissionsSummaryHelper.requestedPermissionCount = 1 + ShadowPermissionsSummaryHelper.grantedGroupLabels = listOf(PERMISSION) + + val (summary, enabled) = summaryLiveData.getOrAwaitValue()!! + + assertThat(summary).isEqualTo(PERMISSION) + assertThat(enabled).isTrue() + } + + @Test + open fun onPermissionSummaryResult_hasAdditionalPermission_shouldSetAdditionalSummary() { + ShadowPermissionsSummaryHelper.requestedPermissionCount = 5 + ShadowPermissionsSummaryHelper.additionalGrantedPermissionCount = 2 + ShadowPermissionsSummaryHelper.grantedGroupLabels = listOf(PERMISSION) + + val (summary, enabled) = summaryLiveData.getOrAwaitValue()!! + + assertThat(summary).isEqualTo("Storage and 2 additional permissions") + assertThat(enabled).isTrue() + } + + companion object { + private const val PACKAGE_NAME = "packageName" + private const val PERMISSION = "Storage" + } +} + +@Implements(PermissionsSummaryHelper::class) +private object ShadowPermissionsSummaryHelper { + var requestedPermissionCount = 0 + var additionalGrantedPermissionCount = 0 + var grantedGroupLabels: List = emptyList() + + @Implementation + @JvmStatic + @Suppress("UNUSED_PARAMETER") + fun getPermissionSummary(context: Context, pkg: String, callback: PermissionsResultCallback) { + callback.onPermissionSummaryResult( + requestedPermissionCount, + additionalGrantedPermissionCount, + grantedGroupLabels, + ) + } +} + +private fun LiveData.getOrAwaitValue( + time: Long = 2, + timeUnit: TimeUnit = TimeUnit.SECONDS, + afterObserve: () -> Unit = {}, +): T? { + var data: T? = null + val latch = CountDownLatch(1) + val observer = Observer { o -> + data = o + latch.countDown() + } + this.observeForever(observer) + + afterObserve() + + try { + // Don't wait indefinitely if the LiveData is not set. + if (!latch.await(time, timeUnit)) { + throw TimeoutException("LiveData value was never set.") + } + + } finally { + this.removeObserver(observer) + } + + return data +}