feat(controlled, controller): 新增配对码生成与兑换功能
被控端:新增生成一次性配对码并弹窗展示,支持倒计时与复制;控制端:新增输入配对码兑换绑定,刷新设备列表,并在设备列表中显示在线状态和点击连接。同时添加统一 401 拦截器,令牌失效时触发重新激活。
This commit is contained in:
@@ -5,19 +5,25 @@ import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.app.AlertDialog;
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.ServiceConnection;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.media.projection.MediaProjectionManager;
|
||||
import android.os.Build;
|
||||
import android.os.CountDownTimer;
|
||||
import android.os.IBinder;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
import android.widget.AdapterView;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -29,12 +35,18 @@ import com.ttstd.controlled.accessibility.AccessibilityServiceHelper;
|
||||
import com.ttstd.controlled.base.mvvm.BaseMvvmActivity;
|
||||
import com.ttstd.controlled.databinding.ActivityMainBinding;
|
||||
import com.ttstd.controlled.activity.settings.SettingsActivity;
|
||||
import com.ttstd.controlled.network.DeviceRepository;
|
||||
import com.ttstd.controlled.network.model.PairingCodeResponse;
|
||||
import com.ttstd.controlled.service.ScreenCaptureService;
|
||||
import com.ttstd.controlled.utils.SignatureUtils;
|
||||
import com.ttstd.controlled.webrtc.WebRtcClient;
|
||||
|
||||
import org.webrtc.RendererCommon;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBinding>
|
||||
implements ScreenCaptureService.ServiceStateListener {
|
||||
|
||||
@@ -50,6 +62,10 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
|
||||
/** 是否为特权应用(系统签名或无 root 权限),缓存后避免重复在主线程执行 su 检测。 */
|
||||
private boolean isPrivileged = false;
|
||||
private AlertDialog accessibilityDialog;
|
||||
/** 配对码生成请求的订阅,用于界面销毁时释放。 */
|
||||
private Disposable pairingDisposable;
|
||||
/** 当前配对码倒计时,用于界面销毁时取消。 */
|
||||
private CountDownTimer pairingCountDown;
|
||||
|
||||
private final ServiceConnection serviceConnection = new ServiceConnection() {
|
||||
@Override
|
||||
@@ -93,6 +109,9 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
|
||||
binding.btnOpenSettings.setOnClickListener(v ->
|
||||
startActivity(new Intent(this, SettingsActivity.class)));
|
||||
|
||||
// 生成一次性配对码,供其他用户(控制端)兑换绑定
|
||||
binding.btnGeneratePairing.setOnClickListener(v -> onGeneratePairingCode());
|
||||
|
||||
updateUI(false);
|
||||
}
|
||||
|
||||
@@ -239,6 +258,96 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
|
||||
Toast.makeText(this, "屏幕共享已停止", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成一次性配对码并弹窗展示。配对码由服务端生成,明文仅回显一次,
|
||||
* 有效期默认 10 分钟;过期后需重新生成。复制按钮便于用户分享给他人。
|
||||
*/
|
||||
private void onGeneratePairingCode() {
|
||||
DeviceRepository repository = new DeviceRepository(this);
|
||||
if (pairingDisposable != null && !pairingDisposable.isDisposed()) {
|
||||
pairingDisposable.dispose();
|
||||
}
|
||||
pairingDisposable = repository.generatePairingCode()
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
this::showPairingCodeDialog,
|
||||
err -> Toast.makeText(this,
|
||||
"生成配对码失败:" + err.getMessage(), Toast.LENGTH_LONG).show()
|
||||
);
|
||||
}
|
||||
|
||||
/** 弹窗展示配对码、剩余有效期与复制按钮。 */
|
||||
private void showPairingCodeDialog(PairingCodeResponse resp) {
|
||||
if (resp == null || resp.getCode() == null || resp.getCode().isEmpty()) {
|
||||
Toast.makeText(this, "生成配对码失败:服务端未返回有效码", Toast.LENGTH_LONG).show();
|
||||
return;
|
||||
}
|
||||
final String code = resp.getCode();
|
||||
int totalSec = resp.getExpiresInSeconds() > 0 ? resp.getExpiresInSeconds() : 600;
|
||||
|
||||
TextView tvCode = new TextView(this);
|
||||
tvCode.setText(code);
|
||||
tvCode.setTextSize(32);
|
||||
tvCode.setPadding(40, 30, 40, 10);
|
||||
tvCode.setTextIsSelectable(true);
|
||||
|
||||
TextView tvTip = new TextView(this);
|
||||
tvTip.setPadding(40, 0, 40, 20);
|
||||
tvTip.setText("请在其他设备的控制端输入此配对码以完成绑定(10分钟内有效)");
|
||||
|
||||
AlertDialog dialog = new AlertDialog.Builder(this)
|
||||
.setTitle("邀请绑定 - 配对码")
|
||||
.setView(composePairingView(tvCode, tvTip))
|
||||
.setPositiveButton("复制", (d, w) -> {
|
||||
ClipboardManager cm = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
if (cm != null) {
|
||||
cm.setPrimaryClip(ClipData.newPlainText("pairing_code", code));
|
||||
Toast.makeText(this, "配对码已复制", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
})
|
||||
.setNegativeButton("关闭", (d, w) -> {
|
||||
if (pairingCountDown != null) pairingCountDown.cancel();
|
||||
d.dismiss();
|
||||
})
|
||||
.setCancelable(false)
|
||||
.create();
|
||||
|
||||
// 倒计时更新提示文案
|
||||
final long tickMs = 1000;
|
||||
pairingCountDown = new CountDownTimer(totalSec * 1000L, tickMs) {
|
||||
@Override
|
||||
public void onTick(long millisUntilFinished) {
|
||||
long sec = TimeUnit.MILLISECONDS.toSeconds(millisUntilFinished);
|
||||
tvTip.setText(String.format(Locale.getDefault(),
|
||||
"请在其他设备的控制端输入此配对码以完成绑定(剩余 %d 秒)", sec));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFinish() {
|
||||
tvTip.setText("配对码已过期,请重新生成");
|
||||
tvCode.setEnabled(false);
|
||||
}
|
||||
};
|
||||
dialog.setOnDismissListener(d -> {
|
||||
if (pairingCountDown != null) {
|
||||
pairingCountDown.cancel();
|
||||
pairingCountDown = null;
|
||||
}
|
||||
});
|
||||
dialog.show();
|
||||
pairingCountDown.start();
|
||||
}
|
||||
|
||||
/** 组合配对码展示视图(码 + 提示)。 */
|
||||
private android.widget.LinearLayout composePairingView(TextView tvCode, TextView tvTip) {
|
||||
android.widget.LinearLayout layout = new android.widget.LinearLayout(this);
|
||||
layout.setOrientation(android.widget.LinearLayout.VERTICAL);
|
||||
layout.addView(tvCode);
|
||||
layout.addView(tvTip);
|
||||
return layout;
|
||||
}
|
||||
|
||||
private void setupLocalPreview() {
|
||||
if (isBound && screenCaptureService != null && !isLocalViewInitialized) {
|
||||
WebRtcClient webRtcClient = screenCaptureService.getWebRtcClient();
|
||||
@@ -262,6 +371,14 @@ public class MainActivity extends BaseMvvmActivity<MainViewModel, ActivityMainBi
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
if (pairingDisposable != null && !pairingDisposable.isDisposed()) {
|
||||
pairingDisposable.dispose();
|
||||
pairingDisposable = null;
|
||||
}
|
||||
if (pairingCountDown != null) {
|
||||
pairingCountDown.cancel();
|
||||
pairingCountDown = null;
|
||||
}
|
||||
if (accessibilityDialog != null && accessibilityDialog.isShowing()) {
|
||||
accessibilityDialog.dismiss();
|
||||
accessibilityDialog = null;
|
||||
|
||||
@@ -9,6 +9,8 @@ import androidx.multidex.MultiDex;
|
||||
|
||||
import com.tencent.mmkv.MMKV;
|
||||
|
||||
import com.ttstd.controlled.network.ChainContext;
|
||||
|
||||
public class BaseApplication extends Application {
|
||||
private static final String TAG = "BaseApplication";
|
||||
|
||||
@@ -33,6 +35,7 @@ public class BaseApplication extends Application {
|
||||
super.onCreate();
|
||||
Log.e(TAG, "onCreate: ");
|
||||
mAppContext = getApplicationContext();
|
||||
ChainContext.init(this);
|
||||
|
||||
init();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.ttstd.controlled.network;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
/**
|
||||
* 在 OkHttp 拦截器(无 Activity/Context 引用)中获取 Application Context 的简易持有者。
|
||||
* 由 {@code BaseApplication} 在 onCreate 中初始化。
|
||||
*/
|
||||
public final class ChainContext {
|
||||
|
||||
private static Context sAppContext;
|
||||
|
||||
private ChainContext() {
|
||||
}
|
||||
|
||||
public static void init(Context context) {
|
||||
sAppContext = context.getApplicationContext();
|
||||
}
|
||||
|
||||
public static Context getAppContext() {
|
||||
return sAppContext;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.ttstd.controlled.network;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.ttstd.controlled.network.model.PairingCodeResponse;
|
||||
import com.ttstd.controlled.network.model.ProvisionRequest;
|
||||
import com.ttstd.controlled.network.model.ProvisionResponse;
|
||||
import com.ttstd.controlled.network.model.TokenRequest;
|
||||
@@ -37,4 +38,10 @@ public interface DeviceApi {
|
||||
*/
|
||||
@GET("/api/client/turn-credentials")
|
||||
Single<JsonObject> turnCredentials(@Header("Authorization") String bearerToken);
|
||||
|
||||
/**
|
||||
* 生成一次性配对码,供其他用户(控制端)兑换绑定。需携带设备令牌。
|
||||
*/
|
||||
@POST("/api/client/device/pairing-code")
|
||||
Single<PairingCodeResponse> pairingCode(@Header("Authorization") String bearerToken);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.text.TextUtils;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.ttstd.controlled.BuildConfig;
|
||||
import com.ttstd.controlled.network.model.PairingCodeResponse;
|
||||
import com.ttstd.controlled.network.model.ProvisionRequest;
|
||||
import com.ttstd.controlled.network.model.ProvisionResponse;
|
||||
import com.ttstd.controlled.network.model.TokenRequest;
|
||||
@@ -13,6 +14,7 @@ import com.ttstd.controlled.network.model.TokenResponse;
|
||||
import com.ttstd.controlled.utils.DeviceSecretStore;
|
||||
import com.ttstd.controlled.utils.DeviceUtils;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
@@ -66,7 +68,8 @@ public class DeviceRepository {
|
||||
return exchangeToken(store.getDeviceUid(), store.getDeviceSecret());
|
||||
}
|
||||
return provision().flatMap(resp -> exchangeToken(resp.getDeviceUid(), resp.getDeviceSecret()));
|
||||
}).subscribeOn(Schedulers.io());
|
||||
}).subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread());
|
||||
}
|
||||
|
||||
/** 强制重新激活:清空本地凭据后走完整 provision 流程。用于激活页「刷新重试」。 */
|
||||
@@ -74,7 +77,8 @@ public class DeviceRepository {
|
||||
return Single.defer(() -> {
|
||||
store.clear();
|
||||
return provision().flatMap(resp -> exchangeToken(resp.getDeviceUid(), resp.getDeviceSecret()));
|
||||
}).subscribeOn(Schedulers.io());
|
||||
}).subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread());
|
||||
}
|
||||
|
||||
/** 第一步:用 SN + HMAC 证明出厂身份,成功后立即加密落盘。 */
|
||||
@@ -120,6 +124,21 @@ public class DeviceRepository {
|
||||
}
|
||||
return api.turnCredentials("Bearer " + accessToken)
|
||||
.onErrorReturnItem(new JsonObject())
|
||||
.subscribeOn(Schedulers.io());
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread());
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成一次性配对码,供其他用户(控制端)兑换绑定。
|
||||
* 需设备已激活且持有有效的 accessToken。
|
||||
*/
|
||||
public Single<PairingCodeResponse> generatePairingCode() {
|
||||
String token = getAccessToken();
|
||||
if (TextUtils.isEmpty(token)) {
|
||||
return Single.error(new IllegalStateException("设备尚未激活,无法生成配对码"));
|
||||
}
|
||||
return api.pairingCode("Bearer " + token)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.google.gson.FieldNamingPolicy;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.ttstd.controlled.BuildConfig;
|
||||
import com.ttstd.controlled.network.UnauthorizedInterceptor;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@@ -35,7 +36,8 @@ public final class RetrofitClient {
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.writeTimeout(15, TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(true);
|
||||
.retryOnConnectionFailure(true)
|
||||
.addInterceptor(new UnauthorizedInterceptor());
|
||||
if (BuildConfig.DEBUG) {
|
||||
HttpLoggingInterceptor logging = new HttpLoggingInterceptor();
|
||||
logging.setLevel(HttpLoggingInterceptor.Level.BODY);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.ttstd.controlled.network;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
|
||||
|
||||
import com.ttstd.controlled.utils.DeviceSecretStore;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import okhttp3.Interceptor;
|
||||
import okhttp3.Response;
|
||||
|
||||
/**
|
||||
* 被控端统一 401 拦截器。
|
||||
*
|
||||
* <p>被控端没有「登录页」概念,401 表示服务端下发的 accessToken 已失效。此时应清理本地
|
||||
* 激活凭据(deviceUid / deviceSecret / accessToken),并通过本地广播通知
|
||||
* {@code ScreenCaptureService} 重新走激活流程(provision / token 换取)。
|
||||
*
|
||||
* <p>注意:令牌失效可能伴随大量并发请求同时返回 401,这里用 {@code notified} 哨兵保证
|
||||
* 一次失效只清一次凭据、只发一次重激活广播。
|
||||
*/
|
||||
public class UnauthorizedInterceptor implements Interceptor {
|
||||
|
||||
/** ScreenCaptureService 监听此本地广播以触发重新激活。 */
|
||||
public static final String ACTION_TOKEN_INVALID = "com.ttstd.controlled.action.TOKEN_INVALID";
|
||||
|
||||
private static final String TAG = "UnauthorizedInterceptor";
|
||||
private static volatile boolean notified = false;
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Response intercept(@NonNull Chain chain) throws IOException {
|
||||
Response response = chain.proceed(chain.request());
|
||||
if (response.code() == 401) {
|
||||
Log.w(TAG, "收到 401 未授权响应,清理激活凭据并触发重新激活");
|
||||
if (!notified) {
|
||||
notified = true;
|
||||
// 清理本地激活凭据,强制重新激活。
|
||||
Context appCtx = ChainContext.getAppContext();
|
||||
if (appCtx != null) {
|
||||
new DeviceSecretStore(appCtx).clear();
|
||||
}
|
||||
if (appCtx != null) {
|
||||
LocalBroadcastManager.getInstance(appCtx)
|
||||
.sendBroadcast(new Intent(ACTION_TOKEN_INVALID));
|
||||
}
|
||||
notified = false;
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.ttstd.controlled.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/**
|
||||
* 被控端生成的一次性配对码响应。
|
||||
* 对应服务端 {@code POST /api/client/device/pairing-code}。
|
||||
*/
|
||||
public class PairingCodeResponse {
|
||||
|
||||
/** 一次性配对码,明文仅回显一次,10 分钟有效。 */
|
||||
@SerializedName("code")
|
||||
private String code;
|
||||
|
||||
/** 有效期(秒)。 */
|
||||
@SerializedName("expiresInSeconds")
|
||||
private int expiresInSeconds;
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public void setCode(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public int getExpiresInSeconds() {
|
||||
return expiresInSeconds;
|
||||
}
|
||||
|
||||
public void setExpiresInSeconds(int expiresInSeconds) {
|
||||
this.expiresInSeconds = expiresInSeconds;
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,10 @@ import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.Service;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.pm.ServiceInfo;
|
||||
import android.media.projection.MediaProjection;
|
||||
import android.os.Binder;
|
||||
@@ -40,6 +42,7 @@ import com.ttstd.controlled.input.RootShellInputUtils;
|
||||
import com.ttstd.controlled.input.ShellInputUtils;
|
||||
import com.ttstd.controlled.input.SystemInputUtils;
|
||||
import com.ttstd.controlled.signaling.SignalMessage;
|
||||
import com.ttstd.controlled.network.UnauthorizedInterceptor;
|
||||
import com.ttstd.controlled.signaling.WebSocketClient;
|
||||
import com.ttstd.controlled.utils.AuthSettings;
|
||||
import com.ttstd.controlled.utils.InputSettings;
|
||||
@@ -157,8 +160,30 @@ public class ScreenCaptureService extends BaseService {
|
||||
|
||||
instance = this;
|
||||
createNotificationChannel();
|
||||
|
||||
// 401 拦截器发来的令牌失效广播:清理本地令牌后重新走激活流程。
|
||||
IntentFilter filter = new IntentFilter(UnauthorizedInterceptor.ACTION_TOKEN_INVALID);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
registerReceiver(tokenInvalidReceiver, filter, Context.RECEIVER_NOT_EXPORTED);
|
||||
} else {
|
||||
registerReceiver(tokenInvalidReceiver, filter);
|
||||
}
|
||||
}
|
||||
|
||||
/** 监听 REST 接口 401,触发重新激活。 */
|
||||
private final BroadcastReceiver tokenInvalidReceiver = new BroadcastReceiver() {
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
Log.w(TAG, "收到令牌失效广播,清理 accessToken 并重新激活");
|
||||
accessToken = null;
|
||||
if (pendingServerUrl != null) {
|
||||
pendingServerUrl = null;
|
||||
}
|
||||
// 交由 ViewModel 走 provision / token 重新激活并连接。
|
||||
mViewModel.activate();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 订阅 ViewModel 的激活 / 令牌事件。
|
||||
* BaseService 实现了 LifecycleOwner,observe 会随 Service 销毁自动解绑。
|
||||
@@ -718,6 +743,11 @@ public class ScreenCaptureService extends BaseService {
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
try {
|
||||
unregisterReceiver(tokenInvalidReceiver);
|
||||
} catch (IllegalArgumentException ignore) {
|
||||
// 未注册或已注销时忽略
|
||||
}
|
||||
isShuttingDown = true;
|
||||
sResultCode = Activity.RESULT_CANCELED;
|
||||
sResultData = null;
|
||||
|
||||
@@ -39,6 +39,14 @@
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<!-- 邀请绑定:生成一次性配对码,供其他用户(控制端)兑换绑定 -->
|
||||
<Button
|
||||
android:id="@+id/btn_generate_pairing"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:text="@string/btn_generate_pairing" />
|
||||
|
||||
<!-- 安全与输入设置入口 -->
|
||||
<Button
|
||||
android:id="@+id/btn_open_settings"
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
<!-- 设置页面 -->
|
||||
<string name="settings_title">安全与输入设置</string>
|
||||
<string name="btn_open_settings">安全与输入设置</string>
|
||||
<string name="btn_generate_pairing">生成配对码(邀请绑定)</string>
|
||||
|
||||
<!-- 模拟点击方式 -->
|
||||
<string name="input_method_label">模拟点击方式</string>
|
||||
|
||||
Reference in New Issue
Block a user