From 601124517a6eaddc57aae41b600311b73537892f Mon Sep 17 00:00:00 2001 From: Chaohui Wang Date: Thu, 27 Oct 2022 18:38:39 +0800 Subject: [PATCH] Add AppInstallerInfoPreference for Spa This is used in new App Info page. To try: 1. adb shell am start -n com.android.settings/.spa.SpaActivity 2. Go to Apps -> All apps -> [One App] -> App details Bug: 236346018 Test: Unit test Change-Id: Ibd1ae27c60a096b7f12ca6640a58b099dcfb0b6b --- src/com/android/settings/Utils.java | 6 +- .../settings/applications/AppStoreUtil.java | 16 +- .../spa/app/appinfo/AppInfoSettings.kt | 4 +- .../app/appinfo/AppInstallerInfoPreference.kt | 123 +++++++++++ .../appinfo/AppInstallerInfoPreferenceTest.kt | 208 ++++++++++++++++++ .../testutils/ComposeContentTestRuleExt.kt | 25 +++ 6 files changed, 374 insertions(+), 8 deletions(-) create mode 100644 src/com/android/settings/spa/app/appinfo/AppInstallerInfoPreference.kt create mode 100644 tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppInstallerInfoPreferenceTest.kt create mode 100644 tests/spa_unit/src/com/android/settings/testutils/ComposeContentTestRuleExt.kt diff --git a/src/com/android/settings/Utils.java b/src/com/android/settings/Utils.java index 0fcf4a3e7d3..b2de0041b1b 100644 --- a/src/com/android/settings/Utils.java +++ b/src/com/android/settings/Utils.java @@ -21,7 +21,6 @@ import static android.content.Intent.EXTRA_USER_ID; import static android.text.format.DateUtils.FORMAT_ABBREV_MONTH; import static android.text.format.DateUtils.FORMAT_SHOW_DATE; -import android.annotation.Nullable; import android.app.ActionBar; import android.app.Activity; import android.app.ActivityManager; @@ -96,6 +95,7 @@ import android.widget.TabWidget; import androidx.annotation.ColorInt; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.core.graphics.drawable.IconCompat; import androidx.core.graphics.drawable.RoundedBitmapDrawable; @@ -799,7 +799,9 @@ public final class Utils extends com.android.settingslib.Utils { } } - public static CharSequence getApplicationLabel(Context context, String packageName) { + /** Gets the application label of the given package name. */ + @Nullable + public static CharSequence getApplicationLabel(Context context, @NonNull String packageName) { try { final ApplicationInfo appInfo = context.getPackageManager().getApplicationInfo( packageName, diff --git a/src/com/android/settings/applications/AppStoreUtil.java b/src/com/android/settings/applications/AppStoreUtil.java index 79a4f35e19f..b18a68fd7db 100644 --- a/src/com/android/settings/applications/AppStoreUtil.java +++ b/src/com/android/settings/applications/AppStoreUtil.java @@ -24,7 +24,9 @@ import android.content.pm.PackageManager.NameNotFoundException; import android.content.pm.ResolveInfo; import android.util.Log; -// This class provides methods that help dealing with app stores. +import androidx.annotation.Nullable; + +/** This class provides methods that help dealing with app stores. */ public class AppStoreUtil { private static final String LOG_TAG = "AppStoreUtil"; @@ -34,8 +36,11 @@ public class AppStoreUtil { .setClassName(result.activityInfo.packageName, result.activityInfo.name) : null; } - // Returns the package name of the app that we consider to be the user-visible 'installer' - // of given packageName, if one is available. + /** + * Returns the package name of the app that we consider to be the user-visible 'installer' + * of given packageName, if one is available. + */ + @Nullable public static String getInstallerPackageName(Context context, String packageName) { String installerPackageName; try { @@ -62,7 +67,8 @@ public class AppStoreUtil { return installerPackageName; } - // Returns a link to the installer app store for a given package name. + /** Returns a link to the installer app store for a given package name. */ + @Nullable public static Intent getAppStoreLink(Context context, String installerPackageName, String packageName) { Intent intent = new Intent(Intent.ACTION_SHOW_APP_INFO) @@ -75,7 +81,7 @@ public class AppStoreUtil { return null; } - // Convenience method that looks up the installerPackageName for you. + /** Convenience method that looks up the installerPackageName for you. */ public static Intent getAppStoreLink(Context context, String packageName) { String installerPackageName = getInstallerPackageName(context, packageName); return getAppStoreLink(context, installerPackageName, packageName); diff --git a/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt b/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt index 2e3e45f4f8f..9a286c712e7 100644 --- a/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt +++ b/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt @@ -113,7 +113,9 @@ private fun AppInfoSettings(packageInfoPresenter: PackageInfoPresenter) { AlarmsAndRemindersAppListProvider.InfoPageEntryItem(app) } - // TODO: app_installer + Category(title = stringResource(R.string.app_install_details_group_title)) { + AppInstallerInfoPreference(app) + } appInfoProvider.FooterAppVersion() } } diff --git a/src/com/android/settings/spa/app/appinfo/AppInstallerInfoPreference.kt b/src/com/android/settings/spa/app/appinfo/AppInstallerInfoPreference.kt new file mode 100644 index 00000000000..8d9c98ab35f --- /dev/null +++ b/src/com/android/settings/spa/app/appinfo/AppInstallerInfoPreference.kt @@ -0,0 +1,123 @@ +/* + * 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.pm.ApplicationInfo +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.Utils +import com.android.settings.applications.AppStoreUtil +import com.android.settingslib.applications.AppUtils +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.framework.common.asUser +import com.android.settingslib.spaprivileged.framework.common.userManager +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.Flow +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 AppInstallerInfoPreference(app: ApplicationInfo) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + val presenter = remember { AppInstallerInfoPresenter(context, app, coroutineScope) } + if (!presenter.isAvailableFlow.collectAsStateWithLifecycle(initialValue = false).value) return + + Preference(object : PreferenceModel { + override val title = stringResource(R.string.app_install_details_title) + override val summary = presenter.summaryFlow.collectAsStateWithLifecycle( + initialValue = stringResource(R.string.summary_placeholder), + ) + override val enabled = + presenter.enabledFlow.collectAsStateWithLifecycle(initialValue = false) + override val onClick = presenter::startActivity + }) +} + +private class AppInstallerInfoPresenter( + private val context: Context, + private val app: ApplicationInfo, + private val coroutineScope: CoroutineScope, +) { + private val userContext = context.asUser(app.userHandle) + private val packageManager = userContext.packageManager + private val userManager = context.userManager + + private val installerPackageFlow = flow { + emit(withContext(Dispatchers.IO) { + AppStoreUtil.getInstallerPackageName(userContext, app.packageName) + }) + }.sharedFlow() + + private val installerLabelFlow = installerPackageFlow.map { installerPackage -> + installerPackage ?: return@map null + withContext(Dispatchers.IO) { + Utils.getApplicationLabel(context, installerPackage) + } + }.sharedFlow() + + val isAvailableFlow = installerLabelFlow.map { installerLabel -> + withContext(Dispatchers.IO) { + !userManager.isManagedProfile(app.userId) && + !AppUtils.isMainlineModule(packageManager, app.packageName) && + installerLabel != null + } + } + + val summaryFlow = installerLabelFlow.map { installerLabel -> + val detailsStringId = when { + app.isInstantApp -> R.string.instant_app_details_summary + else -> R.string.app_install_details_summary + } + context.getString(detailsStringId, installerLabel) + } + + private val intentFlow = installerPackageFlow.map { installerPackage -> + withContext(Dispatchers.IO) { + AppStoreUtil.getAppStoreLink(context, installerPackage, app.packageName) + } + }.sharedFlow() + + val enabledFlow = intentFlow.map { it != null } + + fun startActivity() { + coroutineScope.launch { + intentFlow.collect { intent -> + if (intent != null) { + context.startActivityAsUser(intent, app.userHandle) + } + } + } + } + + private fun Flow.sharedFlow() = + shareIn(coroutineScope, SharingStarted.WhileSubscribed(), 1) +} diff --git a/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppInstallerInfoPreferenceTest.kt b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppInstallerInfoPreferenceTest.kt new file mode 100644 index 00000000000..b66967a96c2 --- /dev/null +++ b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppInstallerInfoPreferenceTest.kt @@ -0,0 +1,208 @@ +/* + * 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.os.UserManager +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.dx.mockito.inline.extended.ExtendedMockito.mockitoSession +import com.android.settings.R +import com.android.settings.Utils +import com.android.settings.applications.AppStoreUtil +import com.android.settings.testutils.waitUntilExists +import com.android.settingslib.applications.AppUtils +import com.android.settingslib.spaprivileged.framework.common.userManager +import com.android.settingslib.spaprivileged.model.app.userHandle +import org.junit.After +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.anyInt +import org.mockito.Mockito.eq +import org.mockito.Mockito.verify +import org.mockito.MockitoSession +import org.mockito.Spy +import org.mockito.quality.Strictness +import org.mockito.Mockito.`when` as whenever + +@RunWith(AndroidJUnit4::class) +class AppInstallerInfoPreferenceTest { + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var mockSession: MockitoSession + + @Spy + private val context: Context = ApplicationProvider.getApplicationContext() + + @Mock + private lateinit var userManager: UserManager + + @Before + fun setUp() { + mockSession = mockitoSession() + .initMocks(this) + .mockStatic(AppStoreUtil::class.java) + .mockStatic(Utils::class.java) + .mockStatic(AppUtils::class.java) + .strictness(Strictness.LENIENT) + .startMocking() + whenever(context.userManager).thenReturn(userManager) + whenever(userManager.isManagedProfile(anyInt())).thenReturn(false) + whenever(AppStoreUtil.getInstallerPackageName(any(), eq(PACKAGE_NAME))) + .thenReturn(INSTALLER_PACKAGE_NAME) + whenever(AppStoreUtil.getAppStoreLink(context, INSTALLER_PACKAGE_NAME, PACKAGE_NAME)) + .thenReturn(STORE_LINK) + whenever(Utils.getApplicationLabel(context, INSTALLER_PACKAGE_NAME)) + .thenReturn(INSTALLER_PACKAGE_LABEL) + whenever(AppUtils.isMainlineModule(any(), eq(PACKAGE_NAME))) + .thenReturn(false) + } + + @After + fun tearDown() { + mockSession.finishMocking() + } + + @Test + fun whenNoInstaller_notDisplayed() { + whenever(AppStoreUtil.getInstallerPackageName(any(), eq(PACKAGE_NAME))).thenReturn(null) + + setContent() + + composeTestRule.onRoot().assertIsNotDisplayed() + } + + @Test + fun whenInstallerLabelIsNull_notDisplayed() { + whenever(Utils.getApplicationLabel(context, INSTALLER_PACKAGE_NAME)).thenReturn(null) + + setContent() + + composeTestRule.onRoot().assertIsNotDisplayed() + } + + @Test + fun whenIsManagedProfile_notDisplayed() { + whenever(userManager.isManagedProfile(anyInt())).thenReturn(true) + + setContent() + + composeTestRule.onRoot().assertIsNotDisplayed() + } + + @Test + fun whenIsMainlineModule_notDisplayed() { + whenever(AppUtils.isMainlineModule(any(), eq(PACKAGE_NAME))).thenReturn(true) + + setContent() + + composeTestRule.onRoot().assertIsNotDisplayed() + } + + @Test + fun whenStoreLinkIsNull_disabled() { + whenever(AppStoreUtil.getAppStoreLink(context, INSTALLER_PACKAGE_NAME, PACKAGE_NAME)) + .thenReturn(null) + + setContent() + waitUntilDisplayed() + + composeTestRule.onNode(preferenceNode).assertIsNotEnabled() + } + + @Test + fun whenIsInstantApp_hasSummaryForInstant() { + val instantApp = ApplicationInfo().apply { + packageName = PACKAGE_NAME + uid = UID + privateFlags = ApplicationInfo.PRIVATE_FLAG_INSTANT + } + + setContent(instantApp) + waitUntilDisplayed() + + composeTestRule.onRoot().printToLog("AAA") + composeTestRule.onNodeWithText("More info on installer label") + .assertIsDisplayed() + .assertIsEnabled() + } + + @Test + fun whenNotInstantApp() { + setContent() + waitUntilDisplayed() + + composeTestRule.onRoot().printToLog("AAA") + composeTestRule.onNodeWithText("App installed from installer label") + .assertIsDisplayed() + .assertIsEnabled() + } + + @Test + fun whenClick_startActivity() { + setContent() + waitUntilDisplayed() + composeTestRule.onRoot().performClick() + + verify(context).startActivityAsUser(STORE_LINK, APP.userHandle) + } + + private fun setContent(app: ApplicationInfo = APP) { + composeTestRule.setContent { + CompositionLocalProvider(LocalContext provides context) { + AppInstallerInfoPreference(app) + } + } + } + + private fun waitUntilDisplayed() { + composeTestRule.waitUntilExists(preferenceNode) + } + + private val preferenceNode = hasText(context.getString(R.string.app_install_details_title)) + + private companion object { + const val PACKAGE_NAME = "packageName" + const val INSTALLER_PACKAGE_NAME = "installer" + const val INSTALLER_PACKAGE_LABEL = "installer label" + val STORE_LINK = Intent("store/link") + const val UID = 123 + val APP = ApplicationInfo().apply { + packageName = PACKAGE_NAME + uid = UID + } + } +} diff --git a/tests/spa_unit/src/com/android/settings/testutils/ComposeContentTestRuleExt.kt b/tests/spa_unit/src/com/android/settings/testutils/ComposeContentTestRuleExt.kt new file mode 100644 index 00000000000..f3eb52957d4 --- /dev/null +++ b/tests/spa_unit/src/com/android/settings/testutils/ComposeContentTestRuleExt.kt @@ -0,0 +1,25 @@ +/* + * 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.testutils + +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.junit4.ComposeContentTestRule + +/** Blocks until the found a semantics node that match the given condition. */ +fun ComposeContentTestRule.waitUntilExists(matcher: SemanticsMatcher) = waitUntil { + onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty() +}