diff --git a/src/com/android/settings/spa/app/appinfo/AppAllServicesPreference.kt b/src/com/android/settings/spa/app/appinfo/AppAllServicesPreference.kt new file mode 100644 index 00000000000..20e8acae85f --- /dev/null +++ b/src/com/android/settings/spa/app/appinfo/AppAllServicesPreference.kt @@ -0,0 +1,116 @@ +/* + * 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.appinfo + +import android.content.Context +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.content.res.Resources +import android.os.Bundle +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.android.settings.R +import com.android.settingslib.spa.framework.compose.collectAsStateWithLifecycle +import com.android.settingslib.spa.widget.preference.Preference +import com.android.settingslib.spa.widget.preference.PreferenceModel +import com.android.settingslib.spaprivileged.model.app.resolveActionForApp +import com.android.settingslib.spaprivileged.model.app.userHandle +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus + +@Composable +fun AppAllServicesPreference(app: ApplicationInfo) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + val presenter = remember { AppAllServicesPresenter(context, app, coroutineScope) } + if (!presenter.isAvailableFlow.collectAsStateWithLifecycle(initialValue = false).value) return + + Preference(object : PreferenceModel { + override val title = stringResource(R.string.app_info_all_services_label) + override val summary = presenter.summaryFlow.collectAsStateWithLifecycle( + initialValue = stringResource(R.string.summary_placeholder), + ) + override val onClick = presenter::startActivity + }) +} + +private class AppAllServicesPresenter( + private val context: Context, + private val app: ApplicationInfo, + private val coroutineScope: CoroutineScope, +) { + private val packageManager = context.packageManager + + private val activityInfoFlow = flow { + emit(packageManager.resolveActionForApp( + app = app, + action = Intent.ACTION_VIEW_APP_FEATURES, + flags = PackageManager.GET_META_DATA, + )) + }.shareIn(coroutineScope + Dispatchers.IO, SharingStarted.WhileSubscribed(), 1) + + val isAvailableFlow = activityInfoFlow.map { it != null } + + val summaryFlow = activityInfoFlow.map { activityInfo -> + activityInfo?.metaData?.getSummary() ?: "" + }.flowOn(Dispatchers.IO) + + private fun Bundle.getSummary(): String { + val resources = try { + packageManager.getResourcesForApplication(app) + } catch (exception: PackageManager.NameNotFoundException) { + Log.d(TAG, "Name not found for the application.") + return "" + } + + return try { + resources.getString(getInt(SUMMARY_METADATA_KEY)) + } catch (exception: Resources.NotFoundException) { + Log.d(TAG, "Resource not found for summary string.") + "" + } + } + + fun startActivity() { + coroutineScope.launch { + activityInfoFlow.firstOrNull()?.let { activityInfo -> + val intent = Intent(Intent.ACTION_VIEW_APP_FEATURES).apply { + component = activityInfo.componentName + } + context.startActivityAsUser(intent, app.userHandle) + } + } + } + + companion object { + private const val TAG = "AppAllServicesPresenter" + private const val SUMMARY_METADATA_KEY = "app_features_preference_summary" + } +} diff --git a/src/com/android/settings/spa/app/appinfo/AppBatteryPreference.kt b/src/com/android/settings/spa/app/appinfo/AppBatteryPreference.kt index a2164b20f37..2766dfef360 100644 --- a/src/com/android/settings/spa/app/appinfo/AppBatteryPreference.kt +++ b/src/com/android/settings/spa/app/appinfo/AppBatteryPreference.kt @@ -16,7 +16,6 @@ package com.android.settings.spa.app.appinfo -import android.app.settings.SettingsEnums import android.content.Context import android.content.pm.ApplicationInfo import android.util.Log @@ -123,7 +122,7 @@ private class AppBatteryPresenter(private val context: Context, private val app: Log.i(TAG, "handlePreferenceTreeClick():\n$this") AdvancedPowerUsageDetail.startBatteryDetailPage( context, - SettingsEnums.APPLICATIONS_INSTALLED_APP_DETAILS, + AppInfoSettingsProvider.METRICS_CATEGORY, this, Utils.formatPercentage(percentOfTotal, true), null, @@ -141,7 +140,7 @@ private class AppBatteryPresenter(private val context: Context, private val app: .setDestination(AdvancedPowerUsageDetail::class.java.name) .setTitleRes(R.string.battery_details_title) .setArguments(args) - .setSourceMetricsCategory(SettingsEnums.APPLICATIONS_INSTALLED_APP_DETAILS) + .setSourceMetricsCategory(AppInfoSettingsProvider.METRICS_CATEGORY) .launch() } diff --git a/src/com/android/settings/spa/app/appinfo/AppDataUsagePreference.kt b/src/com/android/settings/spa/app/appinfo/AppDataUsagePreference.kt index d13d108e408..328f8a5b3cf 100644 --- a/src/com/android/settings/spa/app/appinfo/AppDataUsagePreference.kt +++ b/src/com/android/settings/spa/app/appinfo/AppDataUsagePreference.kt @@ -16,7 +16,6 @@ package com.android.settings.spa.app.appinfo -import android.app.settings.SettingsEnums import android.content.Context import android.content.pm.ApplicationInfo import android.net.NetworkStats @@ -122,7 +121,7 @@ private class AppDataUsagePresenter( AppDataUsage::class.java, app, context, - SettingsEnums.APPLICATIONS_INSTALLED_APP_DETAILS, + AppInfoSettingsProvider.METRICS_CATEGORY, ) } diff --git a/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt b/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt index a1f3c1cb309..da8b72a2d23 100644 --- a/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt +++ b/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt @@ -94,7 +94,7 @@ private fun AppInfoSettings(packageInfoPresenter: PackageInfoPresenter) { AppButtons(packageInfoPresenter) AppSettingsPreference(app) - // TODO: all_services_settings + AppAllServicesPreference(app) // TODO: notification_settings AppPermissionPreference(app) AppStoragePreference(app) diff --git a/src/com/android/settings/spa/app/appinfo/AppSettingsPreference.kt b/src/com/android/settings/spa/app/appinfo/AppSettingsPreference.kt index babd6070080..ee0212ad5e8 100644 --- a/src/com/android/settings/spa/app/appinfo/AppSettingsPreference.kt +++ b/src/com/android/settings/spa/app/appinfo/AppSettingsPreference.kt @@ -19,8 +19,8 @@ package com.android.settings.spa.app.appinfo import android.app.settings.SettingsEnums import android.content.Context import android.content.Intent +import android.content.pm.ActivityInfo import android.content.pm.ApplicationInfo -import android.content.pm.PackageManager.ResolveInfoFlags import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -31,16 +31,17 @@ import com.android.settings.overlay.FeatureFactory import com.android.settingslib.spa.framework.compose.collectAsStateWithLifecycle import com.android.settingslib.spa.widget.preference.Preference import com.android.settingslib.spa.widget.preference.PreferenceModel +import com.android.settingslib.spaprivileged.model.app.resolveActionForApp import com.android.settingslib.spaprivileged.model.app.userHandle -import com.android.settingslib.spaprivileged.model.app.userId import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import kotlinx.coroutines.plus @Composable fun AppSettingsPreference(app: ApplicationInfo) { @@ -63,39 +64,28 @@ private class AppSettingsPresenter( private val packageManager = context.packageManager private val intentFlow = flow { - emit(resolveIntent()) - }.shareIn(coroutineScope, SharingStarted.WhileSubscribed(), 1) + emit(packageManager.resolveActionForApp(app, Intent.ACTION_APPLICATION_PREFERENCES)) + }.shareIn(coroutineScope + Dispatchers.IO, SharingStarted.WhileSubscribed(), 1) val isAvailableFlow = intentFlow.map { it != null } fun startActivity() { coroutineScope.launch { - intentFlow.collect { intent -> - if (intent != null) { - FeatureFactory.getFactory(context).metricsFeatureProvider - .action( - SettingsEnums.PAGE_UNKNOWN, - SettingsEnums.ACTION_OPEN_APP_SETTING, - AppInfoSettingsProvider.METRICS_CATEGORY, - null, - 0, - ) - context.startActivityAsUser(intent, app.userHandle) - } - } + intentFlow.firstOrNull()?.let(::startActivity) } } - private suspend fun resolveIntent(): Intent? = withContext(Dispatchers.IO) { + private fun startActivity(activityInfo: ActivityInfo) { + FeatureFactory.getFactory(context).metricsFeatureProvider.action( + SettingsEnums.PAGE_UNKNOWN, + SettingsEnums.ACTION_OPEN_APP_SETTING, + AppInfoSettingsProvider.METRICS_CATEGORY, + null, + 0, + ) val intent = Intent(Intent.ACTION_APPLICATION_PREFERENCES).apply { - `package` = app.packageName + component = activityInfo.componentName } - packageManager.resolveActivityAsUser(intent, ResolveInfoFlags.of(0), app.userId) - ?.activityInfo - ?.let { activityInfo -> - Intent(intent.action).apply { - setClassName(activityInfo.packageName, activityInfo.name) - } - } + context.startActivityAsUser(intent, app.userHandle) } } diff --git a/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppAllServicesPreferenceTest.kt b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppAllServicesPreferenceTest.kt new file mode 100644 index 00000000000..9846e3f7ff1 --- /dev/null +++ b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppAllServicesPreferenceTest.kt @@ -0,0 +1,189 @@ +/* + * 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.appinfo + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.ActivityInfo +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.content.pm.PackageManager.ResolveInfoFlags +import android.content.pm.ResolveInfo +import android.content.res.Resources +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.core.os.bundleOf +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.settings.R +import com.android.settingslib.spaprivileged.model.app.userHandle +import com.android.settingslib.spaprivileged.model.app.userId +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.Mock +import org.mockito.Mockito.any +import org.mockito.Mockito.anyInt +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.doThrow +import org.mockito.Mockito.eq +import org.mockito.Mockito.verify +import org.mockito.Spy +import org.mockito.junit.MockitoJUnit +import org.mockito.junit.MockitoRule +import org.mockito.Mockito.`when` as whenever + +@RunWith(AndroidJUnit4::class) +class AppAllServicesPreferenceTest { + @JvmField + @Rule + val mockito: MockitoRule = MockitoJUnit.rule() + + @get:Rule + val composeTestRule = createComposeRule() + + @Spy + private val context: Context = ApplicationProvider.getApplicationContext() + + @Mock + private lateinit var packageManager: PackageManager + + @Mock + private lateinit var resources: Resources + + @Before + fun setUp() { + whenever(context.packageManager).thenReturn(packageManager) + whenever(packageManager.getResourcesForApplication(APP)).thenReturn(resources) + doThrow(Resources.NotFoundException()).`when`(resources).getString(anyInt()) + } + + private fun mockResolveActivityAsUser(resolveInfo: ResolveInfo?) { + whenever( + packageManager.resolveActivityAsUser(any(), any(), eq(APP.userId)) + ).thenReturn(resolveInfo) + } + + @Test + fun callResolveActivityAsUser_withIntent() { + mockResolveActivityAsUser(null) + + setContent() + + val intentCaptor = ArgumentCaptor.forClass(Intent::class.java) + verify(packageManager).resolveActivityAsUser( + intentCaptor.capture(), any(), eq(APP.userId) + ) + val intent = intentCaptor.value + assertThat(intent.action).isEqualTo(Intent.ACTION_VIEW_APP_FEATURES) + assertThat(intent.`package`).isEqualTo(PACKAGE_NAME) + } + + @Test + fun noResolveInfo_notDisplayed() { + mockResolveActivityAsUser(null) + + setContent() + + composeTestRule.onRoot().assertIsNotDisplayed() + } + + @Test + fun noAllServicesActivity_notDisplayed() { + mockResolveActivityAsUser(ResolveInfo()) + + setContent() + + composeTestRule.onRoot().assertIsNotDisplayed() + } + + @Test + fun hasAllServicesActivity_displayed() { + mockResolveActivityAsUser(RESOLVE_INFO) + + setContent() + + composeTestRule.onNodeWithText(context.getString(R.string.app_info_all_services_label)) + .assertIsDisplayed() + .assertIsEnabled() + } + + @Test + fun hasSummary() { + mockResolveActivityAsUser(RESOLVE_INFO) + doReturn(SUMMARY).`when`(resources).getString(SUMMARY_RES_ID) + + setContent() + + composeTestRule.onNodeWithText(SUMMARY).assertIsDisplayed() + } + + @Test + fun whenClick_startActivity() { + mockResolveActivityAsUser(RESOLVE_INFO) + + setContent() + composeTestRule.onRoot().performClick() + + val intentCaptor = ArgumentCaptor.forClass(Intent::class.java) + verify(context).startActivityAsUser(intentCaptor.capture(), eq(APP.userHandle)) + val intent = intentCaptor.value + assertThat(intent.action).isEqualTo(Intent.ACTION_VIEW_APP_FEATURES) + assertThat(intent.component).isEqualTo(ComponentName(PACKAGE_NAME, ACTIVITY_NAME)) + } + + private fun setContent() { + composeTestRule.setContent { + CompositionLocalProvider(LocalContext provides context) { + AppAllServicesPreference(APP) + } + } + } + + private companion object { + const val PACKAGE_NAME = "packageName" + const val ACTIVITY_NAME = "activityName" + const val UID = 123 + const val SUMMARY_RES_ID = 456 + const val SUMMARY = "summary" + + val APP = ApplicationInfo().apply { + packageName = PACKAGE_NAME + uid = UID + } + val RESOLVE_INFO = ResolveInfo().apply { + activityInfo = ActivityInfo().apply { + packageName = PACKAGE_NAME + name = ACTIVITY_NAME + metaData = bundleOf( + "app_features_preference_summary" to SUMMARY_RES_ID + ) + } + } + } +} diff --git a/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppBatteryPreferenceTest.kt b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppBatteryPreferenceTest.kt index 06574352f3f..01113ccb279 100644 --- a/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppBatteryPreferenceTest.kt +++ b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppBatteryPreferenceTest.kt @@ -16,7 +16,6 @@ package com.android.settings.spa.app.appinfo -import android.app.settings.SettingsEnums import android.content.Context import android.content.pm.ApplicationInfo import androidx.compose.runtime.CompositionLocalProvider @@ -159,7 +158,7 @@ class AppBatteryPreferenceTest { ExtendedMockito.verify { AdvancedPowerUsageDetail.startBatteryDetailPage( context, - SettingsEnums.APPLICATIONS_INSTALLED_APP_DETAILS, + AppInfoSettingsProvider.METRICS_CATEGORY, batteryDiffEntry, "10%", null, diff --git a/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppDataUsagePreferenceTest.kt b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppDataUsagePreferenceTest.kt index 22876d19464..174f5085864 100644 --- a/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppDataUsagePreferenceTest.kt +++ b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppDataUsagePreferenceTest.kt @@ -16,7 +16,6 @@ package com.android.settings.spa.app.appinfo -import android.app.settings.SettingsEnums import android.content.Context import android.content.pm.ApplicationInfo import android.net.NetworkTemplate @@ -164,7 +163,7 @@ class AppDataUsagePreferenceTest { AppDataUsage::class.java, APP, context, - SettingsEnums.APPLICATIONS_INSTALLED_APP_DETAILS, + AppInfoSettingsProvider.METRICS_CATEGORY, ) } }