Merge "Increase app updater balances."
This commit is contained in:
committed by
Android (Google) Code Review
commit
d274a8df5d
@@ -227,6 +227,9 @@ public class EconomyManager {
|
||||
public static final String KEY_JS_MIN_SATIATED_BALANCE_OTHER_APP =
|
||||
"js_min_satiated_balance_other_app";
|
||||
/** @hide */
|
||||
public static final String KEY_JS_MIN_SATIATED_BALANCE_INCREMENT_APP_UPDATER =
|
||||
"js_min_satiated_balance_increment_updater";
|
||||
/** @hide */
|
||||
public static final String KEY_JS_MAX_SATIATED_BALANCE =
|
||||
"js_max_satiated_balance";
|
||||
/** @hide */
|
||||
@@ -509,6 +512,15 @@ public class EconomyManager {
|
||||
public static final long DEFAULT_JS_REWARD_OTHER_USER_INTERACTION_ONGOING_CAKES = arcToCake(0);
|
||||
/** @hide */
|
||||
public static final long DEFAULT_JS_REWARD_OTHER_USER_INTERACTION_MAX_CAKES = arcToCake(5000);
|
||||
/**
|
||||
* How many credits to increase the updating app's min satiated balance by for each app that it
|
||||
* is responsible for updating.
|
||||
* @hide
|
||||
*/
|
||||
public static final long DEFAULT_JS_MIN_SATIATED_BALANCE_INCREMENT_APP_UPDATER_CAKES =
|
||||
// Research indicates that the median time between popular app updates is 13-14 days,
|
||||
// so adjust by 14 to amortize over that time.
|
||||
DEFAULT_JS_REWARD_APP_INSTALL_INSTANT_CAKES / 14;
|
||||
/** @hide */
|
||||
public static final long DEFAULT_JS_ACTION_JOB_MAX_START_CTP_CAKES = arcToCake(3);
|
||||
/** @hide */
|
||||
|
||||
@@ -81,6 +81,7 @@ import java.io.FileDescriptor;
|
||||
import java.io.PrintWriter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CopyOnWriteArraySet;
|
||||
|
||||
/**
|
||||
@@ -164,6 +165,10 @@ public class InternalResourceService extends SystemService {
|
||||
@GuardedBy("mLock")
|
||||
private final SparseArrayMap<String, Boolean> mVipOverrides = new SparseArrayMap<>();
|
||||
|
||||
/** Set of apps each installer is responsible for installing. */
|
||||
@GuardedBy("mLock")
|
||||
private final SparseArrayMap<String, ArraySet<String>> mInstallers = new SparseArrayMap<>();
|
||||
|
||||
private volatile boolean mHasBattery = true;
|
||||
private volatile boolean mIsEnabled;
|
||||
private volatile int mBootPhase;
|
||||
@@ -353,6 +358,14 @@ public class InternalResourceService extends SystemService {
|
||||
return mCompleteEconomicPolicy;
|
||||
}
|
||||
|
||||
/** Returns the number of apps that this app is expected to update at some point. */
|
||||
int getAppUpdateResponsibilityCount(final int userId, @NonNull final String pkgName) {
|
||||
synchronized (mLock) {
|
||||
// TODO(248274798): return 0 if the app has lost the install permission
|
||||
return ArrayUtils.size(mInstallers.get(userId, pkgName));
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
SparseArrayMap<String, InstalledPackageInfo> getInstalledPackages() {
|
||||
synchronized (mLock) {
|
||||
@@ -525,7 +538,8 @@ public class InternalResourceService extends SystemService {
|
||||
}
|
||||
synchronized (mLock) {
|
||||
final InstalledPackageInfo ipo = new InstalledPackageInfo(packageInfo);
|
||||
mPkgCache.add(userId, pkgName, ipo);
|
||||
final InstalledPackageInfo oldIpo = mPkgCache.add(userId, pkgName, ipo);
|
||||
maybeUpdateInstallerStatusLocked(oldIpo, ipo);
|
||||
mUidToPackageCache.add(uid, pkgName);
|
||||
// TODO: only do this when the user first launches the app (app leaves stopped state)
|
||||
mAgent.grantBirthrightLocked(userId, pkgName);
|
||||
@@ -552,7 +566,14 @@ public class InternalResourceService extends SystemService {
|
||||
synchronized (mLock) {
|
||||
mUidToPackageCache.remove(uid, pkgName);
|
||||
mVipOverrides.delete(userId, pkgName);
|
||||
mPkgCache.delete(userId, pkgName);
|
||||
final InstalledPackageInfo ipo = mPkgCache.delete(userId, pkgName);
|
||||
mInstallers.delete(userId, pkgName);
|
||||
if (ipo != null && ipo.installerPackageName != null) {
|
||||
final ArraySet<String> list = mInstallers.get(userId, ipo.installerPackageName);
|
||||
if (list != null) {
|
||||
list.remove(pkgName);
|
||||
}
|
||||
}
|
||||
mAgent.onPackageRemovedLocked(userId, pkgName);
|
||||
}
|
||||
}
|
||||
@@ -574,7 +595,8 @@ public class InternalResourceService extends SystemService {
|
||||
mPackageManager.getInstalledPackagesAsUser(PACKAGE_QUERY_FLAGS, userId);
|
||||
for (int i = pkgs.size() - 1; i >= 0; --i) {
|
||||
final InstalledPackageInfo ipo = new InstalledPackageInfo(pkgs.get(i));
|
||||
mPkgCache.add(userId, ipo.packageName, ipo);
|
||||
final InstalledPackageInfo oldIpo = mPkgCache.add(userId, ipo.packageName, ipo);
|
||||
maybeUpdateInstallerStatusLocked(oldIpo, ipo);
|
||||
}
|
||||
mAgent.grantBirthrightsLocked(userId);
|
||||
}
|
||||
@@ -590,6 +612,7 @@ public class InternalResourceService extends SystemService {
|
||||
mUidToPackageCache.remove(pkgInfo.uid);
|
||||
}
|
||||
}
|
||||
mInstallers.delete(userId);
|
||||
mPkgCache.delete(userId);
|
||||
mAgent.onUserRemovedLocked(userId);
|
||||
}
|
||||
@@ -746,11 +769,49 @@ public class InternalResourceService extends SystemService {
|
||||
mPackageManager.getInstalledPackagesAsUser(PACKAGE_QUERY_FLAGS, userId);
|
||||
for (int i = pkgs.size() - 1; i >= 0; --i) {
|
||||
final InstalledPackageInfo ipo = new InstalledPackageInfo(pkgs.get(i));
|
||||
mPkgCache.add(userId, ipo.packageName, ipo);
|
||||
final InstalledPackageInfo oldIpo = mPkgCache.add(userId, ipo.packageName, ipo);
|
||||
maybeUpdateInstallerStatusLocked(oldIpo, ipo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to update the set of installed apps for each installer. This only has an effect if the
|
||||
* installer package name is different between {@code oldIpo} and {@code newIpo}.
|
||||
*/
|
||||
@GuardedBy("mLock")
|
||||
private void maybeUpdateInstallerStatusLocked(@Nullable InstalledPackageInfo oldIpo,
|
||||
@NonNull InstalledPackageInfo newIpo) {
|
||||
final boolean changed;
|
||||
if (oldIpo == null) {
|
||||
changed = newIpo.installerPackageName != null;
|
||||
} else {
|
||||
changed = !Objects.equals(oldIpo.installerPackageName, newIpo.installerPackageName);
|
||||
}
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
// InstallSourceInfo doesn't track userId, so for now, assume the installer on the package's
|
||||
// user profile did the installation.
|
||||
// TODO(246640162): use the actual installer's user ID
|
||||
final int userId = UserHandle.getUserId(newIpo.uid);
|
||||
final String pkgName = newIpo.packageName;
|
||||
if (oldIpo != null) {
|
||||
final ArraySet<String> oldList = mInstallers.get(userId, oldIpo.installerPackageName);
|
||||
if (oldList != null) {
|
||||
oldList.remove(pkgName);
|
||||
}
|
||||
}
|
||||
if (newIpo.installerPackageName != null) {
|
||||
ArraySet<String> newList = mInstallers.get(userId, newIpo.installerPackageName);
|
||||
if (newList == null) {
|
||||
newList = new ArraySet<>();
|
||||
mInstallers.add(userId, newIpo.installerPackageName, newList);
|
||||
}
|
||||
newList.add(pkgName);
|
||||
}
|
||||
}
|
||||
|
||||
private void registerListeners() {
|
||||
final IntentFilter filter = new IntentFilter();
|
||||
filter.addAction(Intent.ACTION_BATTERY_CHANGED);
|
||||
@@ -1359,6 +1420,23 @@ public class InternalResourceService extends SystemService {
|
||||
}
|
||||
pw.println();
|
||||
|
||||
pw.println();
|
||||
pw.println("Installers:");
|
||||
pw.increaseIndent();
|
||||
for (int u = 0; u < mInstallers.numMaps(); ++u) {
|
||||
final int userId = mInstallers.keyAt(u);
|
||||
|
||||
for (int p = 0; p < mInstallers.numElementsForKeyAt(u); ++p) {
|
||||
final String pkgName = mInstallers.keyAt(u, p);
|
||||
|
||||
pw.print(appToString(userId, pkgName));
|
||||
pw.print(": ");
|
||||
pw.print(mInstallers.valueAt(u, p).size());
|
||||
pw.println(" apps");
|
||||
}
|
||||
}
|
||||
pw.decreaseIndent();
|
||||
|
||||
pw.println();
|
||||
mCompleteEconomicPolicy.dump(pw);
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import static android.app.tare.EconomyManager.DEFAULT_JS_HARD_CONSUMPTION_LIMIT_
|
||||
import static android.app.tare.EconomyManager.DEFAULT_JS_INITIAL_CONSUMPTION_LIMIT_CAKES;
|
||||
import static android.app.tare.EconomyManager.DEFAULT_JS_MAX_SATIATED_BALANCE_CAKES;
|
||||
import static android.app.tare.EconomyManager.DEFAULT_JS_MIN_SATIATED_BALANCE_EXEMPTED_CAKES;
|
||||
import static android.app.tare.EconomyManager.DEFAULT_JS_MIN_SATIATED_BALANCE_INCREMENT_APP_UPDATER_CAKES;
|
||||
import static android.app.tare.EconomyManager.DEFAULT_JS_MIN_SATIATED_BALANCE_OTHER_APP_CAKES;
|
||||
import static android.app.tare.EconomyManager.DEFAULT_JS_REWARD_APP_INSTALL_INSTANT_CAKES;
|
||||
import static android.app.tare.EconomyManager.DEFAULT_JS_REWARD_APP_INSTALL_MAX_CAKES;
|
||||
@@ -87,6 +88,7 @@ import static android.app.tare.EconomyManager.KEY_JS_HARD_CONSUMPTION_LIMIT;
|
||||
import static android.app.tare.EconomyManager.KEY_JS_INITIAL_CONSUMPTION_LIMIT;
|
||||
import static android.app.tare.EconomyManager.KEY_JS_MAX_SATIATED_BALANCE;
|
||||
import static android.app.tare.EconomyManager.KEY_JS_MIN_SATIATED_BALANCE_EXEMPTED;
|
||||
import static android.app.tare.EconomyManager.KEY_JS_MIN_SATIATED_BALANCE_INCREMENT_APP_UPDATER;
|
||||
import static android.app.tare.EconomyManager.KEY_JS_MIN_SATIATED_BALANCE_OTHER_APP;
|
||||
import static android.app.tare.EconomyManager.KEY_JS_REWARD_APP_INSTALL_INSTANT;
|
||||
import static android.app.tare.EconomyManager.KEY_JS_REWARD_APP_INSTALL_MAX;
|
||||
@@ -154,6 +156,7 @@ public class JobSchedulerEconomicPolicy extends EconomicPolicy {
|
||||
|
||||
private long mMinSatiatedBalanceExempted;
|
||||
private long mMinSatiatedBalanceOther;
|
||||
private long mMinSatiatedBalanceIncrementalAppUpdater;
|
||||
private long mMaxSatiatedBalance;
|
||||
private long mInitialSatiatedConsumptionLimit;
|
||||
private long mHardSatiatedConsumptionLimit;
|
||||
@@ -183,11 +186,20 @@ public class JobSchedulerEconomicPolicy extends EconomicPolicy {
|
||||
if (mIrs.isPackageRestricted(userId, pkgName)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
final long baseBalance;
|
||||
if (mIrs.isPackageExempted(userId, pkgName)) {
|
||||
return mMinSatiatedBalanceExempted;
|
||||
baseBalance = mMinSatiatedBalanceExempted;
|
||||
} else {
|
||||
baseBalance = mMinSatiatedBalanceOther;
|
||||
}
|
||||
// TODO: take other exemptions into account
|
||||
return mMinSatiatedBalanceOther;
|
||||
|
||||
long minBalance = baseBalance;
|
||||
|
||||
final int updateResponsibilityCount = mIrs.getAppUpdateResponsibilityCount(userId, pkgName);
|
||||
minBalance += updateResponsibilityCount * mMinSatiatedBalanceIncrementalAppUpdater;
|
||||
|
||||
return Math.min(minBalance, mMaxSatiatedBalance);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -242,6 +254,9 @@ public class JobSchedulerEconomicPolicy extends EconomicPolicy {
|
||||
mMinSatiatedBalanceExempted = getConstantAsCake(mParser, properties,
|
||||
KEY_JS_MIN_SATIATED_BALANCE_EXEMPTED, DEFAULT_JS_MIN_SATIATED_BALANCE_EXEMPTED_CAKES,
|
||||
mMinSatiatedBalanceOther);
|
||||
mMinSatiatedBalanceIncrementalAppUpdater = getConstantAsCake(mParser, properties,
|
||||
KEY_JS_MIN_SATIATED_BALANCE_INCREMENT_APP_UPDATER,
|
||||
DEFAULT_JS_MIN_SATIATED_BALANCE_INCREMENT_APP_UPDATER_CAKES);
|
||||
mMaxSatiatedBalance = getConstantAsCake(mParser, properties,
|
||||
KEY_JS_MAX_SATIATED_BALANCE, DEFAULT_JS_MAX_SATIATED_BALANCE_CAKES,
|
||||
Math.max(arcToCake(1), mMinSatiatedBalanceExempted));
|
||||
@@ -397,10 +412,11 @@ public class JobSchedulerEconomicPolicy extends EconomicPolicy {
|
||||
|
||||
@Override
|
||||
void dump(IndentingPrintWriter pw) {
|
||||
pw.println("Min satiated balances:");
|
||||
pw.println("Min satiated balance:");
|
||||
pw.increaseIndent();
|
||||
pw.print("Exempted", cakeToString(mMinSatiatedBalanceExempted)).println();
|
||||
pw.print("Other", cakeToString(mMinSatiatedBalanceOther)).println();
|
||||
pw.print("+App Updater", cakeToString(mMinSatiatedBalanceIncrementalAppUpdater)).println();
|
||||
pw.decreaseIndent();
|
||||
pw.print("Max satiated balance", cakeToString(mMaxSatiatedBalance)).println();
|
||||
pw.print("Consumption limits: [");
|
||||
|
||||
@@ -286,7 +286,7 @@ class Ledger {
|
||||
final int idx = (mRewardBucketIndex - b + NUM_REWARD_BUCKET_WINDOWS)
|
||||
% NUM_REWARD_BUCKET_WINDOWS;
|
||||
final RewardBucket rewardBucket = mRewardBuckets[idx];
|
||||
if (rewardBucket == null) {
|
||||
if (rewardBucket == null || rewardBucket.startTimeMs == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -134,15 +134,36 @@ public class JobSchedulerEconomicPolicyTest {
|
||||
mEconomicPolicy.getInitialSatiatedConsumptionLimit());
|
||||
assertEquals(EconomyManager.DEFAULT_JS_HARD_CONSUMPTION_LIMIT_CAKES,
|
||||
mEconomicPolicy.getHardSatiatedConsumptionLimit());
|
||||
|
||||
final String pkgRestricted = "com.pkg.restricted";
|
||||
when(mIrs.isPackageRestricted(anyInt(), eq(pkgRestricted))).thenReturn(true);
|
||||
assertEquals(0, mEconomicPolicy.getMinSatiatedBalance(0, pkgRestricted));
|
||||
assertEquals(0, mEconomicPolicy.getMaxSatiatedBalance(0, pkgRestricted));
|
||||
assertEquals(EconomyManager.DEFAULT_JS_MAX_SATIATED_BALANCE_CAKES,
|
||||
mEconomicPolicy.getMaxSatiatedBalance(0, "com.any.other.app"));
|
||||
|
||||
final String pkgExempted = "com.pkg.exempted";
|
||||
when(mIrs.isPackageExempted(anyInt(), eq(pkgExempted))).thenReturn(true);
|
||||
assertEquals(EconomyManager.DEFAULT_JS_MIN_SATIATED_BALANCE_EXEMPTED_CAKES,
|
||||
mEconomicPolicy.getMinSatiatedBalance(0, pkgExempted));
|
||||
assertEquals(EconomyManager.DEFAULT_JS_MAX_SATIATED_BALANCE_CAKES,
|
||||
mEconomicPolicy.getMaxSatiatedBalance(0, pkgExempted));
|
||||
|
||||
final String pkgUpdater = "com.pkg.updater";
|
||||
when(mIrs.getAppUpdateResponsibilityCount(anyInt(), eq(pkgUpdater))).thenReturn(5);
|
||||
assertEquals(5 * EconomyManager.DEFAULT_JS_MIN_SATIATED_BALANCE_INCREMENT_APP_UPDATER_CAKES
|
||||
+ EconomyManager.DEFAULT_JS_MIN_SATIATED_BALANCE_OTHER_APP_CAKES,
|
||||
mEconomicPolicy.getMinSatiatedBalance(0, pkgUpdater));
|
||||
assertEquals(EconomyManager.DEFAULT_JS_MAX_SATIATED_BALANCE_CAKES,
|
||||
mEconomicPolicy.getMaxSatiatedBalance(0, pkgUpdater));
|
||||
// Make sure it doesn't suggest a min balance greater than max.
|
||||
final int updateCount = (int) (EconomyManager.DEFAULT_JS_MAX_SATIATED_BALANCE_CAKES
|
||||
/ EconomyManager.DEFAULT_JS_MIN_SATIATED_BALANCE_INCREMENT_APP_UPDATER_CAKES);
|
||||
when(mIrs.getAppUpdateResponsibilityCount(anyInt(), eq(pkgUpdater)))
|
||||
.thenReturn(updateCount);
|
||||
assertEquals(EconomyManager.DEFAULT_JS_MAX_SATIATED_BALANCE_CAKES,
|
||||
mEconomicPolicy.getMinSatiatedBalance(0, pkgUpdater));
|
||||
|
||||
assertEquals(EconomyManager.DEFAULT_JS_MAX_SATIATED_BALANCE_CAKES,
|
||||
mEconomicPolicy.getMaxSatiatedBalance(0, "com.any.other.app"));
|
||||
assertEquals(EconomyManager.DEFAULT_JS_MIN_SATIATED_BALANCE_OTHER_APP_CAKES,
|
||||
mEconomicPolicy.getMinSatiatedBalance(0, "com.any.other.app"));
|
||||
}
|
||||
@@ -152,8 +173,10 @@ public class JobSchedulerEconomicPolicyTest {
|
||||
setDeviceConfigCakes(EconomyManager.KEY_JS_INITIAL_CONSUMPTION_LIMIT, arcToCake(5));
|
||||
setDeviceConfigCakes(EconomyManager.KEY_JS_HARD_CONSUMPTION_LIMIT, arcToCake(25));
|
||||
setDeviceConfigCakes(EconomyManager.KEY_JS_MAX_SATIATED_BALANCE, arcToCake(10));
|
||||
setDeviceConfigCakes(EconomyManager.KEY_JS_MIN_SATIATED_BALANCE_EXEMPTED, arcToCake(9));
|
||||
setDeviceConfigCakes(EconomyManager.KEY_JS_MIN_SATIATED_BALANCE_OTHER_APP, arcToCake(7));
|
||||
setDeviceConfigCakes(EconomyManager.KEY_JS_MIN_SATIATED_BALANCE_EXEMPTED, arcToCake(6));
|
||||
setDeviceConfigCakes(EconomyManager.KEY_JS_MIN_SATIATED_BALANCE_OTHER_APP, arcToCake(4));
|
||||
setDeviceConfigCakes(EconomyManager.KEY_JS_MIN_SATIATED_BALANCE_INCREMENT_APP_UPDATER,
|
||||
arcToCake(1));
|
||||
|
||||
assertEquals(arcToCake(5), mEconomicPolicy.getInitialSatiatedConsumptionLimit());
|
||||
assertEquals(arcToCake(25), mEconomicPolicy.getHardSatiatedConsumptionLimit());
|
||||
@@ -163,8 +186,12 @@ public class JobSchedulerEconomicPolicyTest {
|
||||
assertEquals(arcToCake(10), mEconomicPolicy.getMaxSatiatedBalance(0, "com.any.other.app"));
|
||||
final String pkgExempted = "com.pkg.exempted";
|
||||
when(mIrs.isPackageExempted(anyInt(), eq(pkgExempted))).thenReturn(true);
|
||||
assertEquals(arcToCake(9), mEconomicPolicy.getMinSatiatedBalance(0, pkgExempted));
|
||||
assertEquals(arcToCake(7), mEconomicPolicy.getMinSatiatedBalance(0, "com.any.other.app"));
|
||||
assertEquals(arcToCake(6), mEconomicPolicy.getMinSatiatedBalance(0, pkgExempted));
|
||||
assertEquals(arcToCake(4), mEconomicPolicy.getMinSatiatedBalance(0, "com.any.other.app"));
|
||||
final String pkgUpdater = "com.pkg.updater";
|
||||
when(mIrs.getAppUpdateResponsibilityCount(anyInt(), eq(pkgUpdater))).thenReturn(3);
|
||||
assertEquals(arcToCake(4) + 3 * arcToCake(1),
|
||||
mEconomicPolicy.getMinSatiatedBalance(0, pkgUpdater));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -175,6 +202,8 @@ public class JobSchedulerEconomicPolicyTest {
|
||||
setDeviceConfigCakes(EconomyManager.KEY_JS_MAX_SATIATED_BALANCE, arcToCake(-1));
|
||||
setDeviceConfigCakes(EconomyManager.KEY_JS_MIN_SATIATED_BALANCE_EXEMPTED, arcToCake(-2));
|
||||
setDeviceConfigCakes(EconomyManager.KEY_JS_MIN_SATIATED_BALANCE_OTHER_APP, arcToCake(-3));
|
||||
setDeviceConfigCakes(EconomyManager.KEY_JS_MIN_SATIATED_BALANCE_INCREMENT_APP_UPDATER,
|
||||
arcToCake(-4));
|
||||
|
||||
assertEquals(arcToCake(1), mEconomicPolicy.getInitialSatiatedConsumptionLimit());
|
||||
assertEquals(arcToCake(1), mEconomicPolicy.getHardSatiatedConsumptionLimit());
|
||||
@@ -186,6 +215,10 @@ public class JobSchedulerEconomicPolicyTest {
|
||||
when(mIrs.isPackageExempted(anyInt(), eq(pkgExempted))).thenReturn(true);
|
||||
assertEquals(arcToCake(0), mEconomicPolicy.getMinSatiatedBalance(0, pkgExempted));
|
||||
assertEquals(arcToCake(0), mEconomicPolicy.getMinSatiatedBalance(0, "com.any.other.app"));
|
||||
final String pkgUpdater = "com.pkg.updater";
|
||||
when(mIrs.getAppUpdateResponsibilityCount(anyInt(), eq(pkgUpdater))).thenReturn(5);
|
||||
assertEquals(arcToCake(0) + 5 * arcToCake(0),
|
||||
mEconomicPolicy.getMinSatiatedBalance(0, pkgUpdater));
|
||||
|
||||
// Test min+max reversed.
|
||||
setDeviceConfigCakes(EconomyManager.KEY_JS_INITIAL_CONSUMPTION_LIMIT, arcToCake(5));
|
||||
|
||||
Reference in New Issue
Block a user