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