Merge "Add AppInstallerInfoPreference for Spa"
diff --git a/src/com/android/settings/Utils.java b/src/com/android/settings/Utils.java
index 0fcf4a3..b2de004 100644
--- a/src/com/android/settings/Utils.java
+++ b/src/com/android/settings/Utils.java
@@ -21,7 +21,6 @@
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 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 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 79a4f35..b18a68f 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.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 @@
.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 @@
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 @@
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 2e3e45f..9a286c7 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 @@
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 0000000..8d9c98a
--- /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 <T> Flow<T>.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 0000000..b66967a
--- /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 0000000..f3eb529
--- /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()
+}