diff --git a/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt b/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt index b4b6945ca1a..32b09bf1180 100644 --- a/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt +++ b/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt @@ -16,6 +16,7 @@ package com.android.settings.spa.app.appinfo +import android.app.settings.SettingsEnums import android.content.pm.ApplicationInfo import android.os.Bundle import androidx.compose.runtime.Composable @@ -50,6 +51,8 @@ object AppInfoSettingsProvider : SettingsPageProvider { navArgument(USER_ID) { type = NavType.IntType }, ) + const val METRICS_CATEGORY = SettingsEnums.APPLICATIONS_INSTALLED_APP_DETAILS + @Composable override fun Page(arguments: Bundle?) { val packageName = arguments!!.getString(PACKAGE_NAME)!! @@ -90,6 +93,9 @@ private fun AppInfoSettings(packageInfoPresenter: PackageInfoPresenter) { AppButtons(packageInfoPresenter) + AppSettingsPreference(app) + // TODO: all_services_settings + // TODO: notification_settings AppPermissionPreference(app) AppStoragePreference(app) // TODO: instant_app_launch_supported_domain_urls diff --git a/src/com/android/settings/spa/app/appinfo/AppOpenByDefaultPreference.kt b/src/com/android/settings/spa/app/appinfo/AppOpenByDefaultPreference.kt index 936dee61d9a..4cc24b36a87 100644 --- a/src/com/android/settings/spa/app/appinfo/AppOpenByDefaultPreference.kt +++ b/src/com/android/settings/spa/app/appinfo/AppOpenByDefaultPreference.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.Composable @@ -86,7 +85,7 @@ private class AppOpenByDefaultPresenter( AppLaunchSettings::class.java, app, context, - SettingsEnums.APPLICATIONS_INSTALLED_APP_DETAILS, + AppInfoSettingsProvider.METRICS_CATEGORY, ) } } diff --git a/src/com/android/settings/spa/app/appinfo/AppSettingsPreference.kt b/src/com/android/settings/spa/app/appinfo/AppSettingsPreference.kt new file mode 100644 index 00000000000..babd6070080 --- /dev/null +++ b/src/com/android/settings/spa/app/appinfo/AppSettingsPreference.kt @@ -0,0 +1,101 @@ +/* + * 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.app.settings.SettingsEnums +import android.content.Context +import android.content.Intent +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 +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.android.settings.R +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.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.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Composable +fun AppSettingsPreference(app: ApplicationInfo) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + val presenter = remember { AppSettingsPresenter(context, app, coroutineScope) } + if (!presenter.isAvailableFlow.collectAsStateWithLifecycle(initialValue = false).value) return + + Preference(object : PreferenceModel { + override val title = stringResource(R.string.app_settings_link) + override val onClick = presenter::startActivity + }) +} + +private class AppSettingsPresenter( + private val context: Context, + private val app: ApplicationInfo, + private val coroutineScope: CoroutineScope, +) { + private val packageManager = context.packageManager + + private val intentFlow = flow { + emit(resolveIntent()) + }.shareIn(coroutineScope, 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) + } + } + } + } + + private suspend fun resolveIntent(): Intent? = withContext(Dispatchers.IO) { + val intent = Intent(Intent.ACTION_APPLICATION_PREFERENCES).apply { + `package` = app.packageName + } + packageManager.resolveActivityAsUser(intent, ResolveInfoFlags.of(0), app.userId) + ?.activityInfo + ?.let { activityInfo -> + Intent(intent.action).apply { + setClassName(activityInfo.packageName, activityInfo.name) + } + } + } +} diff --git a/src/com/android/settings/spa/app/appinfo/AppStoragePreference.kt b/src/com/android/settings/spa/app/appinfo/AppStoragePreference.kt index 265f88299f3..e8b1018acfd 100644 --- a/src/com/android/settings/spa/app/appinfo/AppStoragePreference.kt +++ b/src/com/android/settings/spa/app/appinfo/AppStoragePreference.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.Composable @@ -70,6 +69,6 @@ private fun startStorageSettingsActivity(context: Context, app: ApplicationInfo) AppStorageSettings::class.java, app, context, - SettingsEnums.APPLICATIONS_INSTALLED_APP_DETAILS, + AppInfoSettingsProvider.METRICS_CATEGORY, ) } diff --git a/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppSettingsPreferenceTest.kt b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppSettingsPreferenceTest.kt new file mode 100644 index 00000000000..1184ee7270a --- /dev/null +++ b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppSettingsPreferenceTest.kt @@ -0,0 +1,169 @@ +/* + * 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 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.assertIsNotEnabled +import androidx.compose.ui.test.hasText +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.compose.ui.test.printToLog +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.settings.R +import com.android.settings.testutils.waitUntilExists +import com.android.settingslib.applications.AppUtils +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.ArgumentMatchers +import org.mockito.Mock +import org.mockito.Mockito.any +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 AppSettingsPreferenceTest { + @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 + + @Before + fun setUp() { + whenever(context.packageManager).thenReturn(packageManager) + } + + 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_APPLICATION_PREFERENCES) + assertThat(intent.`package`).isEqualTo(PACKAGE_NAME) + } + + @Test + fun noResolveInfo_notDisplayed() { + mockResolveActivityAsUser(null) + + setContent() + + composeTestRule.onRoot().assertIsNotDisplayed() + } + + @Test + fun noSettingsActivity_notDisplayed() { + mockResolveActivityAsUser(ResolveInfo()) + + setContent() + + composeTestRule.onRoot().assertIsNotDisplayed() + } + + @Test + fun hasSettingsActivity_displayed() { + mockResolveActivityAsUser(RESOLVE_INFO) + + setContent() + + composeTestRule.onNodeWithText(context.getString(R.string.app_settings_link)) + .assertIsDisplayed() + .assertIsEnabled() + } + + @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_APPLICATION_PREFERENCES) + assertThat(intent.component).isEqualTo(ComponentName(PACKAGE_NAME, ACTIVITY_NAME)) + } + + private fun setContent() { + composeTestRule.setContent { + CompositionLocalProvider(LocalContext provides context) { + AppSettingsPreference(APP) + } + } + } + + private companion object { + const val PACKAGE_NAME = "packageName" + const val ACTIVITY_NAME = "activityName" + const val UID = 123 + val APP = ApplicationInfo().apply { + packageName = PACKAGE_NAME + uid = UID + } + val RESOLVE_INFO = ResolveInfo().apply { + activityInfo = ActivityInfo().apply { + packageName = PACKAGE_NAME + name = ACTIVITY_NAME + } + } + } +}