mirror of
https://github.com/moparisthebest/open-keychain
synced 2024-11-27 19:22:14 -05:00
Design fixes for header, QR Code shared element transitions
This commit is contained in:
parent
27263edda5
commit
57fa702cbf
@ -20,6 +20,7 @@ package org.sufficientlysecure.keychain.ui;
|
|||||||
import android.graphics.Bitmap;
|
import android.graphics.Bitmap;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
import android.support.v4.app.ActivityCompat;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.ViewTreeObserver.OnGlobalLayoutListener;
|
import android.view.ViewTreeObserver.OnGlobalLayoutListener;
|
||||||
import android.widget.ImageView;
|
import android.widget.ImageView;
|
||||||
@ -48,7 +49,7 @@ public class QrCodeViewActivity extends BaseActivity {
|
|||||||
@Override
|
@Override
|
||||||
public void onClick(View v) {
|
public void onClick(View v) {
|
||||||
// "Done"
|
// "Done"
|
||||||
finish();
|
ActivityCompat.finishAfterTransition(QrCodeViewActivity.this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -56,7 +57,7 @@ public class QrCodeViewActivity extends BaseActivity {
|
|||||||
Uri dataUri = getIntent().getData();
|
Uri dataUri = getIntent().getData();
|
||||||
if (dataUri == null) {
|
if (dataUri == null) {
|
||||||
Log.e(Constants.TAG, "Data missing. Should be Uri of key!");
|
Log.e(Constants.TAG, "Data missing. Should be Uri of key!");
|
||||||
finish();
|
ActivityCompat.finishAfterTransition(QrCodeViewActivity.this);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,7 +66,7 @@ public class QrCodeViewActivity extends BaseActivity {
|
|||||||
mFingerprintQrCode.setOnClickListener(new View.OnClickListener() {
|
mFingerprintQrCode.setOnClickListener(new View.OnClickListener() {
|
||||||
@Override
|
@Override
|
||||||
public void onClick(View v) {
|
public void onClick(View v) {
|
||||||
finish();
|
ActivityCompat.finishAfterTransition(QrCodeViewActivity.this);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -77,7 +78,7 @@ public class QrCodeViewActivity extends BaseActivity {
|
|||||||
if (blob == null) {
|
if (blob == null) {
|
||||||
Log.e(Constants.TAG, "key not found!");
|
Log.e(Constants.TAG, "key not found!");
|
||||||
Notify.showNotify(this, R.string.error_key_not_found, Style.ERROR);
|
Notify.showNotify(this, R.string.error_key_not_found, Style.ERROR);
|
||||||
finish();
|
ActivityCompat.finishAfterTransition(QrCodeViewActivity.this);
|
||||||
}
|
}
|
||||||
|
|
||||||
String fingerprint = KeyFormattingUtils.convertFingerprintToHex(blob);
|
String fingerprint = KeyFormattingUtils.convertFingerprintToHex(blob);
|
||||||
@ -88,18 +89,18 @@ public class QrCodeViewActivity extends BaseActivity {
|
|||||||
|
|
||||||
mFingerprintQrCode.getViewTreeObserver().addOnGlobalLayoutListener(
|
mFingerprintQrCode.getViewTreeObserver().addOnGlobalLayoutListener(
|
||||||
new OnGlobalLayoutListener() {
|
new OnGlobalLayoutListener() {
|
||||||
@Override
|
@Override
|
||||||
public void onGlobalLayout() {
|
public void onGlobalLayout() {
|
||||||
// create actual bitmap in display dimensions
|
// create actual bitmap in display dimensions
|
||||||
Bitmap scaled = Bitmap.createScaledBitmap(qrCode,
|
Bitmap scaled = Bitmap.createScaledBitmap(qrCode,
|
||||||
mFingerprintQrCode.getWidth(), mFingerprintQrCode.getWidth(), false);
|
mFingerprintQrCode.getWidth(), mFingerprintQrCode.getWidth(), false);
|
||||||
mFingerprintQrCode.setImageBitmap(scaled);
|
mFingerprintQrCode.setImageBitmap(scaled);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (ProviderHelper.NotFoundException e) {
|
} catch (ProviderHelper.NotFoundException e) {
|
||||||
Log.e(Constants.TAG, "key not found!", e);
|
Log.e(Constants.TAG, "key not found!", e);
|
||||||
Notify.showNotify(this, R.string.error_key_not_found, Style.ERROR);
|
Notify.showNotify(this, R.string.error_key_not_found, Style.ERROR);
|
||||||
finish();
|
ActivityCompat.finishAfterTransition(QrCodeViewActivity.this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,20 +109,4 @@ public class QrCodeViewActivity extends BaseActivity {
|
|||||||
setContentView(R.layout.qr_code_activity);
|
setContentView(R.layout.qr_code_activity);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onResume() {
|
|
||||||
super.onResume();
|
|
||||||
|
|
||||||
// custom activity transition to get zoom in effect
|
|
||||||
this.overridePendingTransition(R.anim.qr_code_zoom_enter, android.R.anim.fade_out);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onPause() {
|
|
||||||
super.onPause();
|
|
||||||
|
|
||||||
// custom activity transition to get zoom out effect
|
|
||||||
this.overridePendingTransition(0, R.anim.qr_code_zoom_exit);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
@ -19,6 +19,7 @@
|
|||||||
package org.sufficientlysecure.keychain.ui;
|
package org.sufficientlysecure.keychain.ui;
|
||||||
|
|
||||||
import android.annotation.TargetApi;
|
import android.annotation.TargetApi;
|
||||||
|
import android.app.ActivityOptions;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.database.Cursor;
|
import android.database.Cursor;
|
||||||
import android.graphics.Bitmap;
|
import android.graphics.Bitmap;
|
||||||
@ -33,6 +34,7 @@ import android.os.Bundle;
|
|||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
import android.os.Message;
|
import android.os.Message;
|
||||||
import android.provider.ContactsContract;
|
import android.provider.ContactsContract;
|
||||||
|
import android.support.v4.app.ActivityCompat;
|
||||||
import android.support.v4.app.LoaderManager;
|
import android.support.v4.app.LoaderManager;
|
||||||
import android.support.v4.content.CursorLoader;
|
import android.support.v4.content.CursorLoader;
|
||||||
import android.support.v4.content.Loader;
|
import android.support.v4.content.Loader;
|
||||||
@ -57,6 +59,7 @@ import org.sufficientlysecure.keychain.pgp.exception.PgpKeyNotFoundException;
|
|||||||
import org.sufficientlysecure.keychain.provider.KeychainContract;
|
import org.sufficientlysecure.keychain.provider.KeychainContract;
|
||||||
import org.sufficientlysecure.keychain.provider.ProviderHelper;
|
import org.sufficientlysecure.keychain.provider.ProviderHelper;
|
||||||
import org.sufficientlysecure.keychain.service.KeychainIntentServiceHandler;
|
import org.sufficientlysecure.keychain.service.KeychainIntentServiceHandler;
|
||||||
|
import org.sufficientlysecure.keychain.ui.util.FormattingUtils;
|
||||||
import org.sufficientlysecure.keychain.ui.util.KeyFormattingUtils;
|
import org.sufficientlysecure.keychain.ui.util.KeyFormattingUtils;
|
||||||
import org.sufficientlysecure.keychain.ui.util.Notify;
|
import org.sufficientlysecure.keychain.ui.util.Notify;
|
||||||
import org.sufficientlysecure.keychain.ui.util.QrCodeUtils;
|
import org.sufficientlysecure.keychain.ui.util.QrCodeUtils;
|
||||||
@ -275,8 +278,18 @@ public class ViewKeyActivity extends BaseActivity implements
|
|||||||
|
|
||||||
private void showQrCodeDialog() {
|
private void showQrCodeDialog() {
|
||||||
Intent qrCodeIntent = new Intent(this, QrCodeViewActivity.class);
|
Intent qrCodeIntent = new Intent(this, QrCodeViewActivity.class);
|
||||||
|
|
||||||
|
// create the transition animation - the images in the layouts
|
||||||
|
// of both activities are defined with android:transitionName="qr_code"
|
||||||
|
Bundle opts = null;
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||||
|
ActivityOptions options = ActivityOptions
|
||||||
|
.makeSceneTransitionAnimation(this, mQrCodeLayout, "qr_code");
|
||||||
|
opts = options.toBundle();
|
||||||
|
}
|
||||||
|
|
||||||
qrCodeIntent.setData(mDataUri);
|
qrCodeIntent.setData(mDataUri);
|
||||||
startActivity(qrCodeIntent);
|
ActivityCompat.startActivity(this, qrCodeIntent, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void exportToFile(Uri dataUri, ExportHelper exportHelper, ProviderHelper providerHelper)
|
private void exportToFile(Uri dataUri, ExportHelper exportHelper, ProviderHelper providerHelper)
|
||||||
@ -602,7 +615,8 @@ public class ViewKeyActivity extends BaseActivity implements
|
|||||||
if (isRevoked) {
|
if (isRevoked) {
|
||||||
mStatusText.setText(R.string.view_key_revoked);
|
mStatusText.setText(R.string.view_key_revoked);
|
||||||
mStatusImage.setVisibility(View.VISIBLE);
|
mStatusImage.setVisibility(View.VISIBLE);
|
||||||
KeyFormattingUtils.setStatusImage(this, mStatusImage, mStatusText, KeyFormattingUtils.STATE_REVOKED, R.color.icons, true);
|
KeyFormattingUtils.setStatusImage(this, mStatusImage, mStatusText,
|
||||||
|
KeyFormattingUtils.STATE_REVOKED, R.color.icons, true);
|
||||||
color = getResources().getColor(R.color.android_red_light);
|
color = getResources().getColor(R.color.android_red_light);
|
||||||
|
|
||||||
mActionEncryptFile.setVisibility(View.GONE);
|
mActionEncryptFile.setVisibility(View.GONE);
|
||||||
@ -620,7 +634,8 @@ public class ViewKeyActivity extends BaseActivity implements
|
|||||||
mActionEdit.setVisibility(View.GONE);
|
mActionEdit.setVisibility(View.GONE);
|
||||||
}
|
}
|
||||||
mStatusImage.setVisibility(View.VISIBLE);
|
mStatusImage.setVisibility(View.VISIBLE);
|
||||||
KeyFormattingUtils.setStatusImage(this, mStatusImage, mStatusText, KeyFormattingUtils.STATE_EXPIRED, R.color.icons, true);
|
KeyFormattingUtils.setStatusImage(this, mStatusImage, mStatusText,
|
||||||
|
KeyFormattingUtils.STATE_EXPIRED, R.color.icons, true);
|
||||||
color = getResources().getColor(R.color.android_red_light);
|
color = getResources().getColor(R.color.android_red_light);
|
||||||
|
|
||||||
mActionEncryptFile.setVisibility(View.GONE);
|
mActionEncryptFile.setVisibility(View.GONE);
|
||||||
@ -636,6 +651,26 @@ public class ViewKeyActivity extends BaseActivity implements
|
|||||||
loadQrCode(fingerprint);
|
loadQrCode(fingerprint);
|
||||||
mQrCodeLayout.setVisibility(View.VISIBLE);
|
mQrCodeLayout.setVisibility(View.VISIBLE);
|
||||||
|
|
||||||
|
// and place leftOf qr code
|
||||||
|
RelativeLayout.LayoutParams nameParams = (RelativeLayout.LayoutParams)
|
||||||
|
mName.getLayoutParams();
|
||||||
|
// remove right margin
|
||||||
|
nameParams.setMargins(FormattingUtils.dpToPx(this, 48), 0, 0, 0);
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
|
||||||
|
nameParams.setMarginEnd(0);
|
||||||
|
}
|
||||||
|
nameParams.addRule(RelativeLayout.LEFT_OF, R.id.view_key_qr_code_layout);
|
||||||
|
mName.setLayoutParams(nameParams);
|
||||||
|
|
||||||
|
RelativeLayout.LayoutParams statusParams = (RelativeLayout.LayoutParams)
|
||||||
|
mStatusText.getLayoutParams();
|
||||||
|
statusParams.setMargins(FormattingUtils.dpToPx(this, 48), 0, 0, 0);
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
|
||||||
|
statusParams.setMarginEnd(0);
|
||||||
|
}
|
||||||
|
statusParams.addRule(RelativeLayout.LEFT_OF, R.id.view_key_qr_code_layout);
|
||||||
|
mStatusText.setLayoutParams(statusParams);
|
||||||
|
|
||||||
mActionEncryptFile.setVisibility(View.VISIBLE);
|
mActionEncryptFile.setVisibility(View.VISIBLE);
|
||||||
mActionEncryptText.setVisibility(View.VISIBLE);
|
mActionEncryptText.setVisibility(View.VISIBLE);
|
||||||
mActionVerify.setVisibility(View.GONE);
|
mActionVerify.setVisibility(View.GONE);
|
||||||
@ -651,7 +686,8 @@ public class ViewKeyActivity extends BaseActivity implements
|
|||||||
if (isVerified) {
|
if (isVerified) {
|
||||||
mStatusText.setText(R.string.view_key_verified);
|
mStatusText.setText(R.string.view_key_verified);
|
||||||
mStatusImage.setVisibility(View.VISIBLE);
|
mStatusImage.setVisibility(View.VISIBLE);
|
||||||
KeyFormattingUtils.setStatusImage(this, mStatusImage, mStatusText, KeyFormattingUtils.STATE_VERIFIED, R.color.icons, true);
|
KeyFormattingUtils.setStatusImage(this, mStatusImage, mStatusText,
|
||||||
|
KeyFormattingUtils.STATE_VERIFIED, R.color.icons, true);
|
||||||
color = getResources().getColor(R.color.primary);
|
color = getResources().getColor(R.color.primary);
|
||||||
photoTask.execute(fingerprint);
|
photoTask.execute(fingerprint);
|
||||||
|
|
||||||
@ -660,7 +696,8 @@ public class ViewKeyActivity extends BaseActivity implements
|
|||||||
} else {
|
} else {
|
||||||
mStatusText.setText(R.string.view_key_unverified);
|
mStatusText.setText(R.string.view_key_unverified);
|
||||||
mStatusImage.setVisibility(View.VISIBLE);
|
mStatusImage.setVisibility(View.VISIBLE);
|
||||||
KeyFormattingUtils.setStatusImage(this, mStatusImage, mStatusText, KeyFormattingUtils.STATE_UNVERIFIED, R.color.icons, true);
|
KeyFormattingUtils.setStatusImage(this, mStatusImage, mStatusText,
|
||||||
|
KeyFormattingUtils.STATE_UNVERIFIED, R.color.icons, true);
|
||||||
color = getResources().getColor(R.color.android_orange_light);
|
color = getResources().getColor(R.color.android_orange_light);
|
||||||
|
|
||||||
mActionVerify.setVisibility(View.VISIBLE);
|
mActionVerify.setVisibility(View.VISIBLE);
|
||||||
|
@ -24,18 +24,12 @@ import android.text.style.StrikethroughSpan;
|
|||||||
|
|
||||||
public class FormattingUtils {
|
public class FormattingUtils {
|
||||||
|
|
||||||
public static SpannableStringBuilder strikeOutText(CharSequence text) {
|
|
||||||
SpannableStringBuilder sb = new SpannableStringBuilder(text);
|
|
||||||
sb.setSpan(new StrikethroughSpan(), 0, text.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE);
|
|
||||||
return sb;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static int dpToPx(Context context, int dp) {
|
public static int dpToPx(Context context, int dp) {
|
||||||
return (int) ((dp * context.getResources().getDisplayMetrics().density) + 0.5);
|
return (int) ((dp * context.getResources().getDisplayMetrics().density) + 0.5f);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static int pxToDp(Context context, int px) {
|
public static int pxToDp(Context context, int px) {
|
||||||
return (int) ((px / context.getResources().getDisplayMetrics().density) + 0.5);
|
return (int) ((px / context.getResources().getDisplayMetrics().density) + 0.5f);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:card_view="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent">
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
@ -13,12 +15,23 @@
|
|||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical">
|
android:orientation="vertical">
|
||||||
|
|
||||||
<ImageView
|
<android.support.v7.widget.CardView
|
||||||
android:id="@+id/qr_code_image"
|
android:id="@+id/qr_code_image_layout"
|
||||||
android:padding="32dp"
|
android:transitionName="qr_code"
|
||||||
|
android:layout_margin="32dp"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="wrap_content"
|
||||||
style="@style/SelectableItem" />
|
card_view:cardBackgroundColor="@android:color/white"
|
||||||
|
card_view:cardUseCompatPadding="true"
|
||||||
|
card_view:cardCornerRadius="4dp">
|
||||||
|
|
||||||
|
<org.sufficientlysecure.keychain.ui.widget.AspectRatioImageView
|
||||||
|
android:id="@+id/qr_code_image"
|
||||||
|
app:aspectRatioEnabled="true"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
style="?android:attr/borderlessButtonStyle" />
|
||||||
|
</android.support.v7.widget.CardView>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</RelativeLayout>
|
</RelativeLayout>
|
@ -56,9 +56,7 @@
|
|||||||
android:text=""
|
android:text=""
|
||||||
android:textColor="@color/icons"
|
android:textColor="@color/icons"
|
||||||
android:textAppearance="?android:attr/textAppearanceLarge"
|
android:textAppearance="?android:attr/textAppearanceLarge"
|
||||||
android:layout_above="@+id/view_key_status"
|
android:layout_above="@+id/view_key_status" />
|
||||||
android:layout_toLeftOf="@+id/view_key_qr_code_layout"
|
|
||||||
android:layout_toStartOf="@+id/view_key_qr_code_layout" />
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/view_key_status"
|
android:id="@+id/view_key_status"
|
||||||
@ -71,9 +69,7 @@
|
|||||||
android:text=""
|
android:text=""
|
||||||
android:textColor="@color/tab_text"
|
android:textColor="@color/tab_text"
|
||||||
android:textAppearance="?android:attr/textAppearanceSmall"
|
android:textAppearance="?android:attr/textAppearanceSmall"
|
||||||
android:layout_above="@+id/toolbar2"
|
android:layout_above="@+id/toolbar2" />
|
||||||
android:layout_toLeftOf="@+id/view_key_qr_code_layout"
|
|
||||||
android:layout_toStartOf="@+id/view_key_qr_code_layout" />
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/toolbar2"
|
android:id="@+id/toolbar2"
|
||||||
@ -135,8 +131,9 @@
|
|||||||
|
|
||||||
<android.support.v7.widget.CardView
|
<android.support.v7.widget.CardView
|
||||||
android:id="@+id/view_key_qr_code_layout"
|
android:id="@+id/view_key_qr_code_layout"
|
||||||
android:visibility="gone"
|
android:transitionName="qr_code"
|
||||||
android:layout_below="@id/toolbar"
|
android:visibility="visible"
|
||||||
|
android:layout_above="@id/toolbar2"
|
||||||
android:layout_alignParentRight="true"
|
android:layout_alignParentRight="true"
|
||||||
android:layout_alignParentEnd="true"
|
android:layout_alignParentEnd="true"
|
||||||
android:layout_marginRight="20dp"
|
android:layout_marginRight="20dp"
|
||||||
|
@ -7,5 +7,5 @@
|
|||||||
<dimen name="statusbar_height">24dp</dimen>
|
<dimen name="statusbar_height">24dp</dimen>
|
||||||
<!-- 120dp + statusbar_height -->
|
<!-- 120dp + statusbar_height -->
|
||||||
<dimen name="big_toolbar">141dp</dimen>
|
<dimen name="big_toolbar">141dp</dimen>
|
||||||
<dimen name="huge_toolbar">233dp</dimen>
|
<dimen name="huge_toolbar">243dp</dimen>
|
||||||
</resources>
|
</resources>
|
@ -4,10 +4,12 @@
|
|||||||
<style name="KeychainTheme" parent="KeychainTheme.Base">
|
<style name="KeychainTheme" parent="KeychainTheme.Base">
|
||||||
<item name="android:windowTranslucentStatus">true</item>
|
<item name="android:windowTranslucentStatus">true</item>
|
||||||
|
|
||||||
|
<!-- enable window content transitions -->
|
||||||
<item name="android:windowContentTransitions">true</item>
|
<item name="android:windowContentTransitions">true</item>
|
||||||
<item name="android:windowAllowEnterTransitionOverlap">true</item>
|
<item name="android:windowAllowEnterTransitionOverlap">true</item>
|
||||||
<item name="android:windowAllowReturnTransitionOverlap">true</item>
|
<item name="android:windowAllowReturnTransitionOverlap">true</item>
|
||||||
<item name="android:windowSharedElementEnterTransition">@android:transition/move</item>
|
<item name="android:windowSharedElementEnterTransition">@android:transition/move</item>
|
||||||
<item name="android:windowSharedElementExitTransition">@android:transition/move</item>
|
<item name="android:windowSharedElementExitTransition">@android:transition/move</item>
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
@ -3,5 +3,5 @@
|
|||||||
<!-- on Android < 5, we do not color the status bar, thus 0dp! -->
|
<!-- on Android < 5, we do not color the status bar, thus 0dp! -->
|
||||||
<dimen name="statusbar_height">0dp</dimen>
|
<dimen name="statusbar_height">0dp</dimen>
|
||||||
<dimen name="big_toolbar">120dp</dimen>
|
<dimen name="big_toolbar">120dp</dimen>
|
||||||
<dimen name="huge_toolbar">212dp</dimen>
|
<dimen name="huge_toolbar">222dp</dimen>
|
||||||
</resources>
|
</resources>
|
Loading…
Reference in New Issue
Block a user