diff --git a/res/values/strings.xml b/res/values/strings.xml index a3e095d35a1..a92a8974bb3 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -8163,6 +8163,9 @@ At your request, Android is blocking this group of notifications from appearing on this device + + This app does not send notifications + Categories 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/fuelgauge/AdvancedPowerUsageDetail.java b/src/com/android/settings/fuelgauge/AdvancedPowerUsageDetail.java index 32e2e2f0b54..e3919b07ebf 100644 --- a/src/com/android/settings/fuelgauge/AdvancedPowerUsageDetail.java +++ b/src/com/android/settings/fuelgauge/AdvancedPowerUsageDetail.java @@ -134,6 +134,14 @@ public class AdvancedPowerUsageDetail extends DashboardFragment implements public static void startBatteryDetailPage( Activity caller, InstrumentedPreferenceFragment fragment, BatteryDiffEntry diffEntry, String usagePercent, String slotInformation) { + startBatteryDetailPage( + caller, fragment.getMetricsCategory(), diffEntry, usagePercent, slotInformation); + } + + /** Launches battery details page for an individual battery consumer fragment. */ + public static void startBatteryDetailPage( + Context context, int sourceMetricsCategory, + BatteryDiffEntry diffEntry, String usagePercent, String slotInformation) { final BatteryHistEntry histEntry = diffEntry.mBatteryHistEntry; final LaunchBatteryDetailPageArgs launchArgs = new LaunchBatteryDetailPageArgs(); // configure the launch argument. @@ -147,7 +155,7 @@ public class AdvancedPowerUsageDetail extends DashboardFragment implements launchArgs.mForegroundTimeMs = diffEntry.mForegroundUsageTimeInMs; launchArgs.mBackgroundTimeMs = diffEntry.mBackgroundUsageTimeInMs; launchArgs.mIsUserEntry = histEntry.isUserEntry(); - startBatteryDetailPage(caller, fragment, launchArgs); + startBatteryDetailPage(context, sourceMetricsCategory, launchArgs); } /** Launches battery details page for an individual battery consumer. */ @@ -165,11 +173,11 @@ public class AdvancedPowerUsageDetail extends DashboardFragment implements launchArgs.mForegroundTimeMs = isValidToShowSummary ? entry.getTimeInForegroundMs() : 0; launchArgs.mBackgroundTimeMs = isValidToShowSummary ? entry.getTimeInBackgroundMs() : 0; launchArgs.mIsUserEntry = entry.isUserEntry(); - startBatteryDetailPage(caller, fragment, launchArgs); + startBatteryDetailPage(caller, fragment.getMetricsCategory(), launchArgs); } - private static void startBatteryDetailPage(Activity caller, - InstrumentedPreferenceFragment fragment, LaunchBatteryDetailPageArgs launchArgs) { + private static void startBatteryDetailPage( + Context context, int sourceMetricsCategory, LaunchBatteryDetailPageArgs launchArgs) { final Bundle args = new Bundle(); if (launchArgs.mPackageName == null) { // populate data for system app @@ -190,11 +198,11 @@ public class AdvancedPowerUsageDetail extends DashboardFragment implements final int userId = launchArgs.mIsUserEntry ? ActivityManager.getCurrentUser() : UserHandle.getUserId(launchArgs.mUid); - new SubSettingLauncher(caller) + new SubSettingLauncher(context) .setDestination(AdvancedPowerUsageDetail.class.getName()) .setTitleRes(R.string.battery_details_title) .setArguments(args) - .setSourceMetricsCategory(fragment.getMetricsCategory()) + .setSourceMetricsCategory(sourceMetricsCategory) .setUserHandle(new UserHandle(userId)) .launch(); } diff --git a/src/com/android/settings/notification/NotificationBackend.java b/src/com/android/settings/notification/NotificationBackend.java index efc53b947d0..aa7d547a92c 100644 --- a/src/com/android/settings/notification/NotificationBackend.java +++ b/src/com/android/settings/notification/NotificationBackend.java @@ -117,6 +117,7 @@ public class NotificationBackend { if (app.requestedPermissions == null || Arrays.stream(app.requestedPermissions) .noneMatch(p -> p.equals(android.Manifest.permission.POST_NOTIFICATIONS))) { row.lockedImportance = true; + row.permissionStateLocked = true; } } } @@ -675,6 +676,9 @@ public class NotificationBackend { public boolean systemApp; public boolean lockedImportance; public boolean showBadge; + // For apps target T but have not but has not requested the permission + // we cannot change the permission state + public boolean permissionStateLocked; public int bubblePreference = NotificationManager.BUBBLE_PREFERENCE_NONE; public int userId; public int blockedChannelCount; diff --git a/src/com/android/settings/notification/VibrateIconPreferenceController.java b/src/com/android/settings/notification/VibrateIconPreferenceController.java index 25d23264131..d772b47d59e 100644 --- a/src/com/android/settings/notification/VibrateIconPreferenceController.java +++ b/src/com/android/settings/notification/VibrateIconPreferenceController.java @@ -19,6 +19,7 @@ package com.android.settings.notification; import static com.android.settings.notification.SettingPref.TYPE_SECURE; import android.content.Context; +import android.os.Vibrator; import android.provider.Settings.Secure; import com.android.settings.SettingsPreferenceFragment; @@ -27,16 +28,18 @@ import com.android.settingslib.core.lifecycle.Lifecycle; public class VibrateIconPreferenceController extends SettingPrefController { private static final String KEY_VIBRATE_ICON = "vibrate_icon"; + private final boolean mHasVibrator; public VibrateIconPreferenceController(Context context, SettingsPreferenceFragment parent, Lifecycle lifecycle) { super(context, parent, lifecycle); + mHasVibrator = context.getSystemService(Vibrator.class).hasVibrator(); mPreference = new SettingPref( TYPE_SECURE, KEY_VIBRATE_ICON, Secure.STATUS_BAR_SHOW_VIBRATE_ICON, 0 /*default off*/); } @Override public boolean isAvailable() { - return true; + return mHasVibrator; } } diff --git a/src/com/android/settings/notification/app/NotificationsOffPreferenceController.java b/src/com/android/settings/notification/app/NotificationsOffPreferenceController.java index 0c7cd2361e2..04e3f0e311b 100644 --- a/src/com/android/settings/notification/app/NotificationsOffPreferenceController.java +++ b/src/com/android/settings/notification/app/NotificationsOffPreferenceController.java @@ -62,6 +62,8 @@ public class NotificationsOffPreferenceController extends NotificationPreference preference.setTitle(R.string.channel_notifications_off_desc); } else if (mChannelGroup != null) { preference.setTitle(R.string.channel_group_notifications_off_desc); + } else if (mAppRow.permissionStateLocked) { + preference.setTitle(R.string.app_notifications_not_send_desc); } else { preference.setTitle(R.string.app_notifications_off_desc); } diff --git a/src/com/android/settings/spa/app/appinfo/AppBatteryPreference.kt b/src/com/android/settings/spa/app/appinfo/AppBatteryPreference.kt new file mode 100644 index 00000000000..a2164b20f37 --- /dev/null +++ b/src/com/android/settings/spa/app/appinfo/AppBatteryPreference.kt @@ -0,0 +1,159 @@ +/* + * 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.pm.ApplicationInfo +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource +import androidx.core.os.bundleOf +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle +import com.android.settings.R +import com.android.settings.Utils +import com.android.settings.core.SubSettingLauncher +import com.android.settings.fuelgauge.AdvancedPowerUsageDetail +import com.android.settings.fuelgauge.batteryusage.BatteryChartPreferenceController +import com.android.settings.fuelgauge.batteryusage.BatteryDiffEntry +import com.android.settingslib.spa.widget.preference.Preference +import com.android.settingslib.spa.widget.preference.PreferenceModel +import com.android.settingslib.spaprivileged.model.app.installed +import com.android.settingslib.spaprivileged.model.app.userId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Composable +fun AppBatteryPreference(app: ApplicationInfo) { + val context = LocalContext.current + val presenter = remember { AppBatteryPresenter(context, app) } + if (!presenter.isAvailable()) return + + Preference(object : PreferenceModel { + override val title = stringResource(R.string.app_battery_usage_title) + override val summary = presenter.summary + override val enabled = presenter.enabled + override val onClick = presenter::startActivity + }) + + presenter.Updater() +} + +private class AppBatteryPresenter(private val context: Context, private val app: ApplicationInfo) { + private var batteryDiffEntryState: LoadingState + by mutableStateOf(LoadingState.Loading) + + @Composable + fun isAvailable() = remember { + context.resources.getBoolean(R.bool.config_show_app_info_settings_battery) + } + + @Composable + fun Updater() { + if (!app.installed) return + val current = LocalLifecycleOwner.current + LaunchedEffect(app) { + current.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { batteryDiffEntryState = LoadingState.Done(getBatteryDiffEntry()) } + } + } + } + + private suspend fun getBatteryDiffEntry(): BatteryDiffEntry? = withContext(Dispatchers.IO) { + BatteryChartPreferenceController.getAppBatteryUsageData( + context, app.packageName, app.userId + ).also { + Log.d(TAG, "loadBatteryDiffEntries():\n$it") + } + } + + val enabled = derivedStateOf { batteryDiffEntryState is LoadingState.Done } + + val summary = derivedStateOf { + if (!app.installed) return@derivedStateOf "" + batteryDiffEntryState.let { batteryDiffEntryState -> + when (batteryDiffEntryState) { + is LoadingState.Loading -> context.getString(R.string.summary_placeholder) + is LoadingState.Done -> batteryDiffEntryState.result.getSummary() + } + } + } + + private fun BatteryDiffEntry?.getSummary(): String = + this?.takeIf { mConsumePower > 0 }?.let { + context.getString( + R.string.battery_summary, Utils.formatPercentage(percentOfTotal, true) + ) + } ?: context.getString(R.string.no_battery_summary) + + fun startActivity() { + batteryDiffEntryState.resultOrNull?.run { + startBatteryDetailPage() + return + } + + fallbackStartBatteryDetailPage() + } + + private fun BatteryDiffEntry.startBatteryDetailPage() { + Log.i(TAG, "handlePreferenceTreeClick():\n$this") + AdvancedPowerUsageDetail.startBatteryDetailPage( + context, + SettingsEnums.APPLICATIONS_INSTALLED_APP_DETAILS, + this, + Utils.formatPercentage(percentOfTotal, true), + null, + ) + } + + private fun fallbackStartBatteryDetailPage() { + Log.i(TAG, "Launch : ${app.packageName} with package name") + val args = bundleOf( + AdvancedPowerUsageDetail.EXTRA_PACKAGE_NAME to app.packageName, + AdvancedPowerUsageDetail.EXTRA_POWER_USAGE_PERCENT to Utils.formatPercentage(0), + AdvancedPowerUsageDetail.EXTRA_UID to app.uid, + ) + SubSettingLauncher(context) + .setDestination(AdvancedPowerUsageDetail::class.java.name) + .setTitleRes(R.string.battery_details_title) + .setArguments(args) + .setSourceMetricsCategory(SettingsEnums.APPLICATIONS_INSTALLED_APP_DETAILS) + .launch() + } + + companion object { + private const val TAG = "AppBatteryPresenter" + } +} + +private sealed class LoadingState { + object Loading : LoadingState() + + data class Done(val result: T) : LoadingState() + + val resultOrNull: T? get() = if (this is Done) result else null +} diff --git a/src/com/android/settings/spa/app/appinfo/AppDataUsagePreference.kt b/src/com/android/settings/spa/app/appinfo/AppDataUsagePreference.kt new file mode 100644 index 00000000000..d13d108e408 --- /dev/null +++ b/src/com/android/settings/spa/app/appinfo/AppDataUsagePreference.kt @@ -0,0 +1,132 @@ +/* + * 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.pm.ApplicationInfo +import android.net.NetworkStats +import android.net.NetworkTemplate +import android.os.Process +import android.text.format.DateUtils +import android.text.format.Formatter +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +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.appinfo.AppInfoDashboardFragment +import com.android.settings.datausage.AppDataUsage +import com.android.settings.datausage.DataUsageUtils +import com.android.settingslib.net.NetworkCycleDataForUid +import com.android.settingslib.net.NetworkCycleDataForUidLoader +import com.android.settingslib.spa.framework.compose.collectAsStateWithLifecycle +import com.android.settingslib.spa.framework.compose.toState +import com.android.settingslib.spa.widget.preference.Preference +import com.android.settingslib.spa.widget.preference.PreferenceModel +import com.android.settingslib.spaprivileged.model.app.hasFlag +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withContext + +@Composable +fun AppDataUsagePreference(app: ApplicationInfo) { + val context = LocalContext.current + val presenter = remember { AppDataUsagePresenter(context, app) } + if (!presenter.isAvailableFlow.collectAsStateWithLifecycle(initialValue = false).value) return + + Preference(object : PreferenceModel { + override val title = stringResource(R.string.data_usage_app_summary_title) + override val summary = presenter.summaryFlow.collectAsStateWithLifecycle( + initialValue = stringResource(R.string.computing_size), + ) + override val enabled = presenter.isEnabled().toState() + override val onClick = presenter::startActivity + }) +} + +private class AppDataUsagePresenter( + private val context: Context, + private val app: ApplicationInfo, +) { + val isAvailableFlow = flow { emit(isAvailable()) } + + private suspend fun isAvailable(): Boolean = withContext(Dispatchers.IO) { + Utils.isBandwidthControlEnabled() + } + + fun isEnabled() = app.hasFlag(ApplicationInfo.FLAG_INSTALLED) + + val summaryFlow = flow { emit(getSummary()) } + + private suspend fun getSummary() = withContext(Dispatchers.IO) { + val appUsageData = getAppUsageData() + val totalBytes = appUsageData.sumOf { it.totalUsage } + if (totalBytes == 0L) { + context.getString(R.string.no_data_usage) + } else { + val startTime = appUsageData.minOfOrNull { it.startTime } ?: System.currentTimeMillis() + context.getString( + R.string.data_summary_format, + Formatter.formatFileSize(context, totalBytes, Formatter.FLAG_IEC_UNITS), + DateUtils.formatDateTime(context, startTime, DATE_FORMAT), + ) + } + } + + private suspend fun getAppUsageData(): List = + withContext(Dispatchers.IO) { + createLoader().loadInBackground() ?: emptyList() + } + + private fun createLoader(): NetworkCycleDataForUidLoader = + NetworkCycleDataForUidLoader.builder(context).apply { + setRetrieveDetail(false) + setNetworkTemplate(getTemplate()) + addUid(app.uid) + if (Process.isApplicationUid(app.uid)) { + // Also add in network usage for the app's SDK sandbox + addUid(Process.toSdkSandboxUid(app.uid)) + } + }.build() + + private fun getTemplate(): NetworkTemplate = when { + DataUsageUtils.hasReadyMobileRadio(context) -> { + NetworkTemplate.Builder(NetworkTemplate.MATCH_MOBILE) + .setMeteredness(NetworkStats.METERED_YES) + .build() + } + DataUsageUtils.hasWifiRadio(context) -> { + NetworkTemplate.Builder(NetworkTemplate.MATCH_WIFI).build() + } + else -> NetworkTemplate.Builder(NetworkTemplate.MATCH_ETHERNET).build() + } + + fun startActivity() { + AppInfoDashboardFragment.startAppInfoFragment( + AppDataUsage::class.java, + app, + context, + SettingsEnums.APPLICATIONS_INSTALLED_APP_DETAILS, + ) + } + + private companion object { + const val DATE_FORMAT = DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_ABBREV_MONTH + } +} diff --git a/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt b/src/com/android/settings/spa/app/appinfo/AppInfoSettings.kt index 2e3e45f4f8f..a1f3c1cb309 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,12 +93,15 @@ 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 - // TODO: data_settings + AppDataUsagePreference(app) AppTimeSpentPreference(app) - // TODO: battery + AppBatteryPreference(app) AppLocalePreference(app) AppOpenByDefaultPreference(app) DefaultAppShortcuts(app) @@ -113,7 +119,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/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/robotests/src/com/android/settings/notification/app/NotificationsOffPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/notification/app/NotificationsOffPreferenceControllerTest.java index 34e94a042f9..2eebd3a2eab 100644 --- a/tests/robotests/src/com/android/settings/notification/app/NotificationsOffPreferenceControllerTest.java +++ b/tests/robotests/src/com/android/settings/notification/app/NotificationsOffPreferenceControllerTest.java @@ -20,6 +20,7 @@ import static android.app.NotificationManager.IMPORTANCE_NONE; import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertEquals; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; @@ -32,6 +33,7 @@ import android.os.UserManager; import androidx.preference.Preference; +import com.android.settings.R; import com.android.settings.notification.NotificationBackend; import com.google.common.collect.ImmutableList; @@ -157,4 +159,23 @@ public class NotificationsOffPreferenceControllerTest { assertThat(pref.getTitle().toString()).contains("app"); assertThat(pref.isSelectable()).isFalse(); } + + @Test + public void testUpdateState_whenToggleDisabled() { + // Given: the app does not request to post notifications + // and it's preference toggle is disabled + NotificationBackend.AppRow appRow = new NotificationBackend.AppRow(); + appRow.banned = true; + appRow.permissionStateLocked = true; + mController.onResume(appRow, null, null, null, null, null, null); + Preference pref = new Preference(RuntimeEnvironment.application); + + // When: updateState(Preference preference) is called + mController.updateState(pref); + + // Then: title of pref should be app_notifications_not_send_desc + assertEquals( + RuntimeEnvironment.application.getString(R.string.app_notifications_not_send_desc), + pref.getTitle().toString()); + } } diff --git a/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppBatteryPreferenceTest.kt b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppBatteryPreferenceTest.kt new file mode 100644 index 00000000000..06574352f3f --- /dev/null +++ b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppBatteryPreferenceTest.kt @@ -0,0 +1,187 @@ +/* + * 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.pm.ApplicationInfo +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.hasTextExactly +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.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.dx.mockito.inline.extended.ExtendedMockito +import com.android.settings.R +import com.android.settings.fuelgauge.AdvancedPowerUsageDetail +import com.android.settings.fuelgauge.batteryusage.BatteryChartPreferenceController +import com.android.settings.fuelgauge.batteryusage.BatteryDiffEntry +import com.android.settingslib.spaprivileged.model.app.userId +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.mockito.MockitoSession +import org.mockito.Spy +import org.mockito.quality.Strictness +import org.mockito.Mockito.`when` as whenever + +@RunWith(AndroidJUnit4::class) +class AppBatteryPreferenceTest { + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var mockSession: MockitoSession + + @Spy + private val context: Context = ApplicationProvider.getApplicationContext() + + @Spy + private val resources = context.resources + + @Before + fun setUp() { + mockSession = ExtendedMockito.mockitoSession() + .initMocks(this) + .mockStatic(BatteryChartPreferenceController::class.java) + .mockStatic(AdvancedPowerUsageDetail::class.java) + .strictness(Strictness.LENIENT) + .startMocking() + whenever(context.resources).thenReturn(resources) + whenever(resources.getBoolean(R.bool.config_show_app_info_settings_battery)) + .thenReturn(true) + } + + private fun mockBatteryDiffEntry(batteryDiffEntry: BatteryDiffEntry?) { + whenever(BatteryChartPreferenceController.getAppBatteryUsageData( + context, PACKAGE_NAME, APP.userId + )).thenReturn(batteryDiffEntry) + } + + @After + fun tearDown() { + mockSession.finishMocking() + } + + @Test + fun whenConfigIsFalse_notDisplayed() { + whenever(resources.getBoolean(R.bool.config_show_app_info_settings_battery)) + .thenReturn(false) + + setContent() + + composeTestRule.onRoot().assertIsNotDisplayed() + } + + @Test + fun whenAppNotInstalled_noSummary() { + val notInstalledApp = ApplicationInfo() + + setContent(notInstalledApp) + + composeTestRule.onNode(hasTextExactly(context.getString(R.string.app_battery_usage_title))) + .assertIsDisplayed() + .assertIsNotEnabled() + } + + @Test + fun batteryDiffEntryIsNull() { + mockBatteryDiffEntry(null) + + setContent() + + composeTestRule.onNode( + hasTextExactly( + context.getString(R.string.app_battery_usage_title), + context.getString(R.string.no_battery_summary), + ), + ).assertIsDisplayed().assertIsEnabled() + } + + @Test + fun noConsumePower() { + val batteryDiffEntry = mock(BatteryDiffEntry::class.java).apply { + mConsumePower = 0.0 + } + mockBatteryDiffEntry(batteryDiffEntry) + + setContent() + + composeTestRule.onNodeWithText(context.getString(R.string.no_battery_summary)) + .assertIsDisplayed() + } + + @Test + fun hasConsumePower() { + val batteryDiffEntry = mock(BatteryDiffEntry::class.java).apply { + mConsumePower = 12.3 + } + whenever(batteryDiffEntry.percentOfTotal).thenReturn(45.6) + mockBatteryDiffEntry(batteryDiffEntry) + + setContent() + + composeTestRule.onNodeWithText("46% use since last full charge").assertIsDisplayed() + } + + @Test + fun whenClick_openDetailsPage() { + val batteryDiffEntry = mock(BatteryDiffEntry::class.java) + whenever(batteryDiffEntry.percentOfTotal).thenReturn(10.0) + mockBatteryDiffEntry(batteryDiffEntry) + + setContent() + composeTestRule.onRoot().performClick() + + ExtendedMockito.verify { + AdvancedPowerUsageDetail.startBatteryDetailPage( + context, + SettingsEnums.APPLICATIONS_INSTALLED_APP_DETAILS, + batteryDiffEntry, + "10%", + null, + ) + } + } + + private fun setContent(app: ApplicationInfo = APP) { + composeTestRule.setContent { + CompositionLocalProvider(LocalContext provides context) { + AppBatteryPreference(app) + } + } + } + + private companion object { + const val PACKAGE_NAME = "packageName" + const val UID = 123 + val APP = ApplicationInfo().apply { + packageName = PACKAGE_NAME + uid = UID + flags = ApplicationInfo.FLAG_INSTALLED + } + } +} diff --git a/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppDataUsagePreferenceTest.kt b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppDataUsagePreferenceTest.kt new file mode 100644 index 00000000000..22876d19464 --- /dev/null +++ b/tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppDataUsagePreferenceTest.kt @@ -0,0 +1,189 @@ +/* + * 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.pm.ApplicationInfo +import android.net.NetworkTemplate +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 +import com.android.dx.mockito.inline.extended.ExtendedMockito.mockitoSession +import com.android.settings.R +import com.android.settings.Utils +import com.android.settings.applications.appinfo.AppInfoDashboardFragment +import com.android.settings.datausage.AppDataUsage +import com.android.settings.testutils.waitUntilExists +import com.android.settingslib.net.NetworkCycleDataForUid +import com.android.settingslib.net.NetworkCycleDataForUidLoader +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.mock +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 AppDataUsagePreferenceTest { + @get:Rule + val composeTestRule = createComposeRule() + + private lateinit var mockSession: MockitoSession + + @Spy + private val context: Context = ApplicationProvider.getApplicationContext() + + @Mock + private lateinit var builder: NetworkCycleDataForUidLoader.Builder + + @Mock + private lateinit var loader: NetworkCycleDataForUidLoader + + @Before + fun setUp() { + mockSession = mockitoSession() + .initMocks(this) + .mockStatic(Utils::class.java) + .mockStatic(NetworkCycleDataForUidLoader::class.java) + .mockStatic(NetworkTemplate::class.java) + .mockStatic(AppInfoDashboardFragment::class.java) + .strictness(Strictness.LENIENT) + .startMocking() + whenever(Utils.isBandwidthControlEnabled()).thenReturn(true) + whenever(NetworkCycleDataForUidLoader.builder(context)).thenReturn(builder) + whenever(builder.build()).thenReturn(loader) + } + + @After + fun tearDown() { + mockSession.finishMocking() + } + + @Test + fun whenBandwidthControlDisabled_notDisplayed() { + whenever(Utils.isBandwidthControlEnabled()).thenReturn(false) + + setContent() + + composeTestRule.onRoot().assertIsNotDisplayed() + } + + @Test + fun whenAppNotInstalled_disabled() { + val notInstalledApp = ApplicationInfo() + + setContent(notInstalledApp) + + composeTestRule.onNodeWithText(context.getString(R.string.data_usage_app_summary_title)) + .assertIsDisplayed() + .assertIsNotEnabled() + } + + @Test + fun whenAppInstalled_enabled() { + setContent(APP) + + composeTestRule.onNodeWithText(context.getString(R.string.data_usage_app_summary_title)) + .assertIsDisplayed() + .assertIsEnabled() + } + + @Test + fun setCorrectValuesForBuilder() { + setContent() + + verify(builder).setRetrieveDetail(false) + verify(builder).addUid(UID) + } + + @Test + fun whenNoDataUsage() { + whenever(loader.loadInBackground()).thenReturn(emptyList()) + + setContent() + + composeTestRule.onRoot().printToLog("AAA") + composeTestRule.onNodeWithText(context.getString(R.string.no_data_usage)) + .assertIsDisplayed() + } + + @Test + fun whenHasDataUsage() { + val cycleData = mock(NetworkCycleDataForUid::class.java) + whenever(cycleData.totalUsage).thenReturn(123) + whenever(cycleData.startTime).thenReturn(1666666666666) + whenever(loader.loadInBackground()).thenReturn(listOf(cycleData)) + + setContent() + + composeTestRule.waitUntilExists(hasText("123 B used since Oct 25")) + } + + @Test + fun whenClick_startActivity() { + whenever(loader.loadInBackground()).thenReturn(emptyList()) + + setContent() + composeTestRule.onRoot().performClick() + + ExtendedMockito.verify { + AppInfoDashboardFragment.startAppInfoFragment( + AppDataUsage::class.java, + APP, + context, + SettingsEnums.APPLICATIONS_INSTALLED_APP_DETAILS, + ) + } + } + + private fun setContent(app: ApplicationInfo = APP) { + composeTestRule.setContent { + CompositionLocalProvider(LocalContext provides context) { + AppDataUsagePreference(app) + } + } + } + + private companion object { + const val PACKAGE_NAME = "packageName" + const val UID = 123 + val APP = ApplicationInfo().apply { + packageName = PACKAGE_NAME + uid = UID + flags = ApplicationInfo.FLAG_INSTALLED + } + } +} 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/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 + } + } + } +} 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() +}