blob: 72d84ef666357e90bcfe9527c4f233702c903375 [file] [log] [blame]
/*
* Copyright (C) 2017 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.fuelgauge;
import android.app.AppOpsManager;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.ApplicationInfo;
import android.content.pm.InstallSourceInfo;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.os.BatteryManager;
import android.os.BatteryStats;
import android.os.BatteryStatsManager;
import android.os.BatteryUsageStats;
import android.os.BatteryUsageStatsQuery;
import android.os.Build;
import android.os.Process;
import android.os.SystemClock;
import android.os.UidBatteryConsumer;
import android.os.UserHandle;
import android.provider.Settings;
import android.text.TextUtils;
import android.text.format.DateUtils;
import android.util.Base64;
import android.util.Log;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.annotation.WorkerThread;
import com.android.internal.util.ArrayUtils;
import com.android.settings.R;
import com.android.settings.fuelgauge.batterytip.AnomalyDatabaseHelper;
import com.android.settings.fuelgauge.batterytip.AnomalyInfo;
import com.android.settings.fuelgauge.batterytip.BatteryDatabaseManager;
import com.android.settings.fuelgauge.batterytip.StatsManagerConfig;
import com.android.settings.overlay.FeatureFactory;
import com.android.settingslib.applications.AppUtils;
import com.android.settingslib.fuelgauge.Estimate;
import com.android.settingslib.fuelgauge.EstimateKt;
import com.android.settingslib.fuelgauge.PowerAllowlistBackend;
import com.android.settingslib.utils.PowerUtil;
import com.android.settingslib.utils.StringUtil;
import com.android.settingslib.utils.ThreadUtils;
import com.google.protobuf.InvalidProtocolBufferException;
import com.google.protobuf.MessageLite;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
/**
* Utils for battery operation
*/
public class BatteryUtils {
public static final int UID_NULL = -1;
public static final int SDK_NULL = -1;
/** Special UID value for data usage by removed apps. */
public static final int UID_REMOVED_APPS = -4;
/** Special UID value for data usage by tethering. */
public static final int UID_TETHERING = -5;
/** Flag to check if the dock defender mode has been temporarily bypassed */
public static final String SETTINGS_GLOBAL_DOCK_DEFENDER_BYPASS = "dock_defender_bypass";
public static final String BYPASS_DOCK_DEFENDER_ACTION = "battery.dock.defender.bypass";
private static final String GOOGLE_PLAY_STORE_PACKAGE = "com.android.vending";
@Retention(RetentionPolicy.SOURCE)
@IntDef({StatusType.SCREEN_USAGE,
StatusType.FOREGROUND,
StatusType.BACKGROUND,
StatusType.ALL
})
public @interface StatusType {
int SCREEN_USAGE = 0;
int FOREGROUND = 1;
int BACKGROUND = 2;
int ALL = 3;
}
@Retention(RetentionPolicy.SOURCE)
@IntDef({DockDefenderMode.FUTURE_BYPASS,
DockDefenderMode.ACTIVE,
DockDefenderMode.TEMPORARILY_BYPASSED,
DockDefenderMode.DISABLED})
public @interface DockDefenderMode {
int FUTURE_BYPASS = 0;
int ACTIVE = 1;
int TEMPORARILY_BYPASSED = 2;
int DISABLED = 3;
}
private static final String TAG = "BatteryUtils";
private static BatteryUtils sInstance;
private PackageManager mPackageManager;
private AppOpsManager mAppOpsManager;
private Context mContext;
@VisibleForTesting
PowerUsageFeatureProvider mPowerUsageFeatureProvider;
public static BatteryUtils getInstance(Context context) {
if (sInstance == null || sInstance.isDataCorrupted()) {
sInstance = new BatteryUtils(context.getApplicationContext());
}
return sInstance;
}
@VisibleForTesting
public BatteryUtils(Context context) {
mContext = context;
mPackageManager = context.getPackageManager();
mAppOpsManager = (AppOpsManager) context.getSystemService(Context.APP_OPS_SERVICE);
mPowerUsageFeatureProvider = FeatureFactory.getFactory(context)
.getPowerUsageFeatureProvider(context);
}
public long getProcessTimeMs(@StatusType int type, @Nullable BatteryStats.Uid uid,
int which) {
if (uid == null) {
return 0;
}
switch (type) {
case StatusType.SCREEN_USAGE:
return getScreenUsageTimeMs(uid, which);
case StatusType.FOREGROUND:
return getProcessForegroundTimeMs(uid, which);
case StatusType.BACKGROUND:
return getProcessBackgroundTimeMs(uid, which);
case StatusType.ALL:
return getProcessForegroundTimeMs(uid, which)
+ getProcessBackgroundTimeMs(uid, which);
}
return 0;
}
private long getScreenUsageTimeMs(BatteryStats.Uid uid, int which, long rawRealTimeUs) {
final int foregroundTypes[] = {BatteryStats.Uid.PROCESS_STATE_TOP};
Log.v(TAG, "package: " + mPackageManager.getNameForUid(uid.getUid()));
long timeUs = 0;
for (int type : foregroundTypes) {
final long localTime = uid.getProcessStateTime(type, rawRealTimeUs, which);
Log.v(TAG, "type: " + type + " time(us): " + localTime);
timeUs += localTime;
}
Log.v(TAG, "foreground time(us): " + timeUs);
// Return the min value of STATE_TOP time and foreground activity time, since both of these
// time have some errors
return PowerUtil.convertUsToMs(
Math.min(timeUs, getForegroundActivityTotalTimeUs(uid, rawRealTimeUs)));
}
private long getScreenUsageTimeMs(BatteryStats.Uid uid, int which) {
final long rawRealTimeUs = PowerUtil.convertMsToUs(SystemClock.elapsedRealtime());
return getScreenUsageTimeMs(uid, which, rawRealTimeUs);
}
private long getProcessBackgroundTimeMs(BatteryStats.Uid uid, int which) {
final long rawRealTimeUs = PowerUtil.convertMsToUs(SystemClock.elapsedRealtime());
final long timeUs = uid.getProcessStateTime(
BatteryStats.Uid.PROCESS_STATE_BACKGROUND, rawRealTimeUs, which);
Log.v(TAG, "package: " + mPackageManager.getNameForUid(uid.getUid()));
Log.v(TAG, "background time(us): " + timeUs);
return PowerUtil.convertUsToMs(timeUs);
}
private long getProcessForegroundTimeMs(BatteryStats.Uid uid, int which) {
final long rawRealTimeUs = PowerUtil.convertMsToUs(SystemClock.elapsedRealtime());
return getScreenUsageTimeMs(uid, which, rawRealTimeUs)
+ PowerUtil.convertUsToMs(getForegroundServiceTotalTimeUs(uid, rawRealTimeUs));
}
/**
* Returns true if the specified battery consumer should be excluded from the summary
* battery consumption list.
*/
public boolean shouldHideUidBatteryConsumer(UidBatteryConsumer consumer) {
return shouldHideUidBatteryConsumer(consumer,
mPackageManager.getPackagesForUid(consumer.getUid()));
}
/**
* Returns true if the specified battery consumer should be excluded from the summary
* battery consumption list.
*/
public boolean shouldHideUidBatteryConsumer(UidBatteryConsumer consumer, String[] packages) {
return mPowerUsageFeatureProvider.isTypeSystem(consumer.getUid(), packages)
|| shouldHideUidBatteryConsumerUnconditionally(consumer, packages);
}
/**
* Returns true if the specified battery consumer should be excluded from
* battery consumption lists, either short or full.
*/
public boolean shouldHideUidBatteryConsumerUnconditionally(UidBatteryConsumer consumer,
String[] packages) {
final int uid = consumer.getUid();
return uid == UID_TETHERING
? false
: uid < 0 || isHiddenSystemModule(packages);
}
/**
* Returns true if one the specified packages belongs to a hidden system module.
*/
public boolean isHiddenSystemModule(String[] packages) {
if (packages != null) {
for (int i = 0, length = packages.length; i < length; i++) {
if (AppUtils.isHiddenSystemModule(mContext, packages[i])) {
return true;
}
}
}
return false;
}
/**
* Calculate the power usage percentage for an app
*
* @param powerUsageMah power used by the app
* @param totalPowerMah total power used in the system
* @param dischargeAmount The discharge amount calculated by {@link BatteryStats}
* @return A percentage value scaled by {@paramref dischargeAmount}
* @see BatteryStats#getDischargeAmount(int)
*/
public double calculateBatteryPercent(double powerUsageMah, double totalPowerMah,
int dischargeAmount) {
if (totalPowerMah == 0) {
return 0;
}
return (powerUsageMah / totalPowerMah) * dischargeAmount;
}
/**
* Find the package name for a {@link android.os.BatteryStats.Uid}
*
* @param uid id to get the package name
* @return the package name. If there are multiple packages related to
* given id, return the first one. Or return null if there are no known
* packages with the given id
* @see PackageManager#getPackagesForUid(int)
*/
public String getPackageName(int uid) {
final String[] packageNames = mPackageManager.getPackagesForUid(uid);
return ArrayUtils.isEmpty(packageNames) ? null : packageNames[0];
}
/**
* Find the targetSdkVersion for package with name {@code packageName}
*
* @return the targetSdkVersion, or {@link #SDK_NULL} if {@code packageName} doesn't exist
*/
public int getTargetSdkVersion(final String packageName) {
try {
ApplicationInfo info = mPackageManager.getApplicationInfo(packageName,
PackageManager.GET_META_DATA);
return info.targetSdkVersion;
} catch (PackageManager.NameNotFoundException e) {
Log.e(TAG, "Cannot find package: " + packageName, e);
}
return SDK_NULL;
}
/**
* Check whether background restriction is enabled
*/
public boolean isBackgroundRestrictionEnabled(final int targetSdkVersion, final int uid,
final String packageName) {
if (targetSdkVersion >= Build.VERSION_CODES.O) {
return true;
}
final int mode = mAppOpsManager
.checkOpNoThrow(AppOpsManager.OP_RUN_IN_BACKGROUND, uid, packageName);
return mode == AppOpsManager.MODE_IGNORED || mode == AppOpsManager.MODE_ERRORED;
}
/**
* Calculate the time since last full charge, including the device off time
*
* @param batteryUsageStats class that contains the data
* @param currentTimeMs current wall time
* @return time in millis
*/
public long calculateLastFullChargeTime(BatteryUsageStats batteryUsageStats,
long currentTimeMs) {
return currentTimeMs - batteryUsageStats.getStatsStartTimestamp();
}
public static void logRuntime(String tag, String message, long startTime) {
Log.d(tag, message + ": " + (System.currentTimeMillis() - startTime) + "ms");
}
/**
* Return {@code true} if battery is overheated and charging.
*/
public static boolean isBatteryDefenderOn(BatteryInfo batteryInfo) {
return batteryInfo.isOverheated && !batteryInfo.discharging;
}
/**
* Find package uid from package name
*
* @param packageName used to find the uid
* @return uid for packageName, or {@link #UID_NULL} if exception happens or
* {@code packageName} is null
*/
public int getPackageUid(String packageName) {
try {
return packageName == null ? UID_NULL : mPackageManager.getPackageUid(packageName,
PackageManager.GET_META_DATA);
} catch (PackageManager.NameNotFoundException e) {
return UID_NULL;
}
}
/**
* Parses proto object from string.
*
* @param serializedProto the serialized proto string
* @param protoClass class of the proto
* @return instance of the proto class parsed from the string
*/
@SuppressWarnings("unchecked")
public static <T extends MessageLite> T parseProtoFromString(
String serializedProto, T protoClass) {
if (serializedProto.isEmpty()) {
return (T) protoClass.getDefaultInstanceForType();
}
try {
return (T) protoClass.getParserForType()
.parseFrom(Base64.decode(serializedProto, Base64.DEFAULT));
} catch (InvalidProtocolBufferException e) {
Log.e(TAG, "Failed to deserialize proto class", e);
return (T) protoClass.getDefaultInstanceForType();
}
}
public void setForceAppStandby(int uid, String packageName,
int mode) {
final boolean isPreOApp = isPreOApp(packageName);
if (isPreOApp) {
// Control whether app could run in the background if it is pre O app
mAppOpsManager.setMode(AppOpsManager.OP_RUN_IN_BACKGROUND, uid, packageName, mode);
}
// Control whether app could run jobs in the background
mAppOpsManager.setMode(AppOpsManager.OP_RUN_ANY_IN_BACKGROUND, uid, packageName, mode);
ThreadUtils.postOnBackgroundThread(() -> {
final BatteryDatabaseManager batteryDatabaseManager = BatteryDatabaseManager
.getInstance(mContext);
if (mode == AppOpsManager.MODE_IGNORED) {
batteryDatabaseManager.insertAction(AnomalyDatabaseHelper.ActionType.RESTRICTION,
uid, packageName, System.currentTimeMillis());
} else if (mode == AppOpsManager.MODE_ALLOWED) {
batteryDatabaseManager.deleteAction(AnomalyDatabaseHelper.ActionType.RESTRICTION,
uid, packageName);
}
});
}
public boolean isForceAppStandbyEnabled(int uid, String packageName) {
return mAppOpsManager.checkOpNoThrow(AppOpsManager.OP_RUN_ANY_IN_BACKGROUND, uid,
packageName) == AppOpsManager.MODE_IGNORED;
}
public boolean clearForceAppStandby(String packageName) {
final int uid = getPackageUid(packageName);
if (uid != UID_NULL && isForceAppStandbyEnabled(uid, packageName)) {
setForceAppStandby(uid, packageName, AppOpsManager.MODE_ALLOWED);
return true;
} else {
return false;
}
}
@WorkerThread
public BatteryInfo getBatteryInfo(final String tag) {
final BatteryStatsManager systemService = mContext.getSystemService(
BatteryStatsManager.class);
BatteryUsageStats batteryUsageStats;
try {
batteryUsageStats = systemService.getBatteryUsageStats(
new BatteryUsageStatsQuery.Builder().includeBatteryHistory().build());
} catch (RuntimeException e) {
Log.e(TAG, "getBatteryInfo() error from getBatteryUsageStats()", e);
// Use default BatteryUsageStats.
batteryUsageStats = new BatteryUsageStats.Builder(new String[0]).build();
}
final long startTime = System.currentTimeMillis();
// Stuff we always need to get BatteryInfo
final Intent batteryBroadcast = getBatteryIntent(mContext);
final long elapsedRealtimeUs = PowerUtil.convertMsToUs(
SystemClock.elapsedRealtime());
BatteryInfo batteryInfo;
Estimate estimate = getEnhancedEstimate();
// couldn't get estimate from cache or provider, use fallback
if (estimate == null) {
estimate = new Estimate(
batteryUsageStats.getBatteryTimeRemainingMs(),
false /* isBasedOnUsage */,
EstimateKt.AVERAGE_TIME_TO_DISCHARGE_UNKNOWN);
}
BatteryUtils.logRuntime(tag, "BatteryInfoLoader post query", startTime);
batteryInfo = BatteryInfo.getBatteryInfo(mContext, batteryBroadcast,
batteryUsageStats, estimate, elapsedRealtimeUs, false /* shortString */);
BatteryUtils.logRuntime(tag, "BatteryInfoLoader.loadInBackground", startTime);
try {
batteryUsageStats.close();
} catch (Exception e) {
Log.e(TAG, "BatteryUsageStats.close() failed", e);
}
return batteryInfo;
}
@VisibleForTesting
Estimate getEnhancedEstimate() {
Estimate estimate = null;
// Get enhanced prediction if available
if (Duration.between(Estimate.getLastCacheUpdateTime(mContext), Instant.now())
.compareTo(Duration.ofSeconds(10)) < 0) {
estimate = Estimate.getCachedEstimateIfAvailable(mContext);
} else if (mPowerUsageFeatureProvider != null &&
mPowerUsageFeatureProvider.isEnhancedBatteryPredictionEnabled(mContext)) {
estimate = mPowerUsageFeatureProvider.getEnhancedBatteryPrediction(mContext);
if (estimate != null) {
Estimate.storeCachedEstimate(mContext, estimate);
}
}
return estimate;
}
private boolean isDataCorrupted() {
return mPackageManager == null || mAppOpsManager == null;
}
@VisibleForTesting
long getForegroundActivityTotalTimeUs(BatteryStats.Uid uid, long rawRealtimeUs) {
final BatteryStats.Timer timer = uid.getForegroundActivityTimer();
if (timer != null) {
return timer.getTotalTimeLocked(rawRealtimeUs, BatteryStats.STATS_SINCE_CHARGED);
}
return 0;
}
@VisibleForTesting
long getForegroundServiceTotalTimeUs(BatteryStats.Uid uid, long rawRealtimeUs) {
final BatteryStats.Timer timer = uid.getForegroundServiceTimer();
if (timer != null) {
return timer.getTotalTimeLocked(rawRealtimeUs, BatteryStats.STATS_SINCE_CHARGED);
}
return 0;
}
public boolean isPreOApp(final String packageName) {
try {
ApplicationInfo info = mPackageManager.getApplicationInfo(packageName,
PackageManager.GET_META_DATA);
return info.targetSdkVersion < Build.VERSION_CODES.O;
} catch (PackageManager.NameNotFoundException e) {
Log.e(TAG, "Cannot find package: " + packageName, e);
}
return false;
}
public boolean isPreOApp(final String[] packageNames) {
if (ArrayUtils.isEmpty(packageNames)) {
return false;
}
for (String packageName : packageNames) {
if (isPreOApp(packageName)) {
return true;
}
}
return false;
}
/**
* Return {@code true} if we should hide anomaly app represented by {@code uid}
*/
public boolean shouldHideAnomaly(PowerAllowlistBackend powerAllowlistBackend, int uid,
AnomalyInfo anomalyInfo) {
final String[] packageNames = mPackageManager.getPackagesForUid(uid);
if (ArrayUtils.isEmpty(packageNames)) {
// Don't show it if app has been uninstalled
return true;
}
return isSystemUid(uid) || powerAllowlistBackend.isAllowlisted(packageNames, uid)
|| (isSystemApp(mPackageManager, packageNames) && !hasLauncherEntry(packageNames))
|| (isExcessiveBackgroundAnomaly(anomalyInfo) && !isPreOApp(packageNames));
}
private boolean isExcessiveBackgroundAnomaly(AnomalyInfo anomalyInfo) {
return anomalyInfo.anomalyType
== StatsManagerConfig.AnomalyType.EXCESSIVE_BACKGROUND_SERVICE;
}
private boolean isSystemUid(int uid) {
final int appUid = UserHandle.getAppId(uid);
return appUid >= Process.ROOT_UID && appUid < Process.FIRST_APPLICATION_UID;
}
private boolean isSystemApp(PackageManager packageManager, String[] packageNames) {
for (String packageName : packageNames) {
try {
final ApplicationInfo info = packageManager.getApplicationInfo(packageName,
0 /* flags */);
if ((info.flags & ApplicationInfo.FLAG_SYSTEM) != 0) {
return true;
}
} catch (PackageManager.NameNotFoundException e) {
Log.e(TAG, "Package not found: " + packageName, e);
}
}
return false;
}
private boolean hasLauncherEntry(String[] packageNames) {
final Intent launchIntent = new Intent(Intent.ACTION_MAIN, null);
launchIntent.addCategory(Intent.CATEGORY_LAUNCHER);
// If we do not specify MATCH_DIRECT_BOOT_AWARE or
// MATCH_DIRECT_BOOT_UNAWARE, system will derive and update the flags
// according to the user's lock state. When the user is locked,
// components
// with ComponentInfo#directBootAware == false will be filtered. We should
// explicitly include both direct boot aware and unaware components here.
final List<ResolveInfo> resolveInfos = mPackageManager.queryIntentActivities(launchIntent,
PackageManager.MATCH_DISABLED_COMPONENTS
| PackageManager.MATCH_DIRECT_BOOT_AWARE
| PackageManager.MATCH_DIRECT_BOOT_UNAWARE
| PackageManager.MATCH_SYSTEM_ONLY);
for (int i = 0, size = resolveInfos.size(); i < size; i++) {
final ResolveInfo resolveInfo = resolveInfos.get(i);
if (ArrayUtils.contains(packageNames, resolveInfo.activityInfo.packageName)) {
return true;
}
}
return false;
}
/**
* Return version number of an app represented by {@code packageName}, and return -1 if not
* found.
*/
public long getAppLongVersionCode(String packageName) {
try {
final PackageInfo packageInfo = mPackageManager.getPackageInfo(packageName,
0 /* flags */);
return packageInfo.getLongVersionCode();
} catch (PackageManager.NameNotFoundException e) {
Log.e(TAG, "Cannot find package: " + packageName, e);
}
return -1L;
}
/** Whether the package is installed from Google Play Store or not */
public static boolean isAppInstalledFromGooglePlayStore(Context context, String packageName) {
if (TextUtils.isEmpty(packageName)) {
return false;
}
InstallSourceInfo installSourceInfo;
try {
installSourceInfo = context.getPackageManager().getInstallSourceInfo(packageName);
} catch (PackageManager.NameNotFoundException e) {
return false;
}
return installSourceInfo != null
&& GOOGLE_PLAY_STORE_PACKAGE.equals(installSourceInfo.getInitiatingPackageName());
}
/** Gets the latest sticky battery intent from the Android system. */
public static Intent getBatteryIntent(Context context) {
return com.android.settingslib.fuelgauge.BatteryUtils.getBatteryIntent(context);
}
/** Gets the current dock defender mode */
public static int getCurrentDockDefenderMode(Context context, BatteryInfo batteryInfo) {
if (batteryInfo.pluggedStatus == BatteryManager.BATTERY_PLUGGED_DOCK) {
if (Settings.Global.getInt(context.getContentResolver(),
SETTINGS_GLOBAL_DOCK_DEFENDER_BYPASS, 0) == 1) {
return DockDefenderMode.TEMPORARILY_BYPASSED;
} else if (batteryInfo.isOverheated && FeatureFactory.getFactory(context)
.getPowerUsageFeatureProvider(context)
.isExtraDefend()) {
return DockDefenderMode.ACTIVE;
} else if (!batteryInfo.isOverheated) {
return DockDefenderMode.FUTURE_BYPASS;
}
}
return DockDefenderMode.DISABLED;
}
/** Formats elapsed time without commas in between. */
public static CharSequence formatElapsedTimeWithoutComma(
Context context, double millis, boolean withSeconds, boolean collapseTimeUnit) {
return StringUtil.formatElapsedTime(context, millis, withSeconds, collapseTimeUnit)
.toString().replaceAll(",", "");
}
/** Builds the battery usage time summary. */
public static String buildBatteryUsageTimeSummary(final Context context, final boolean isSystem,
final long foregroundUsageTimeInMs, final long backgroundUsageTimeInMs,
final long screenOnTimeInMs) {
StringBuilder summary = new StringBuilder();
if (isSystem) {
final long totalUsageTimeInMs = foregroundUsageTimeInMs + backgroundUsageTimeInMs;
if (totalUsageTimeInMs != 0) {
summary.append(buildBatteryUsageTimeInfo(context, totalUsageTimeInMs,
R.string.battery_usage_total_less_than_one_minute,
R.string.battery_usage_for_total_time));
}
} else {
if (screenOnTimeInMs != 0) {
summary.append(buildBatteryUsageTimeInfo(context, screenOnTimeInMs,
R.string.battery_usage_screen_time_less_than_one_minute,
R.string.battery_usage_screen_time));
}
if (screenOnTimeInMs != 0 && backgroundUsageTimeInMs != 0) {
summary.append('\n');
}
if (backgroundUsageTimeInMs != 0) {
summary.append(buildBatteryUsageTimeInfo(context, backgroundUsageTimeInMs,
R.string.battery_usage_background_less_than_one_minute,
R.string.battery_usage_for_background_time));
}
}
return summary.toString();
}
/** Builds the battery usage time information for one timestamp. */
private static String buildBatteryUsageTimeInfo(final Context context, long timeInMs,
final int lessThanOneMinuteResId, final int normalResId) {
if (timeInMs < DateUtils.MINUTE_IN_MILLIS) {
return context.getString(lessThanOneMinuteResId);
}
final CharSequence timeSequence = formatElapsedTimeWithoutComma(
context, (double) timeInMs, /*withSeconds=*/ false, /*collapseTimeUnit=*/ false);
return context.getString(normalResId, timeSequence);
}
}