Move DialogEumn into AccessibilityDialogUtils
Bug: 190022774 Test: Manual testing Change-Id: I391089b675b415c26bbacd028caf1d1ab617e707
This commit is contained in:
@@ -0,0 +1,515 @@
|
||||
/*
|
||||
* Copyright (C) 2019 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.accessibility;
|
||||
|
||||
import static com.android.settings.accessibility.ItemInfoArrayAdapter.ItemInfo;
|
||||
|
||||
import android.app.Dialog;
|
||||
import android.app.settings.SettingsEnums;
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.content.res.TypedArray;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.icu.text.MessageFormat;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.TextUtils;
|
||||
import android.text.method.LinkMovementMethod;
|
||||
import android.text.style.ImageSpan;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.widget.AbsListView;
|
||||
import android.widget.AdapterView;
|
||||
import android.widget.Button;
|
||||
import android.widget.CheckBox;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.ListView;
|
||||
import android.widget.ScrollView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.IntDef;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.android.settings.R;
|
||||
import com.android.settings.core.SubSettingLauncher;
|
||||
import com.android.settings.utils.AnnotationSpan;
|
||||
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/**
|
||||
* Utility class for creating the edit dialog.
|
||||
*/
|
||||
public class AccessibilityDialogUtils {
|
||||
|
||||
/** Denotes the dialog emuns for show dialog. */
|
||||
@Retention(RetentionPolicy.SOURCE)
|
||||
public @interface DialogEnums {
|
||||
|
||||
/** OPEN: Settings > Accessibility > Any toggle service > Shortcut > Settings. */
|
||||
int EDIT_SHORTCUT = 1;
|
||||
|
||||
/** OPEN: Settings > Accessibility > Magnification > Shortcut > Settings. */
|
||||
int MAGNIFICATION_EDIT_SHORTCUT = 1001;
|
||||
|
||||
/**
|
||||
* OPEN: Settings > Accessibility > Downloaded toggle service > Toggle use service to
|
||||
* enable service.
|
||||
*/
|
||||
int ENABLE_WARNING_FROM_TOGGLE = 1002;
|
||||
|
||||
/** OPEN: Settings > Accessibility > Downloaded toggle service > Shortcut checkbox. */
|
||||
int ENABLE_WARNING_FROM_SHORTCUT = 1003;
|
||||
|
||||
/**
|
||||
* OPEN: Settings > Accessibility > Downloaded toggle service > Shortcut checkbox
|
||||
* toggle.
|
||||
*/
|
||||
int ENABLE_WARNING_FROM_SHORTCUT_TOGGLE = 1004;
|
||||
|
||||
/**
|
||||
* OPEN: Settings > Accessibility > Downloaded toggle service > Toggle use service to
|
||||
* disable service.
|
||||
*/
|
||||
int DISABLE_WARNING_FROM_TOGGLE = 1005;
|
||||
|
||||
/**
|
||||
* OPEN: Settings > Accessibility > Magnification > Toggle user service in button
|
||||
* navigation.
|
||||
*/
|
||||
int ACCESSIBILITY_BUTTON_TUTORIAL = 1006;
|
||||
|
||||
/**
|
||||
* OPEN: Settings > Accessibility > Magnification > Toggle user service in gesture
|
||||
* navigation.
|
||||
*/
|
||||
int GESTURE_NAVIGATION_TUTORIAL = 1007;
|
||||
|
||||
/**
|
||||
* OPEN: Settings > Accessibility > Downloaded toggle service > Toggle user service > Show
|
||||
* launch tutorial.
|
||||
*/
|
||||
int LAUNCH_ACCESSIBILITY_TUTORIAL = 1008;
|
||||
}
|
||||
|
||||
/**
|
||||
* IntDef enum for dialog type that indicates different dialog for user to choose the shortcut
|
||||
* type.
|
||||
*/
|
||||
@Retention(RetentionPolicy.SOURCE)
|
||||
@IntDef({
|
||||
DialogType.EDIT_SHORTCUT_GENERIC,
|
||||
DialogType.EDIT_SHORTCUT_GENERIC_SUW,
|
||||
DialogType.EDIT_SHORTCUT_MAGNIFICATION,
|
||||
DialogType.EDIT_SHORTCUT_MAGNIFICATION_SUW,
|
||||
DialogType.EDIT_MAGNIFICATION_SWITCH_SHORTCUT,
|
||||
})
|
||||
|
||||
public @interface DialogType {
|
||||
int EDIT_SHORTCUT_GENERIC = 0;
|
||||
int EDIT_SHORTCUT_GENERIC_SUW = 1;
|
||||
int EDIT_SHORTCUT_MAGNIFICATION = 2;
|
||||
int EDIT_SHORTCUT_MAGNIFICATION_SUW = 3;
|
||||
int EDIT_MAGNIFICATION_SWITCH_SHORTCUT = 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to show the edit shortcut dialog.
|
||||
*
|
||||
* @param context A valid context
|
||||
* @param dialogType The type of edit shortcut dialog
|
||||
* @param dialogTitle The title of edit shortcut dialog
|
||||
* @param listener The listener to determine the action of edit shortcut dialog
|
||||
* @return A edit shortcut dialog for showing
|
||||
*/
|
||||
public static AlertDialog showEditShortcutDialog(Context context, int dialogType,
|
||||
CharSequence dialogTitle, DialogInterface.OnClickListener listener) {
|
||||
final AlertDialog alertDialog = createDialog(context, dialogType, dialogTitle, listener);
|
||||
alertDialog.show();
|
||||
setScrollIndicators(alertDialog);
|
||||
return alertDialog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to show the magnification edit shortcut dialog in Magnification.
|
||||
*
|
||||
* @param context A valid context
|
||||
* @param positiveBtnListener The positive button listener
|
||||
* @return A magnification edit shortcut dialog in Magnification
|
||||
*/
|
||||
public static Dialog createMagnificationSwitchShortcutDialog(Context context,
|
||||
CustomButtonsClickListener positiveBtnListener) {
|
||||
final View contentView = createSwitchShortcutDialogContentView(context);
|
||||
final AlertDialog alertDialog = new AlertDialog.Builder(context)
|
||||
.setView(contentView)
|
||||
.setTitle(context.getString(
|
||||
R.string.accessibility_magnification_switch_shortcut_title))
|
||||
.create();
|
||||
setCustomButtonsClickListener(alertDialog, contentView,
|
||||
positiveBtnListener, /* negativeBtnListener= */ null);
|
||||
setScrollIndicators(contentView);
|
||||
return alertDialog;
|
||||
}
|
||||
|
||||
private static AlertDialog createDialog(Context context, int dialogType,
|
||||
CharSequence dialogTitle, DialogInterface.OnClickListener listener) {
|
||||
|
||||
final AlertDialog alertDialog = new AlertDialog.Builder(context)
|
||||
.setView(createEditDialogContentView(context, dialogType))
|
||||
.setTitle(dialogTitle)
|
||||
.setPositiveButton(R.string.save, listener)
|
||||
.setNegativeButton(R.string.cancel,
|
||||
(DialogInterface dialog, int which) -> dialog.dismiss())
|
||||
.create();
|
||||
|
||||
return alertDialog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the scroll indicators for dialog view. The indicators appears while content view is
|
||||
* out of vision for vertical scrolling.
|
||||
*/
|
||||
private static void setScrollIndicators(AlertDialog dialog) {
|
||||
final ScrollView scrollView = dialog.findViewById(R.id.container_layout);
|
||||
setScrollIndicators(scrollView);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the scroll indicators for dialog view. The indicators appear while content view is
|
||||
* out of vision for vertical scrolling.
|
||||
*
|
||||
* @param view The view contains customized dialog content. Usually it is {@link ScrollView} or
|
||||
* {@link AbsListView}
|
||||
*/
|
||||
private static void setScrollIndicators(@NonNull View view) {
|
||||
view.setScrollIndicators(
|
||||
View.SCROLL_INDICATOR_TOP | View.SCROLL_INDICATOR_BOTTOM,
|
||||
View.SCROLL_INDICATOR_TOP | View.SCROLL_INDICATOR_BOTTOM);
|
||||
}
|
||||
|
||||
|
||||
interface CustomButtonsClickListener {
|
||||
void onClick(@CustomButton int which);
|
||||
}
|
||||
|
||||
/**
|
||||
* Annotation for customized dialog button type.
|
||||
*/
|
||||
@Retention(RetentionPolicy.SOURCE)
|
||||
@IntDef({
|
||||
CustomButton.POSITIVE,
|
||||
CustomButton.NEGATIVE,
|
||||
})
|
||||
|
||||
public @interface CustomButton {
|
||||
int POSITIVE = 1;
|
||||
int NEGATIVE = 2;
|
||||
}
|
||||
|
||||
private static void setCustomButtonsClickListener(Dialog dialog, View contentView,
|
||||
CustomButtonsClickListener positiveBtnListener,
|
||||
CustomButtonsClickListener negativeBtnListener) {
|
||||
final Button positiveButton = contentView.findViewById(
|
||||
R.id.custom_positive_button);
|
||||
final Button negativeButton = contentView.findViewById(
|
||||
R.id.custom_negative_button);
|
||||
|
||||
if (positiveButton != null) {
|
||||
positiveButton.setOnClickListener(v -> {
|
||||
if (positiveBtnListener != null) {
|
||||
positiveBtnListener.onClick(CustomButton.POSITIVE);
|
||||
}
|
||||
dialog.dismiss();
|
||||
});
|
||||
}
|
||||
|
||||
if (negativeButton != null) {
|
||||
negativeButton.setOnClickListener(v -> {
|
||||
if (negativeBtnListener != null) {
|
||||
negativeBtnListener.onClick(CustomButton.NEGATIVE);
|
||||
}
|
||||
dialog.dismiss();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static View createSwitchShortcutDialogContentView(Context context) {
|
||||
return createEditDialogContentView(context, DialogType.EDIT_MAGNIFICATION_SWITCH_SHORTCUT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a content View for the edit shortcut dialog.
|
||||
*
|
||||
* @param context A valid context
|
||||
* @param dialogType The type of edit shortcut dialog
|
||||
* @return A content view suitable for viewing
|
||||
*/
|
||||
private static View createEditDialogContentView(Context context, int dialogType) {
|
||||
final LayoutInflater inflater = (LayoutInflater) context.getSystemService(
|
||||
Context.LAYOUT_INFLATER_SERVICE);
|
||||
|
||||
View contentView = null;
|
||||
|
||||
switch (dialogType) {
|
||||
case DialogType.EDIT_SHORTCUT_GENERIC:
|
||||
contentView = inflater.inflate(
|
||||
R.layout.accessibility_edit_shortcut, null);
|
||||
initSoftwareShortcut(context, contentView);
|
||||
initHardwareShortcut(context, contentView);
|
||||
break;
|
||||
case DialogType.EDIT_SHORTCUT_GENERIC_SUW:
|
||||
contentView = inflater.inflate(
|
||||
R.layout.accessibility_edit_shortcut, null);
|
||||
initSoftwareShortcutForSUW(context, contentView);
|
||||
initHardwareShortcut(context, contentView);
|
||||
break;
|
||||
case DialogType.EDIT_SHORTCUT_MAGNIFICATION:
|
||||
contentView = inflater.inflate(
|
||||
R.layout.accessibility_edit_shortcut_magnification, null);
|
||||
initSoftwareShortcut(context, contentView);
|
||||
initHardwareShortcut(context, contentView);
|
||||
initMagnifyShortcut(context, contentView);
|
||||
initAdvancedWidget(contentView);
|
||||
break;
|
||||
case DialogType.EDIT_SHORTCUT_MAGNIFICATION_SUW:
|
||||
contentView = inflater.inflate(
|
||||
R.layout.accessibility_edit_shortcut_magnification, null);
|
||||
initSoftwareShortcutForSUW(context, contentView);
|
||||
initHardwareShortcut(context, contentView);
|
||||
initMagnifyShortcut(context, contentView);
|
||||
initAdvancedWidget(contentView);
|
||||
break;
|
||||
case DialogType.EDIT_MAGNIFICATION_SWITCH_SHORTCUT:
|
||||
contentView = inflater.inflate(
|
||||
R.layout.accessibility_edit_magnification_shortcut, null);
|
||||
final ImageView image = contentView.findViewById(R.id.image);
|
||||
image.setImageResource(retrieveSoftwareShortcutImageResId(context));
|
||||
break;
|
||||
default:
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
|
||||
return contentView;
|
||||
}
|
||||
|
||||
private static void setupShortcutWidget(View view, CharSequence titleText,
|
||||
CharSequence summaryText, int imageResId) {
|
||||
final CheckBox checkBox = view.findViewById(R.id.checkbox);
|
||||
checkBox.setText(titleText);
|
||||
final TextView summary = view.findViewById(R.id.summary);
|
||||
if (TextUtils.isEmpty(summaryText)) {
|
||||
summary.setVisibility(View.GONE);
|
||||
} else {
|
||||
summary.setText(summaryText);
|
||||
summary.setMovementMethod(LinkMovementMethod.getInstance());
|
||||
summary.setFocusable(false);
|
||||
}
|
||||
final ImageView image = view.findViewById(R.id.image);
|
||||
image.setImageResource(imageResId);
|
||||
}
|
||||
|
||||
private static void initSoftwareShortcutForSUW(Context context, View view) {
|
||||
final View dialogView = view.findViewById(R.id.software_shortcut);
|
||||
final CharSequence title = context.getText(
|
||||
R.string.accessibility_shortcut_edit_dialog_title_software);
|
||||
final TextView summary = dialogView.findViewById(R.id.summary);
|
||||
final int lineHeight = summary.getLineHeight();
|
||||
|
||||
setupShortcutWidget(dialogView, title,
|
||||
retrieveSoftwareShortcutSummaryForSUW(context, lineHeight),
|
||||
retrieveSoftwareShortcutImageResId(context));
|
||||
}
|
||||
|
||||
private static void initSoftwareShortcut(Context context, View view) {
|
||||
final View dialogView = view.findViewById(R.id.software_shortcut);
|
||||
final CharSequence title = context.getText(
|
||||
R.string.accessibility_shortcut_edit_dialog_title_software);
|
||||
final TextView summary = dialogView.findViewById(R.id.summary);
|
||||
final int lineHeight = summary.getLineHeight();
|
||||
|
||||
setupShortcutWidget(dialogView, title,
|
||||
retrieveSoftwareShortcutSummary(context, lineHeight),
|
||||
retrieveSoftwareShortcutImageResId(context));
|
||||
}
|
||||
|
||||
private static void initHardwareShortcut(Context context, View view) {
|
||||
final View dialogView = view.findViewById(R.id.hardware_shortcut);
|
||||
final CharSequence title = context.getText(
|
||||
R.string.accessibility_shortcut_edit_dialog_title_hardware);
|
||||
final CharSequence summary = context.getText(
|
||||
R.string.accessibility_shortcut_edit_dialog_summary_hardware);
|
||||
setupShortcutWidget(dialogView, title, summary,
|
||||
R.drawable.accessibility_shortcut_type_hardware);
|
||||
// TODO(b/142531156): Use vector drawable instead of temporal png file to avoid distorted.
|
||||
}
|
||||
|
||||
private static void initMagnifyShortcut(Context context, View view) {
|
||||
final View dialogView = view.findViewById(R.id.triple_tap_shortcut);
|
||||
final CharSequence title = context.getText(
|
||||
R.string.accessibility_shortcut_edit_dialog_title_triple_tap);
|
||||
String summary = context.getString(
|
||||
R.string.accessibility_shortcut_edit_dialog_summary_triple_tap);
|
||||
// Format the number '3' in the summary.
|
||||
final Object[] arguments = {3};
|
||||
summary = MessageFormat.format(summary, arguments);
|
||||
|
||||
setupShortcutWidget(dialogView, title, summary,
|
||||
R.drawable.accessibility_shortcut_type_triple_tap);
|
||||
// TODO(b/142531156): Use vector drawable instead of temporal png file to avoid distorted.
|
||||
}
|
||||
|
||||
private static void initAdvancedWidget(View view) {
|
||||
final LinearLayout advanced = view.findViewById(R.id.advanced_shortcut);
|
||||
final View tripleTap = view.findViewById(R.id.triple_tap_shortcut);
|
||||
advanced.setOnClickListener((View v) -> {
|
||||
advanced.setVisibility(View.GONE);
|
||||
tripleTap.setVisibility(View.VISIBLE);
|
||||
});
|
||||
}
|
||||
|
||||
private static CharSequence retrieveSoftwareShortcutSummaryForSUW(Context context,
|
||||
int lineHeight) {
|
||||
final SpannableStringBuilder sb = new SpannableStringBuilder();
|
||||
if (!AccessibilityUtil.isFloatingMenuEnabled(context)) {
|
||||
sb.append(getSummaryStringWithIcon(context, lineHeight));
|
||||
}
|
||||
return sb;
|
||||
}
|
||||
|
||||
private static CharSequence retrieveSoftwareShortcutSummary(Context context, int lineHeight) {
|
||||
final SpannableStringBuilder sb = new SpannableStringBuilder();
|
||||
if (!AccessibilityUtil.isFloatingMenuEnabled(context)) {
|
||||
sb.append(getSummaryStringWithIcon(context, lineHeight));
|
||||
sb.append("\n\n");
|
||||
}
|
||||
sb.append(getCustomizeAccessibilityButtonLink(context));
|
||||
return sb;
|
||||
}
|
||||
|
||||
private static int retrieveSoftwareShortcutImageResId(Context context) {
|
||||
return AccessibilityUtil.isFloatingMenuEnabled(context)
|
||||
? R.drawable.accessibility_shortcut_type_software_floating
|
||||
: R.drawable.accessibility_shortcut_type_software;
|
||||
}
|
||||
|
||||
private static CharSequence getCustomizeAccessibilityButtonLink(Context context) {
|
||||
final View.OnClickListener linkListener = v -> new SubSettingLauncher(context)
|
||||
.setDestination(AccessibilityButtonFragment.class.getName())
|
||||
.setSourceMetricsCategory(
|
||||
SettingsEnums.SWITCH_SHORTCUT_DIALOG_ACCESSIBILITY_BUTTON_SETTINGS)
|
||||
.launch();
|
||||
final AnnotationSpan.LinkInfo linkInfo = new AnnotationSpan.LinkInfo(
|
||||
AnnotationSpan.LinkInfo.DEFAULT_ANNOTATION, linkListener);
|
||||
|
||||
return AnnotationSpan.linkify(context.getText(
|
||||
R.string.accessibility_shortcut_edit_dialog_summary_software_floating), linkInfo);
|
||||
}
|
||||
|
||||
private static SpannableString getSummaryStringWithIcon(Context context, int lineHeight) {
|
||||
final String summary = context
|
||||
.getString(R.string.accessibility_shortcut_edit_dialog_summary_software);
|
||||
final SpannableString spannableMessage = SpannableString.valueOf(summary);
|
||||
|
||||
// Icon
|
||||
final int indexIconStart = summary.indexOf("%s");
|
||||
final int indexIconEnd = indexIconStart + 2;
|
||||
final Drawable icon = context.getDrawable(R.drawable.ic_accessibility_new);
|
||||
final ImageSpan imageSpan = new ImageSpan(icon);
|
||||
imageSpan.setContentDescription("");
|
||||
icon.setBounds(0, 0, lineHeight, lineHeight);
|
||||
spannableMessage.setSpan(
|
||||
imageSpan, indexIconStart, indexIconEnd,
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
return spannableMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the color associated with the specified attribute in the context's theme.
|
||||
*/
|
||||
@ColorInt
|
||||
private static int getThemeAttrColor(final Context context, final int attributeColor) {
|
||||
final int colorResId = getAttrResourceId(context, attributeColor);
|
||||
return ContextCompat.getColor(context, colorResId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the identifier of the resolved resource assigned to the given attribute.
|
||||
*/
|
||||
private static int getAttrResourceId(final Context context, final int attributeColor) {
|
||||
final int[] attrs = {attributeColor};
|
||||
final TypedArray typedArray = context.obtainStyledAttributes(attrs);
|
||||
final int colorResId = typedArray.getResourceId(0, 0);
|
||||
typedArray.recycle();
|
||||
return colorResId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a dialog with the given view.
|
||||
*
|
||||
* @param context A valid context
|
||||
* @param dialogTitle The title of the dialog
|
||||
* @param customView The customized view
|
||||
* @param listener This listener will be invoked when the positive button in the dialog is
|
||||
* clicked
|
||||
* @return the {@link Dialog} with the given view
|
||||
*/
|
||||
public static Dialog createCustomDialog(Context context, CharSequence dialogTitle,
|
||||
View customView, DialogInterface.OnClickListener listener) {
|
||||
final AlertDialog alertDialog = new AlertDialog.Builder(context)
|
||||
.setView(customView)
|
||||
.setTitle(dialogTitle)
|
||||
.setCancelable(true)
|
||||
.setPositiveButton(R.string.save, listener)
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.create();
|
||||
if (customView instanceof ScrollView || customView instanceof AbsListView) {
|
||||
setScrollIndicators(customView);
|
||||
}
|
||||
return alertDialog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a single choice {@link ListView} with given {@link ItemInfo} list.
|
||||
*
|
||||
* @param context A context.
|
||||
* @param itemInfoList A {@link ItemInfo} list.
|
||||
* @param itemListener The listener will be invoked when the item is clicked.
|
||||
*/
|
||||
@NonNull
|
||||
public static ListView createSingleChoiceListView(@NonNull Context context,
|
||||
@NonNull List<? extends ItemInfo> itemInfoList,
|
||||
@Nullable AdapterView.OnItemClickListener itemListener) {
|
||||
final ListView list = new ListView(context);
|
||||
// Set an id to save its state.
|
||||
list.setId(android.R.id.list);
|
||||
list.setDivider(/* divider= */ null);
|
||||
list.setChoiceMode(ListView.CHOICE_MODE_SINGLE);
|
||||
final ItemInfoArrayAdapter
|
||||
adapter = new ItemInfoArrayAdapter(context, itemInfoList);
|
||||
list.setAdapter(adapter);
|
||||
list.setOnItemClickListener(itemListener);
|
||||
return list;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user