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()
+}