Refactor: Made the backup system better so users chose the location.
Signed-off-by: HeCodes2Much <wayne6324@gmail.com>
This commit is contained in:
@@ -343,151 +343,6 @@ fun Context.isWorkProfileEnabled(): Boolean {
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.backupSharedPreferences(backupFileNames: Array<String>) {
|
||||
// Check if external storage is writable
|
||||
if (!isExternalStorageWritable()) {
|
||||
showLongToast("External storage is not writable.")
|
||||
return
|
||||
}
|
||||
|
||||
val downloadDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||
val backupDir = File(downloadDir, Constants.PACKAGE_NAME)
|
||||
|
||||
// Ensure the directory exists, create it if necessary
|
||||
if (!backupDir.exists()) {
|
||||
backupDir.mkdirs()
|
||||
}
|
||||
|
||||
for (backupFileName in backupFileNames) {
|
||||
val sharedPreferences: SharedPreferences =
|
||||
this.getSharedPreferences(backupFileName, Context.MODE_PRIVATE)
|
||||
val allPrefs = sharedPreferences.all
|
||||
|
||||
val backupFile = File(backupDir, "$backupFileName.xml")
|
||||
|
||||
try {
|
||||
backupFile.bufferedWriter().use { writer ->
|
||||
writer.write("<?xml version='1.0' encoding='utf-8' standalone='yes' ?>\n")
|
||||
writer.write("<map>\n")
|
||||
|
||||
// Loop through all preferences
|
||||
for ((key, value) in allPrefs) {
|
||||
if (value != null) {
|
||||
val valueString = value.toString().replace("'", "'")
|
||||
val line = when (value) {
|
||||
is Boolean, is Int, is Float, is Long, is String -> "\t<${value::class.simpleName!!.lowercase()} name='$key' value='$valueString' />\n"
|
||||
else -> null
|
||||
}
|
||||
line?.let {
|
||||
writer.write(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writer.write("</map>")
|
||||
}
|
||||
showLongToast("Backup for $backupFileName completed successfully.")
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
showLongToast("Failed to backup SharedPreferences $backupFileName: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun isExternalStorageWritable(): Boolean {
|
||||
return Environment.getExternalStorageState() == Environment.MEDIA_MOUNTED
|
||||
}
|
||||
|
||||
fun Context.restoreSharedPreferences(backupFileNames: Array<String>) {
|
||||
// Check if external storage is readable
|
||||
if (!isExternalStorageReadable()) {
|
||||
showLongToast("External storage is not readable.")
|
||||
return
|
||||
}
|
||||
|
||||
val downloadDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||
val backupDir = File(downloadDir, Constants.PACKAGE_NAME)
|
||||
|
||||
for (backupFileName in backupFileNames) {
|
||||
val backupFile = File(backupDir, "$backupFileName.xml")
|
||||
|
||||
if (backupFile.exists()) {
|
||||
try {
|
||||
FileInputStream(backupFile).use { fileInputStream ->
|
||||
val factory = XmlPullParserFactory.newInstance()
|
||||
factory.isNamespaceAware = true
|
||||
val xpp = factory.newPullParser()
|
||||
xpp.setInput(fileInputStream, null)
|
||||
|
||||
var eventType = xpp.eventType
|
||||
var key: String? = null
|
||||
var value: Any? = null
|
||||
|
||||
while (eventType != XmlPullParser.END_DOCUMENT) {
|
||||
when (eventType) {
|
||||
XmlPullParser.START_TAG -> {
|
||||
when (xpp.name) {
|
||||
"boolean", "int", "float", "long", "string" -> {
|
||||
key = xpp.getAttributeValue(null, "name")
|
||||
val valueString = xpp.getAttributeValue(null, "value")
|
||||
value = when (xpp.name) {
|
||||
"boolean" -> valueString.toBoolean()
|
||||
"int" -> valueString.toInt()
|
||||
"float" -> valueString.toFloat()
|
||||
"long" -> valueString.toLong()
|
||||
else -> valueString
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
XmlPullParser.END_TAG -> {
|
||||
when (xpp.name) {
|
||||
"boolean", "int", "float", "long", "string" -> {
|
||||
if (key != null && value != null) {
|
||||
saveToSharedPreferences(backupFileName, key, value)
|
||||
key = null
|
||||
value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
eventType = xpp.next()
|
||||
}
|
||||
}
|
||||
showLongToast("Restore for $backupFileName completed successfully.")
|
||||
} catch (e: Exception) {
|
||||
Log.e("Exception", e.toString())
|
||||
showLongToast("Failed to restore SharedPreferences $backupFileName: ${e.message}")
|
||||
}
|
||||
} else {
|
||||
showLongToast("Backup file for $backupFileName does not exist.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.saveToSharedPreferences(prefsFileName: String, key: String, value: Any) {
|
||||
val sharedPreferences: SharedPreferences = this.getSharedPreferences(prefsFileName, Context.MODE_PRIVATE)
|
||||
val editor = sharedPreferences.edit()
|
||||
|
||||
when (value) {
|
||||
is Boolean -> editor.putBoolean(key, value)
|
||||
is Int -> editor.putInt(key, value)
|
||||
is Float -> editor.putFloat(key, value)
|
||||
is Long -> editor.putLong(key, value)
|
||||
is String -> editor.putString(key, value)
|
||||
}
|
||||
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
private fun isExternalStorageReadable(): Boolean {
|
||||
val state = Environment.getExternalStorageState()
|
||||
return Environment.MEDIA_MOUNTED == state || Environment.MEDIA_MOUNTED_READ_ONLY == state
|
||||
}
|
||||
|
||||
fun Context.hasInternetPermission(): Boolean {
|
||||
val permission = Manifest.permission.INTERNET
|
||||
val result = ContextCompat.checkSelfPermission(this, permission)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.github.droidworksstudio.launcher.helper
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
@@ -16,8 +17,6 @@ import android.view.WindowInsets
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.widget.LinearLayoutCompat
|
||||
import androidx.navigation.NavOptions
|
||||
import com.github.droidworksstudio.common.backupSharedPreferences
|
||||
import com.github.droidworksstudio.common.restoreSharedPreferences
|
||||
import com.github.droidworksstudio.common.showLongToast
|
||||
import com.github.droidworksstudio.launcher.BuildConfig
|
||||
import com.github.droidworksstudio.launcher.utils.Constants
|
||||
@@ -29,7 +28,10 @@ import com.google.gson.Gson
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.net.UnknownHostException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -192,14 +194,25 @@ class AppHelper @Inject constructor() {
|
||||
context.startActivity(Intent.createChooser(emailIntent, "Choose Mail Application"))
|
||||
}
|
||||
|
||||
fun backupSharedPreferences(context: Context) {
|
||||
val backupFileNames = arrayOf(Constants.PREFS_FILENAME, Constants.WEATHER_PREFS)
|
||||
context.backupSharedPreferences(backupFileNames)
|
||||
fun storeFile(activity: Activity) {
|
||||
// Generate a unique filename with a timestamp
|
||||
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
|
||||
val fileName = "backup_$timeStamp.json"
|
||||
|
||||
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "application/json"
|
||||
putExtra(Intent.EXTRA_TITLE, fileName)
|
||||
}
|
||||
activity.startActivityForResult(intent, Constants.BACKUP_WRITE, null)
|
||||
}
|
||||
|
||||
fun restoreSharedPreferences(context: Context) {
|
||||
val backupFileNames = arrayOf(Constants.PREFS_FILENAME, Constants.WEATHER_PREFS)
|
||||
context.restoreSharedPreferences(backupFileNames)
|
||||
fun loadFile(activity: Activity) {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "application/json"
|
||||
}
|
||||
activity.startActivityForResult(intent, Constants.BACKUP_READ, null)
|
||||
}
|
||||
|
||||
fun getActionType(actionType: Constants.Swipe): NavOptions {
|
||||
|
||||
@@ -3,14 +3,17 @@ package com.github.droidworksstudio.launcher.helper
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.content.res.Configuration
|
||||
import android.util.Log
|
||||
import android.view.Gravity
|
||||
import com.github.droidworksstudio.launcher.utils.Constants
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
|
||||
class PreferenceHelper @Inject constructor(@ApplicationContext context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences(Constants.PREFS_FILENAME, 0)
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences(Constants.PACKAGE_PREFS, 0)
|
||||
|
||||
private val setColor = getColor(context)
|
||||
|
||||
@@ -257,4 +260,45 @@ class PreferenceHelper @Inject constructor(@ApplicationContext context: Context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveToString(): String {
|
||||
val all: HashMap<String, Any?> = HashMap(prefs.all)
|
||||
return Gson().toJson(all)
|
||||
}
|
||||
|
||||
fun loadFromString(json: String) {
|
||||
val editor = prefs.edit()
|
||||
val all: HashMap<String, Any?> =
|
||||
Gson().fromJson(json, object : TypeToken<HashMap<String, Any?>>() {}.type)
|
||||
for ((key, value) in all) {
|
||||
when (value) {
|
||||
is String -> editor.putString(key, value)
|
||||
is Boolean -> editor.putBoolean(key, value)
|
||||
is Int -> editor.putInt(key, value)
|
||||
is Double -> editor.putInt(key, value.toInt()) // we store everything as int
|
||||
is Float -> editor.putInt(key, value.toInt())
|
||||
is MutableSet<*> -> {
|
||||
val list = value.filterIsInstance<String>().toSet()
|
||||
editor.putStringSet(key, list)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.d("backup error", "$value")
|
||||
}
|
||||
}
|
||||
}
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
prefs.edit().clear().apply()
|
||||
}
|
||||
|
||||
fun clearAll(context: Context) {
|
||||
val prefsLauncher: SharedPreferences = context.getSharedPreferences(Constants.PACKAGE_PREFS, 0)
|
||||
val prefsWidgets: SharedPreferences = context.getSharedPreferences(Constants.WEATHER_PREFS, 0)
|
||||
|
||||
prefsLauncher.edit().clear().apply()
|
||||
prefsWidgets.edit().clear().apply()
|
||||
}
|
||||
}
|
||||
@@ -162,15 +162,18 @@ class UpdateManagerHelper(private val fragment: Fragment) {
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (requestCode == Constants.REQUEST_INSTALL_PERMISSION) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val canInstallPackages = context.packageManager.canRequestPackageInstalls()
|
||||
if (canInstallPackages) {
|
||||
// Permission granted, proceed with installation
|
||||
installApk()
|
||||
} else {
|
||||
// Permission still not granted, handle accordingly
|
||||
context.applicationContext.showLongToast("Please allow install permission to install.")
|
||||
|
||||
when (requestCode) {
|
||||
Constants.REQUEST_INSTALL_PERMISSION -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val canInstallPackages = context.packageManager.canRequestPackageInstalls()
|
||||
if (canInstallPackages) {
|
||||
// Permission granted, proceed with installation
|
||||
installApk()
|
||||
} else {
|
||||
// Permission still not granted, handle accordingly
|
||||
context.applicationContext.showLongToast("Please allow install permission to install.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package com.github.droidworksstudio.launcher.ui.activities
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.pm.PackageManager
|
||||
@@ -33,12 +35,16 @@ import com.github.droidworksstudio.common.showLongToast
|
||||
import com.github.droidworksstudio.launcher.R
|
||||
import com.github.droidworksstudio.launcher.databinding.ActivityMainBinding
|
||||
import com.github.droidworksstudio.launcher.helper.AppHelper
|
||||
import com.github.droidworksstudio.launcher.helper.AppReloader
|
||||
import com.github.droidworksstudio.launcher.helper.PreferenceHelper
|
||||
import com.github.droidworksstudio.launcher.utils.Constants
|
||||
import com.github.droidworksstudio.launcher.viewmodel.AppViewModel
|
||||
import com.github.droidworksstudio.launcher.viewmodel.PreferenceViewModel
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.BufferedReader
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStreamReader
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
@@ -274,13 +280,62 @@ class MainActivity : AppCompatActivity() {
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode == Constants.REQUEST_LOCATION_PERMISSION_CODE) {
|
||||
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
// Permission granted, proceed with getting location
|
||||
getLocation()
|
||||
} else {
|
||||
// Permission denied, show a message to the user
|
||||
setLocationPermissionDenied(true)
|
||||
when (requestCode) {
|
||||
Constants.REQUEST_LOCATION_PERMISSION_CODE -> {
|
||||
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
// Permission granted, proceed with getting location
|
||||
getLocation()
|
||||
} else {
|
||||
// Permission denied, show a message to the user
|
||||
setLocationPermissionDenied(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
|
||||
if (resultCode != Activity.RESULT_OK) {
|
||||
// showToastLong(applicationContext, "Intent Error")
|
||||
return
|
||||
}
|
||||
|
||||
when (requestCode) {
|
||||
Constants.BACKUP_READ -> {
|
||||
data?.data?.also { uri ->
|
||||
applicationContext.contentResolver.openInputStream(uri).use { inputStream ->
|
||||
val stringBuilder = StringBuilder()
|
||||
BufferedReader(InputStreamReader(inputStream)).use { reader ->
|
||||
var line: String? = reader.readLine()
|
||||
while (line != null) {
|
||||
stringBuilder.append(line)
|
||||
line = reader.readLine()
|
||||
}
|
||||
}
|
||||
|
||||
val string = stringBuilder.toString()
|
||||
val prefs = PreferenceHelper(applicationContext)
|
||||
prefs.clear()
|
||||
prefs.loadFromString(string)
|
||||
}
|
||||
}
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
AppReloader.restartApp(applicationContext)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
Constants.BACKUP_WRITE -> {
|
||||
data?.data?.also { uri ->
|
||||
applicationContext.contentResolver.openFileDescriptor(uri, "w")?.use { file ->
|
||||
FileOutputStream(file.fileDescriptor).use { stream ->
|
||||
val text = PreferenceHelper(applicationContext).saveToString()
|
||||
stream.channel.truncate(0)
|
||||
stream.write(text.toByteArray())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,11 +95,21 @@ class SettingsFragment : Fragment(),
|
||||
binding.miscellaneousSearchEngineControl.text = preferenceHelper.searchEngines.getString(context)
|
||||
binding.miscellaneousLauncherFontsControl.text = preferenceHelper.launcherFont.getString(context)
|
||||
|
||||
updateGestureControlText(context, preferenceHelper.doubleTapAction, preferenceHelper.doubleTapApp, binding.gesturesDoubleTapControl)
|
||||
updateGestureControlText(context, preferenceHelper.swipeUpAction, preferenceHelper.swipeUpApp, binding.gesturesSwipeUpControl)
|
||||
updateGestureControlText(context, preferenceHelper.swipeDownAction, preferenceHelper.swipeDownApp, binding.gesturesSwipeDownControl)
|
||||
updateGestureControlText(context, preferenceHelper.swipeLeftAction, preferenceHelper.swipeLeftApp, binding.gesturesSwipeLeftControl)
|
||||
updateGestureControlText(context, preferenceHelper.swipeRightAction, preferenceHelper.swipeRightApp, binding.gesturesSwipeRightControl)
|
||||
val actions = listOf(
|
||||
Triple(preferenceHelper.doubleTapAction, preferenceHelper.doubleTapApp, binding.gesturesDoubleTapControl),
|
||||
Triple(preferenceHelper.swipeUpAction, preferenceHelper.swipeUpApp, binding.gesturesSwipeUpControl),
|
||||
Triple(preferenceHelper.swipeDownAction, preferenceHelper.swipeDownApp, binding.gesturesSwipeDownControl),
|
||||
Triple(preferenceHelper.swipeLeftAction, preferenceHelper.swipeLeftApp, binding.gesturesSwipeLeftControl),
|
||||
Triple(preferenceHelper.swipeRightAction, preferenceHelper.swipeRightApp, binding.gesturesSwipeRightControl)
|
||||
)
|
||||
|
||||
actions.forEach { (action, app, control) ->
|
||||
when (action) {
|
||||
Constants.Action.OpenApp -> updateGestureControlText(context, action, app, control)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Function to update UI text based on action and app name
|
||||
@@ -189,12 +199,18 @@ class SettingsFragment : Fragment(),
|
||||
}
|
||||
|
||||
binding.backupView.setOnClickListener {
|
||||
appHelper.backupSharedPreferences(requireContext())
|
||||
appHelper.storeFile(requireActivity())
|
||||
}
|
||||
|
||||
binding.clearView.setOnClickListener {
|
||||
preferenceHelper.clearAll(context)
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
AppReloader.restartApp(context)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
binding.restoreView.setOnClickListener {
|
||||
appHelper.restoreSharedPreferences(requireContext())
|
||||
AppReloader.restartApp(context)
|
||||
appHelper.loadFile(requireActivity())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,18 +8,18 @@ import com.github.droidworksstudio.launcher.R
|
||||
object Constants {
|
||||
const val PACKAGE_NAME = "app.easy.launcher"
|
||||
const val PACKAGE_NAME_DEBUG = "$PACKAGE_NAME.debug"
|
||||
const val PACKAGE_PREFS = "EasyLauncher.pref"
|
||||
|
||||
const val WIDGETS_COUNT = 2
|
||||
const val WIDGET_WEATHER = "WIDGET_WEATHER"
|
||||
const val WIDGET_BATTERY = "WIDGET_BATTERY"
|
||||
|
||||
const val WEATHER_UNITS = "WEATHER_UNITS"
|
||||
|
||||
const val WEATHER_PREFS = "EasyWeather.pref"
|
||||
const val WEATHER_UNITS = "WEATHER_UNITS"
|
||||
const val LATITUDE = "LATITUDE"
|
||||
const val LONGITUDE = "LONGITUDE"
|
||||
|
||||
const val PREFS_FILENAME = "EasyLauncher.pref"
|
||||
const val FIRST_LAUNCH = "FIRST_LAUNCH"
|
||||
const val SHOW_DATE = "SHOW_DATE"
|
||||
const val SHOW_TIME = "SHOW_TIME"
|
||||
@@ -80,7 +80,10 @@ object Constants {
|
||||
const val LAUNCHER_FONT = "LAUNCHER_FONT"
|
||||
|
||||
const val REQUEST_INSTALL_PERMISSION = 123
|
||||
const val REQUEST_LOCATION_PERMISSION_CODE = 246
|
||||
const val REQUEST_LOCATION_PERMISSION_CODE = 234
|
||||
|
||||
const val BACKUP_WRITE = 987
|
||||
const val BACKUP_READ = 876
|
||||
|
||||
const val LOCATION_DENIED = "LOCATION_DENIED"
|
||||
|
||||
|
||||
@@ -883,6 +883,15 @@
|
||||
android:layout_marginVertical="10dp"
|
||||
style="@style/TextDefaultStyle" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatTextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:id="@+id/clear_view"
|
||||
android:text="@string/settings_backups_clear"
|
||||
android:textSize="@dimen/text_large"
|
||||
android:layout_marginVertical="10dp"
|
||||
style="@style/TextDefaultStyle" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatTextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
<string name="settings_others_github">Github</string>
|
||||
|
||||
<string name="settings_backups_backup">Backup Preferences</string>
|
||||
<string name="settings_backups_clear">Clear Preferences</string>
|
||||
<string name="settings_backups_restore">Restore Preferences</string>
|
||||
|
||||
<string name="settings_reload_app_backup">Preferences backed up.</string>
|
||||
|
||||
Reference in New Issue
Block a user