mirror of
https://github.com/LawnchairLauncher/lawnchair.git
synced 2026-02-19 10:48:19 +00:00
Merge changes I865871e5,Ie655de7a into tm-qpr-dev
* changes: Fixes to VoiceInteractionWindowController Fix TaskbarBackgroundRenderer not being applied correctly in 2 cases
This commit is contained in:
committed by
Android (Google) Code Review
commit
6e21d310c1
@@ -18,6 +18,7 @@ package com.android.launcher3.taskbar;
|
||||
import static android.view.View.AccessibilityDelegate;
|
||||
import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
|
||||
import static android.view.ViewTreeObserver.InternalInsetsInfo.TOUCHABLE_INSETS_REGION;
|
||||
import static android.view.WindowManager.LayoutParams.TYPE_NAVIGATION_BAR_PANEL;
|
||||
|
||||
import static com.android.launcher3.LauncherAnimUtils.VIEW_TRANSLATE_X;
|
||||
import static com.android.launcher3.Utilities.getDescendantCoordRelativeToAncestor;
|
||||
@@ -864,8 +865,8 @@ public class NavbarButtonsViewController implements TaskbarControllers.LoggableT
|
||||
mAreNavButtonsInSeparateWindow = true;
|
||||
mContext.getDragLayer().removeView(mNavButtonsView);
|
||||
mSeparateWindowParent.addView(mNavButtonsView);
|
||||
WindowManager.LayoutParams windowLayoutParams = mContext.createDefaultWindowLayoutParams();
|
||||
windowLayoutParams.setTitle(NAV_BUTTONS_SEPARATE_WINDOW_TITLE);
|
||||
WindowManager.LayoutParams windowLayoutParams = mContext.createDefaultWindowLayoutParams(
|
||||
TYPE_NAVIGATION_BAR_PANEL, NAV_BUTTONS_SEPARATE_WINDOW_TITLE);
|
||||
mContext.addWindowView(mSeparateWindowParent, windowLayoutParams);
|
||||
|
||||
}
|
||||
|
||||
@@ -241,7 +241,8 @@ public class TaskbarActivityContext extends BaseTaskbarContext {
|
||||
|
||||
public void init(@NonNull TaskbarSharedState sharedState) {
|
||||
mLastRequestedNonFullscreenHeight = getDefaultTaskbarWindowHeight();
|
||||
mWindowLayoutParams = createDefaultWindowLayoutParams();
|
||||
mWindowLayoutParams =
|
||||
createDefaultWindowLayoutParams(TYPE_NAVIGATION_BAR_PANEL, WINDOW_TITLE);
|
||||
|
||||
// Initialize controllers after all are constructed.
|
||||
mControllers.init(sharedState);
|
||||
@@ -317,16 +318,12 @@ public class TaskbarActivityContext extends BaseTaskbarContext {
|
||||
return super.getStatsLogManager();
|
||||
}
|
||||
|
||||
/** @see #createDefaultWindowLayoutParams(int) */
|
||||
public WindowManager.LayoutParams createDefaultWindowLayoutParams() {
|
||||
return createDefaultWindowLayoutParams(TYPE_NAVIGATION_BAR_PANEL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates LayoutParams for adding a view directly to WindowManager as a new window.
|
||||
* @param type The window type to pass to the created WindowManager.LayoutParams.
|
||||
* @param title The window title to pass to the created WindowManager.LayoutParams.
|
||||
*/
|
||||
public WindowManager.LayoutParams createDefaultWindowLayoutParams(int type) {
|
||||
public WindowManager.LayoutParams createDefaultWindowLayoutParams(int type, String title) {
|
||||
DeviceProfile deviceProfile = getDeviceProfile();
|
||||
// Taskbar is on the logical bottom of the screen
|
||||
boolean isVerticalBarLayout = TaskbarManager.isPhoneMode(deviceProfile) &&
|
||||
@@ -346,7 +343,7 @@ public class TaskbarActivityContext extends BaseTaskbarContext {
|
||||
type,
|
||||
windowFlags,
|
||||
PixelFormat.TRANSLUCENT);
|
||||
windowLayoutParams.setTitle(WINDOW_TITLE);
|
||||
windowLayoutParams.setTitle(title);
|
||||
windowLayoutParams.packageName = getPackageName();
|
||||
windowLayoutParams.gravity = !isVerticalBarLayout ?
|
||||
Gravity.BOTTOM :
|
||||
|
||||
@@ -49,6 +49,8 @@ public class TaskbarDragLayerController implements TaskbarControllers.LoggableTa
|
||||
private final AnimatedFloat mNotificationShadeBgTaskbar = new AnimatedFloat(
|
||||
this::updateBackgroundAlpha);
|
||||
private final AnimatedFloat mImeBgTaskbar = new AnimatedFloat(this::updateBackgroundAlpha);
|
||||
private final AnimatedFloat mAssistantBgTaskbar = new AnimatedFloat(
|
||||
this::updateBackgroundAlpha);
|
||||
// Used to hide our background color when someone else (e.g. ScrimView) is handling it.
|
||||
private final AnimatedFloat mBgOverride = new AnimatedFloat(this::updateBackgroundAlpha);
|
||||
|
||||
@@ -60,6 +62,7 @@ public class TaskbarDragLayerController implements TaskbarControllers.LoggableTa
|
||||
private AnimatedFloat mNavButtonDarkIntensityMultiplier;
|
||||
|
||||
private float mLastSetBackgroundAlpha;
|
||||
private boolean mIsBackgroundDrawnElsewhere;
|
||||
|
||||
public TaskbarDragLayerController(TaskbarActivityContext activity,
|
||||
TaskbarDragLayer taskbarDragLayer) {
|
||||
@@ -81,6 +84,7 @@ public class TaskbarDragLayerController implements TaskbarControllers.LoggableTa
|
||||
mKeyguardBgTaskbar.value = 1;
|
||||
mNotificationShadeBgTaskbar.value = 1;
|
||||
mImeBgTaskbar.value = 1;
|
||||
mAssistantBgTaskbar.value = 1;
|
||||
mBgOverride.value = 1;
|
||||
updateBackgroundAlpha();
|
||||
}
|
||||
@@ -119,6 +123,10 @@ public class TaskbarDragLayerController implements TaskbarControllers.LoggableTa
|
||||
return mImeBgTaskbar;
|
||||
}
|
||||
|
||||
public AnimatedFloat getAssistantBgTaskbar() {
|
||||
return mAssistantBgTaskbar;
|
||||
}
|
||||
|
||||
public AnimatedFloat getOverrideBackgroundAlpha() {
|
||||
return mBgOverride;
|
||||
}
|
||||
@@ -143,7 +151,8 @@ public class TaskbarDragLayerController implements TaskbarControllers.LoggableTa
|
||||
private void updateBackgroundAlpha() {
|
||||
final float bgNavbar = mBgNavbar.value;
|
||||
final float bgTaskbar = mBgTaskbar.value * mKeyguardBgTaskbar.value
|
||||
* mNotificationShadeBgTaskbar.value * mImeBgTaskbar.value;
|
||||
* mNotificationShadeBgTaskbar.value * mImeBgTaskbar.value
|
||||
* mAssistantBgTaskbar.value;
|
||||
mLastSetBackgroundAlpha = mBgOverride.value * Math.max(bgNavbar, bgTaskbar);
|
||||
mTaskbarDragLayer.setTaskbarBackgroundAlpha(mLastSetBackgroundAlpha);
|
||||
|
||||
@@ -168,9 +177,23 @@ public class TaskbarDragLayerController implements TaskbarControllers.LoggableTa
|
||||
mTaskbarDragLayer.setCornerRoundness(cornerRoundness);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set if another controller is temporarily handling background drawing. In this case we:
|
||||
* - Override our background alpha to be 0.
|
||||
* - Keep the nav bar dark intensity assuming taskbar background is at full alpha.
|
||||
*/
|
||||
public void setIsBackgroundDrawnElsewhere(boolean isBackgroundDrawnElsewhere) {
|
||||
mIsBackgroundDrawnElsewhere = isBackgroundDrawnElsewhere;
|
||||
mBgOverride.updateValue(mIsBackgroundDrawnElsewhere ? 0 : 1);
|
||||
updateNavBarDarkIntensityMultiplier();
|
||||
}
|
||||
|
||||
private void updateNavBarDarkIntensityMultiplier() {
|
||||
// Zero out the app-requested dark intensity when we're drawing our own background.
|
||||
float effectiveBgAlpha = mLastSetBackgroundAlpha * (1 - mBgOffset.value);
|
||||
if (mIsBackgroundDrawnElsewhere) {
|
||||
effectiveBgAlpha = 1;
|
||||
}
|
||||
mNavButtonDarkIntensityMultiplier.updateValue(1 - effectiveBgAlpha);
|
||||
}
|
||||
|
||||
@@ -181,6 +204,13 @@ public class TaskbarDragLayerController implements TaskbarControllers.LoggableTa
|
||||
pw.println(prefix + "\tmBgOffset=" + mBgOffset.value);
|
||||
pw.println(prefix + "\tmFolderMargin=" + mFolderMargin);
|
||||
pw.println(prefix + "\tmLastSetBackgroundAlpha=" + mLastSetBackgroundAlpha);
|
||||
pw.println(prefix + "\t\tmBgOverride=" + mBgOverride.value);
|
||||
pw.println(prefix + "\t\tmBgNavbar=" + mBgNavbar.value);
|
||||
pw.println(prefix + "\t\tmBgTaskbar=" + mBgTaskbar.value);
|
||||
pw.println(prefix + "\t\tmKeyguardBgTaskbar=" + mKeyguardBgTaskbar.value);
|
||||
pw.println(prefix + "\t\tmNotificationShadeBgTaskbar=" + mNotificationShadeBgTaskbar.value);
|
||||
pw.println(prefix + "\t\tmImeBgTaskbar=" + mImeBgTaskbar.value);
|
||||
pw.println(prefix + "\t\tmAssistantBgTaskbar=" + mAssistantBgTaskbar.value);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -151,7 +151,7 @@ class TaskbarEduTooltipController(val activityContext: TaskbarActivityContext) :
|
||||
}
|
||||
|
||||
override fun dumpLogs(prefix: String?, pw: PrintWriter?) {
|
||||
pw?.println("$(prefix)TaskbarEduController:")
|
||||
pw?.println(prefix + "TaskbarEduTooltipController:")
|
||||
pw?.println("$prefix\tisTooltipEnabled=$isTooltipEnabled")
|
||||
pw?.println("$prefix\tisOpen=$isOpen")
|
||||
pw?.println("$prefix\ttooltipStep=$tooltipStep")
|
||||
|
||||
@@ -67,7 +67,8 @@ public class TaskbarScrimViewController implements TaskbarControllers.LoggableTa
|
||||
final boolean manageMenuExpanded =
|
||||
(stateFlags & SYSUI_STATE_BUBBLES_MANAGE_MENU_EXPANDED) != 0;
|
||||
final boolean showScrim = !mControllers.navbarButtonsViewController.isImeVisible()
|
||||
&& bubblesExpanded && mControllers.taskbarStashController.isInAppAndNotStashed();
|
||||
&& bubblesExpanded
|
||||
&& mControllers.taskbarStashController.isTaskbarVisibleAndNotStashing();
|
||||
final float scrimAlpha = manageMenuExpanded
|
||||
// When manage menu shows there's the first scrim and second scrim so figure out
|
||||
// what the total transparency would be.
|
||||
|
||||
@@ -349,10 +349,10 @@ public class TaskbarStashController implements TaskbarControllers.LoggableTaskba
|
||||
|
||||
|
||||
/**
|
||||
* Returns whether the taskbar is currently visible and in an app.
|
||||
* Returns whether the taskbar is currently visible and not in the process of being stashed.
|
||||
*/
|
||||
public boolean isInAppAndNotStashed() {
|
||||
return !mIsStashed && isInApp();
|
||||
public boolean isTaskbarVisibleAndNotStashing() {
|
||||
return !mIsStashed && mControllers.taskbarViewController.areIconsVisible();
|
||||
}
|
||||
|
||||
public boolean isInApp() {
|
||||
|
||||
@@ -113,7 +113,7 @@ public class TaskbarTranslationController implements TaskbarControllers.Loggable
|
||||
return;
|
||||
}
|
||||
reset();
|
||||
if (mControllers.taskbarStashController.isInAppAndNotStashed()) {
|
||||
if (mControllers.taskbarStashController.isTaskbarVisibleAndNotStashing()) {
|
||||
mControllers.taskbarEduTooltipController.maybeShowFeaturesEdu();
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -1,31 +1,70 @@
|
||||
/*
|
||||
* Copyright (C) 2023 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.launcher3.taskbar
|
||||
|
||||
import android.animation.AnimatorSet
|
||||
import android.graphics.Canvas
|
||||
import android.view.View
|
||||
import android.view.ViewTreeObserver
|
||||
import android.view.ViewTreeObserver.InternalInsetsInfo.TOUCHABLE_INSETS_REGION
|
||||
import android.view.WindowManager
|
||||
import android.view.WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
|
||||
import com.android.launcher3.util.DisplayController
|
||||
import com.android.launcher3.views.BaseDragLayer
|
||||
import com.android.systemui.animation.ViewRootSync
|
||||
import java.io.PrintWriter
|
||||
|
||||
private const val TASKBAR_ICONS_FADE_DURATION = 300L
|
||||
private const val STASHED_HANDLE_FADE_DURATION = 180L
|
||||
private const val TEMP_BACKGROUND_WINDOW_TITLE = "VoiceInteractionTaskbarBackground"
|
||||
|
||||
/** Controls Taskbar behavior while Voice Interaction Window (assistant) is showing. */
|
||||
/**
|
||||
* Controls Taskbar behavior while Voice Interaction Window (assistant) is showing. Specifically:
|
||||
* - We always hide the taskbar icons or stashed handle, whichever is currently showing.
|
||||
* - For persistent taskbar, we also move the taskbar background to a new window/layer
|
||||
* (TYPE_APPLICATION_OVERLAY) which is behind the assistant.
|
||||
* - For transient taskbar, we hide the real taskbar background (if it's showing).
|
||||
*/
|
||||
class VoiceInteractionWindowController(val context: TaskbarActivityContext) :
|
||||
TaskbarControllers.LoggableTaskbarController, TaskbarControllers.BackgroundRendererController {
|
||||
|
||||
private val isSeparateBackgroundEnabled = !DisplayController.isTransientTaskbar(context)
|
||||
private val taskbarBackgroundRenderer = TaskbarBackgroundRenderer(context)
|
||||
private val nonTouchableInsetsComputer =
|
||||
ViewTreeObserver.OnComputeInternalInsetsListener {
|
||||
it.touchableRegion.setEmpty()
|
||||
it.setTouchableInsets(TOUCHABLE_INSETS_REGION)
|
||||
}
|
||||
|
||||
// Initialized in init.
|
||||
private lateinit var controllers: TaskbarControllers
|
||||
private lateinit var separateWindowForTaskbarBackground: BaseDragLayer<TaskbarActivityContext>
|
||||
private lateinit var separateWindowLayoutParams: WindowManager.LayoutParams
|
||||
// Only initialized if isSeparateBackgroundEnabled
|
||||
private var separateWindowForTaskbarBackground: BaseDragLayer<TaskbarActivityContext>? = null
|
||||
private var separateWindowLayoutParams: WindowManager.LayoutParams? = null
|
||||
|
||||
private var isVoiceInteractionWindowVisible: Boolean = false
|
||||
private var pendingAttachedToWindowListener: View.OnAttachStateChangeListener? = null
|
||||
|
||||
fun init(controllers: TaskbarControllers) {
|
||||
this.controllers = controllers
|
||||
|
||||
if (!isSeparateBackgroundEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
separateWindowForTaskbarBackground =
|
||||
object : BaseDragLayer<TaskbarActivityContext>(context, null, 0) {
|
||||
override fun recreateControllers() {
|
||||
@@ -34,24 +73,39 @@ class VoiceInteractionWindowController(val context: TaskbarActivityContext) :
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
super.draw(canvas)
|
||||
if (
|
||||
this@VoiceInteractionWindowController.context.isGestureNav &&
|
||||
controllers.taskbarStashController.isInAppAndNotStashed
|
||||
) {
|
||||
if (controllers.taskbarStashController.isTaskbarVisibleAndNotStashing) {
|
||||
taskbarBackgroundRenderer.draw(canvas)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
viewTreeObserver.addOnComputeInternalInsetsListener(nonTouchableInsetsComputer)
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow()
|
||||
viewTreeObserver.removeOnComputeInternalInsetsListener(
|
||||
nonTouchableInsetsComputer
|
||||
)
|
||||
}
|
||||
}
|
||||
separateWindowForTaskbarBackground.recreateControllers()
|
||||
separateWindowForTaskbarBackground.setWillNotDraw(false)
|
||||
separateWindowForTaskbarBackground?.recreateControllers()
|
||||
separateWindowForTaskbarBackground?.setWillNotDraw(false)
|
||||
|
||||
separateWindowLayoutParams =
|
||||
context.createDefaultWindowLayoutParams(TYPE_APPLICATION_OVERLAY)
|
||||
separateWindowLayoutParams.isSystemApplicationOverlay = true
|
||||
context.createDefaultWindowLayoutParams(
|
||||
TYPE_APPLICATION_OVERLAY,
|
||||
TEMP_BACKGROUND_WINDOW_TITLE
|
||||
)
|
||||
separateWindowLayoutParams?.isSystemApplicationOverlay = true
|
||||
}
|
||||
|
||||
fun onDestroy() {
|
||||
setIsVoiceInteractionWindowVisible(visible = false, skipAnim = true)
|
||||
separateWindowForTaskbarBackground?.removeOnAttachStateChangeListener(
|
||||
pendingAttachedToWindowListener
|
||||
)
|
||||
}
|
||||
|
||||
fun setIsVoiceInteractionWindowVisible(visible: Boolean, skipAnim: Boolean) {
|
||||
@@ -72,14 +126,24 @@ class VoiceInteractionWindowController(val context: TaskbarActivityContext) :
|
||||
.get(StashedHandleViewController.ALPHA_INDEX_ASSISTANT_INVOKED)
|
||||
.animateToValue(taskbarIconAlpha)
|
||||
.setDuration(STASHED_HANDLE_FADE_DURATION)
|
||||
fadeTaskbarIcons.start()
|
||||
fadeStashedHandle.start()
|
||||
val animSet = AnimatorSet()
|
||||
animSet.play(fadeTaskbarIcons)
|
||||
animSet.play(fadeStashedHandle)
|
||||
if (!isSeparateBackgroundEnabled) {
|
||||
val fadeTaskbarBackground =
|
||||
controllers.taskbarDragLayerController.assistantBgTaskbar
|
||||
.animateToValue(taskbarIconAlpha)
|
||||
.setDuration(TASKBAR_ICONS_FADE_DURATION)
|
||||
animSet.play(fadeTaskbarBackground)
|
||||
}
|
||||
animSet.start()
|
||||
if (skipAnim) {
|
||||
fadeTaskbarIcons.end()
|
||||
fadeStashedHandle.end()
|
||||
animSet.end()
|
||||
}
|
||||
|
||||
moveTaskbarBackgroundToAppropriateLayer(skipAnim)
|
||||
if (isSeparateBackgroundEnabled) {
|
||||
moveTaskbarBackgroundToAppropriateLayer(skipAnim)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,8 +156,6 @@ class VoiceInteractionWindowController(val context: TaskbarActivityContext) :
|
||||
* Removes the temporary window and show the TaskbarDragLayer background again.
|
||||
*/
|
||||
private fun moveTaskbarBackgroundToAppropriateLayer(skipAnim: Boolean) {
|
||||
val taskbarBackgroundOverride =
|
||||
controllers.taskbarDragLayerController.overrideBackgroundAlpha
|
||||
val moveToLowerLayer = isVoiceInteractionWindowVisible
|
||||
val onWindowsSynchronized =
|
||||
if (moveToLowerLayer) {
|
||||
@@ -102,31 +164,60 @@ class VoiceInteractionWindowController(val context: TaskbarActivityContext) :
|
||||
separateWindowForTaskbarBackground,
|
||||
separateWindowLayoutParams
|
||||
);
|
||||
{ taskbarBackgroundOverride.updateValue(0f) }
|
||||
{ controllers.taskbarDragLayerController.setIsBackgroundDrawnElsewhere(true) }
|
||||
} else {
|
||||
// First reapply the original taskbar background, then remove the temporary window.
|
||||
taskbarBackgroundOverride.updateValue(1f);
|
||||
controllers.taskbarDragLayerController.setIsBackgroundDrawnElsewhere(false);
|
||||
{ context.removeWindowView(separateWindowForTaskbarBackground) }
|
||||
}
|
||||
|
||||
if (skipAnim) {
|
||||
onWindowsSynchronized()
|
||||
} else {
|
||||
ViewRootSync.synchronizeNextDraw(
|
||||
separateWindowForTaskbarBackground,
|
||||
context.dragLayer,
|
||||
onWindowsSynchronized
|
||||
)
|
||||
separateWindowForTaskbarBackground?.runWhenAttachedToWindow {
|
||||
ViewRootSync.synchronizeNextDraw(
|
||||
separateWindowForTaskbarBackground!!,
|
||||
context.dragLayer,
|
||||
onWindowsSynchronized
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun View.runWhenAttachedToWindow(onAttachedToWindow: () -> Unit) {
|
||||
if (isAttachedToWindow) {
|
||||
onAttachedToWindow()
|
||||
return
|
||||
}
|
||||
removeOnAttachStateChangeListener(pendingAttachedToWindowListener)
|
||||
pendingAttachedToWindowListener =
|
||||
object : View.OnAttachStateChangeListener {
|
||||
override fun onViewAttachedToWindow(v: View?) {
|
||||
onAttachedToWindow()
|
||||
removeOnAttachStateChangeListener(this)
|
||||
pendingAttachedToWindowListener = null
|
||||
}
|
||||
|
||||
override fun onViewDetachedFromWindow(v: View?) {}
|
||||
}
|
||||
addOnAttachStateChangeListener(pendingAttachedToWindowListener)
|
||||
}
|
||||
|
||||
override fun setCornerRoundness(cornerRoundness: Float) {
|
||||
if (!isSeparateBackgroundEnabled) {
|
||||
return
|
||||
}
|
||||
taskbarBackgroundRenderer.setCornerRoundness(cornerRoundness)
|
||||
separateWindowForTaskbarBackground.invalidate()
|
||||
separateWindowForTaskbarBackground?.invalidate()
|
||||
}
|
||||
|
||||
override fun dumpLogs(prefix: String, pw: PrintWriter) {
|
||||
pw.println(prefix + "VoiceInteractionWindowController:")
|
||||
pw.println("$prefix\tisSeparateBackgroundEnabled=$isSeparateBackgroundEnabled")
|
||||
pw.println("$prefix\tisVoiceInteractionWindowVisible=$isVoiceInteractionWindowVisible")
|
||||
pw.println(
|
||||
"$prefix\tisSeparateTaskbarBackgroundAttachedToWindow=" +
|
||||
"${separateWindowForTaskbarBackground?.isAttachedToWindow}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user