Add AppSettingsPreference 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] -> Additional settings in the app

Bug: 236346018
Test: Unit test & Manual with App Info page
Change-Id: I40a175d771c4c0f13986f8c615169c99ce1b0623
This commit is contained in:
Chaohui Wang
2022-10-28 10:31:49 +08:00
parent c972af0ca7
commit aafdb3bfbc
5 changed files with 278 additions and 4 deletions

View File

@@ -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

View File

@@ -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,
)
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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,
)
}

View File

@@ -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<ResolveInfoFlags>(), 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<ResolveInfoFlags>(), 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
}
}
}
}