mirror of
https://github.com/revanced/revanced-manager
synced 2024-05-14 13:56:57 +02:00
feat(installer): apk signing and installation
This commit is contained in:
parent
762bfa8514
commit
52ab7937bd
@ -81,6 +81,10 @@ dependencies {
|
|||||||
// ReVanced
|
// ReVanced
|
||||||
implementation("app.revanced:revanced-patcher:7.1.0")
|
implementation("app.revanced:revanced-patcher:7.1.0")
|
||||||
|
|
||||||
|
// Signing
|
||||||
|
implementation("com.android.tools.build:apksig:8.1.0-beta02")
|
||||||
|
implementation("org.bouncycastle:bcpkix-jdk15on:1.70")
|
||||||
|
|
||||||
// Koin
|
// Koin
|
||||||
val koinVersion = "3.4.0"
|
val koinVersion = "3.4.0"
|
||||||
implementation("io.insert-koin:koin-android:$koinVersion")
|
implementation("io.insert-koin:koin-android:$koinVersion")
|
||||||
|
@ -75,12 +75,20 @@ class MainActivity : ComponentActivity() {
|
|||||||
vm = getViewModel { parametersOf(destination.input) }
|
vm = getViewModel { parametersOf(destination.input) }
|
||||||
)
|
)
|
||||||
|
|
||||||
is Destination.Installer -> InstallerScreen(getViewModel {
|
is Destination.Installer -> InstallerScreen(
|
||||||
|
onBackClick = {
|
||||||
|
with(navController) {
|
||||||
|
popAll()
|
||||||
|
navigate(Destination.Dashboard)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
vm = getViewModel {
|
||||||
parametersOf(
|
parametersOf(
|
||||||
destination.input,
|
destination.input,
|
||||||
destination.selectedPatches
|
destination.selectedPatches
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ package app.revanced.manager.compose.di
|
|||||||
|
|
||||||
import app.revanced.manager.compose.network.service.HttpService
|
import app.revanced.manager.compose.network.service.HttpService
|
||||||
import app.revanced.manager.compose.network.service.ReVancedService
|
import app.revanced.manager.compose.network.service.ReVancedService
|
||||||
|
import app.revanced.manager.compose.patcher.SignerService
|
||||||
import org.koin.core.module.dsl.singleOf
|
import org.koin.core.module.dsl.singleOf
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
@ -16,4 +17,5 @@ val serviceModule = module {
|
|||||||
|
|
||||||
single { provideReVancedService(get()) }
|
single { provideReVancedService(get()) }
|
||||||
singleOf(::HttpService)
|
singleOf(::HttpService)
|
||||||
|
singleOf(::SignerService)
|
||||||
}
|
}
|
@ -21,7 +21,8 @@ val viewModelModule = module {
|
|||||||
InstallerScreenViewModel(
|
InstallerScreenViewModel(
|
||||||
input = it.get(),
|
input = it.get(),
|
||||||
selectedPatches = it.get(),
|
selectedPatches = it.get(),
|
||||||
app = get()
|
app = get(),
|
||||||
|
signerService = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,11 @@
|
|||||||
|
package app.revanced.manager.compose.patcher
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import app.revanced.manager.compose.util.signing.Signer
|
||||||
|
import app.revanced.manager.compose.util.signing.SigningOptions
|
||||||
|
|
||||||
|
class SignerService(app: Application) {
|
||||||
|
private val options = SigningOptions("ReVanced", "ReVanced", app.dataDir.resolve("manager.keystore").path)
|
||||||
|
|
||||||
|
fun createSigner() = Signer(options)
|
||||||
|
}
|
@ -7,7 +7,6 @@ import androidx.work.workDataOf
|
|||||||
import app.revanced.manager.compose.R
|
import app.revanced.manager.compose.R
|
||||||
import app.revanced.manager.compose.patcher.Session
|
import app.revanced.manager.compose.patcher.Session
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
@ -26,6 +26,7 @@ import app.revanced.manager.compose.ui.component.AppIcon
|
|||||||
import app.revanced.manager.compose.ui.component.AppTopBar
|
import app.revanced.manager.compose.ui.component.AppTopBar
|
||||||
import app.revanced.manager.compose.ui.component.LoadingIndicator
|
import app.revanced.manager.compose.ui.component.LoadingIndicator
|
||||||
import app.revanced.manager.compose.ui.viewmodel.AppSelectorViewModel
|
import app.revanced.manager.compose.ui.viewmodel.AppSelectorViewModel
|
||||||
|
import app.revanced.manager.compose.util.APK_MIMETYPE
|
||||||
import app.revanced.manager.compose.util.PM
|
import app.revanced.manager.compose.util.PM
|
||||||
import app.revanced.manager.compose.util.PackageInfo
|
import app.revanced.manager.compose.util.PackageInfo
|
||||||
import org.koin.androidx.compose.getViewModel
|
import org.koin.androidx.compose.getViewModel
|
||||||
@ -125,7 +126,7 @@ fun AppSelectorScreen(
|
|||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
modifier = Modifier.clickable {
|
modifier = Modifier.clickable {
|
||||||
pickApkLauncher.launch("*/*")
|
pickApkLauncher.launch(APK_MIMETYPE)
|
||||||
},
|
},
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Box(Modifier.size(36.dp), Alignment.Center) {
|
Box(Modifier.size(36.dp), Alignment.Center) {
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
package app.revanced.manager.compose.ui.screen
|
package app.revanced.manager.compose.ui.screen
|
||||||
|
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@ -13,17 +16,21 @@ import app.revanced.manager.compose.R
|
|||||||
import app.revanced.manager.compose.ui.component.AppScaffold
|
import app.revanced.manager.compose.ui.component.AppScaffold
|
||||||
import app.revanced.manager.compose.ui.component.AppTopBar
|
import app.revanced.manager.compose.ui.component.AppTopBar
|
||||||
import app.revanced.manager.compose.ui.viewmodel.InstallerScreenViewModel
|
import app.revanced.manager.compose.ui.viewmodel.InstallerScreenViewModel
|
||||||
|
import app.revanced.manager.compose.util.APK_MIMETYPE
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun InstallerScreen(
|
fun InstallerScreen(
|
||||||
|
onBackClick: () -> Unit,
|
||||||
vm: InstallerScreenViewModel
|
vm: InstallerScreenViewModel
|
||||||
) {
|
) {
|
||||||
|
val exportApkLauncher = rememberLauncherForActivityResult(CreateDocument(APK_MIMETYPE), vm::export)
|
||||||
|
|
||||||
AppScaffold(
|
AppScaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
AppTopBar(
|
AppTopBar(
|
||||||
title = stringResource(R.string.installer),
|
title = stringResource(R.string.installer),
|
||||||
onBackClick = { },
|
onBackClick = onBackClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
@ -47,6 +54,20 @@ fun InstallerScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = vm::installApk,
|
||||||
|
enabled = vm.canInstall
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.install_app))
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { exportApkLauncher.launch("${vm.packageName}.apk") },
|
||||||
|
enabled = vm.canInstall
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.export_app))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -6,12 +6,16 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
import android.content.pm.PackageInstaller
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.lifecycle.Observer
|
import androidx.lifecycle.Observer
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.work.*
|
import androidx.work.*
|
||||||
|
import app.revanced.manager.compose.R
|
||||||
|
import app.revanced.manager.compose.patcher.SignerService
|
||||||
import app.revanced.manager.compose.patcher.worker.PatcherProgressManager
|
import app.revanced.manager.compose.patcher.worker.PatcherProgressManager
|
||||||
import app.revanced.manager.compose.patcher.worker.PatcherWorker
|
import app.revanced.manager.compose.patcher.worker.PatcherWorker
|
||||||
import app.revanced.manager.compose.patcher.worker.StepGroup
|
import app.revanced.manager.compose.patcher.worker.StepGroup
|
||||||
@ -19,26 +23,38 @@ import app.revanced.manager.compose.service.InstallService
|
|||||||
import app.revanced.manager.compose.service.UninstallService
|
import app.revanced.manager.compose.service.UninstallService
|
||||||
import app.revanced.manager.compose.util.PM
|
import app.revanced.manager.compose.util.PM
|
||||||
import app.revanced.manager.compose.util.PackageInfo
|
import app.revanced.manager.compose.util.PackageInfo
|
||||||
|
import app.revanced.manager.compose.util.toast
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.nio.file.Files
|
||||||
|
|
||||||
class InstallerScreenViewModel(
|
class InstallerScreenViewModel(
|
||||||
input: PackageInfo,
|
input: PackageInfo,
|
||||||
selectedPatches: List<String>,
|
selectedPatches: List<String>,
|
||||||
private val app: Application
|
private val app: Application,
|
||||||
|
private val signerService: SignerService
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
var stepGroups by mutableStateOf<List<StepGroup>>(PatcherProgressManager.generateGroupsList(app, selectedPatches))
|
var stepGroups by mutableStateOf<List<StepGroup>>(PatcherProgressManager.generateGroupsList(app, selectedPatches))
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
val packageName = input.packageName
|
||||||
|
|
||||||
private val workManager = WorkManager.getInstance(app)
|
private val workManager = WorkManager.getInstance(app)
|
||||||
|
|
||||||
// TODO: handle app installation as a step.
|
// TODO: get rid of these and use stepGroups instead.
|
||||||
var installStatus by mutableStateOf<Boolean?>(null)
|
var installStatus by mutableStateOf<Boolean?>(null)
|
||||||
var pmStatus by mutableStateOf(-999)
|
var pmStatus by mutableStateOf(-999)
|
||||||
var extra by mutableStateOf("")
|
var extra by mutableStateOf("")
|
||||||
|
|
||||||
private val outputFile = File(app.cacheDir, "output.apk")
|
private val outputFile = File(app.cacheDir, "output.apk")
|
||||||
|
private val signedFile = File(app.cacheDir, "signed.apk").also { if (it.exists()) it.delete() }
|
||||||
|
private var hasSigned = false
|
||||||
|
private var patcherStatus by mutableStateOf<Boolean?>(null)
|
||||||
|
private var isInstalling by mutableStateOf(false)
|
||||||
|
|
||||||
|
val canInstall by derivedStateOf { patcherStatus == true && !isInstalling }
|
||||||
|
|
||||||
|
|
||||||
private val patcherWorker =
|
private val patcherWorker =
|
||||||
OneTimeWorkRequest.Builder(PatcherWorker::class.java) // create Worker
|
OneTimeWorkRequest.Builder(PatcherWorker::class.java) // create Worker
|
||||||
@ -51,7 +67,7 @@ class InstallerScreenViewModel(
|
|||||||
outputFile.path,
|
outputFile.path,
|
||||||
selectedPatches,
|
selectedPatches,
|
||||||
input.packageName,
|
input.packageName,
|
||||||
input.packageName,
|
input.version,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -62,7 +78,10 @@ class InstallerScreenViewModel(
|
|||||||
private val observer = Observer { workInfo: WorkInfo -> // observer for observing patch status
|
private val observer = Observer { workInfo: WorkInfo -> // observer for observing patch status
|
||||||
when (workInfo.state) {
|
when (workInfo.state) {
|
||||||
WorkInfo.State.RUNNING -> workInfo.progress
|
WorkInfo.State.RUNNING -> workInfo.progress
|
||||||
WorkInfo.State.FAILED, WorkInfo.State.SUCCEEDED -> workInfo.outputData
|
WorkInfo.State.FAILED, WorkInfo.State.SUCCEEDED -> workInfo.outputData.also {
|
||||||
|
patcherStatus = workInfo.state == WorkInfo.State.SUCCEEDED
|
||||||
|
}
|
||||||
|
|
||||||
else -> null
|
else -> null
|
||||||
}?.let { PatcherProgressManager.groupsFromWorkData(it) }?.let { stepGroups = it }
|
}?.let { PatcherProgressManager.groupsFromWorkData(it) }?.let { stepGroups = it }
|
||||||
}
|
}
|
||||||
@ -91,8 +110,35 @@ class InstallerScreenViewModel(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fun installApk(apk: List<File>) {
|
private fun signApk(): Boolean {
|
||||||
PM.installApp(apk, app)
|
if (!hasSigned) {
|
||||||
|
try {
|
||||||
|
signerService.createSigner().signApk(outputFile, signedFile)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
e.printStackTrace()
|
||||||
|
app.toast(app.getString(R.string.sign_fail, e::class.simpleName))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun export(uri: Uri?) = uri?.let {
|
||||||
|
if (signApk()) {
|
||||||
|
Files.copy(signedFile.toPath(), app.contentResolver.openOutputStream(it))
|
||||||
|
app.toast(app.getString(R.string.export_app_success))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun installApk() {
|
||||||
|
isInstalling = true
|
||||||
|
try {
|
||||||
|
if (!signApk()) return
|
||||||
|
PM.installApp(listOf(signedFile), app)
|
||||||
|
} finally {
|
||||||
|
isInstalling = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun postInstallStatus() {
|
fun postInstallStatus() {
|
||||||
@ -105,5 +151,8 @@ class InstallerScreenViewModel(
|
|||||||
app.unregisterReceiver(installBroadcastReceiver)
|
app.unregisterReceiver(installBroadcastReceiver)
|
||||||
workManager.cancelWorkById(patcherWorker.id)
|
workManager.cancelWorkById(patcherWorker.id)
|
||||||
// logs.clear()
|
// logs.clear()
|
||||||
|
|
||||||
|
outputFile.delete()
|
||||||
|
signedFile.delete()
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -7,6 +7,7 @@ import android.graphics.drawable.Drawable
|
|||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
|
||||||
|
const val APK_MIMETYPE = "application/vnd.android.package-archive"
|
||||||
|
|
||||||
fun Context.openUrl(url: String) {
|
fun Context.openUrl(url: String) {
|
||||||
startActivity(Intent(Intent.ACTION_VIEW, url.toUri()).apply {
|
startActivity(Intent(Intent.ACTION_VIEW, url.toUri()).apply {
|
||||||
|
@ -0,0 +1,77 @@
|
|||||||
|
package app.revanced.manager.compose.util.signing
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.android.apksig.ApkSigner
|
||||||
|
import org.bouncycastle.asn1.x500.X500Name
|
||||||
|
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo
|
||||||
|
import org.bouncycastle.cert.X509v3CertificateBuilder
|
||||||
|
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter
|
||||||
|
import org.bouncycastle.jce.provider.BouncyCastleProvider
|
||||||
|
import org.bouncycastle.operator.ContentSigner
|
||||||
|
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.math.BigInteger
|
||||||
|
import java.security.*
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class Signer(
|
||||||
|
private val signingOptions: SigningOptions
|
||||||
|
) {
|
||||||
|
private val passwordCharArray = signingOptions.password.toCharArray()
|
||||||
|
private fun newKeystore(out: File) {
|
||||||
|
val (publicKey, privateKey) = createKey()
|
||||||
|
val privateKS = KeyStore.getInstance("BKS", "BC")
|
||||||
|
privateKS.load(null, passwordCharArray)
|
||||||
|
privateKS.setKeyEntry("alias", privateKey, passwordCharArray, arrayOf(publicKey))
|
||||||
|
privateKS.store(FileOutputStream(out), passwordCharArray)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createKey(): Pair<X509Certificate, PrivateKey> {
|
||||||
|
val gen = KeyPairGenerator.getInstance("RSA")
|
||||||
|
gen.initialize(4096)
|
||||||
|
val pair = gen.generateKeyPair()
|
||||||
|
var serialNumber: BigInteger
|
||||||
|
do serialNumber = BigInteger.valueOf(SecureRandom().nextLong()) while (serialNumber < BigInteger.ZERO)
|
||||||
|
val x500Name = X500Name("CN=${signingOptions.cn}")
|
||||||
|
val builder = X509v3CertificateBuilder(
|
||||||
|
x500Name,
|
||||||
|
serialNumber,
|
||||||
|
Date(System.currentTimeMillis() - 1000L * 60L * 60L * 24L * 30L),
|
||||||
|
Date(System.currentTimeMillis() + 1000L * 60L * 60L * 24L * 366L * 30L),
|
||||||
|
Locale.ENGLISH,
|
||||||
|
x500Name,
|
||||||
|
SubjectPublicKeyInfo.getInstance(pair.public.encoded)
|
||||||
|
)
|
||||||
|
val signer: ContentSigner = JcaContentSignerBuilder("SHA256withRSA").build(pair.private)
|
||||||
|
return JcaX509CertificateConverter().getCertificate(builder.build(signer)) to pair.private
|
||||||
|
}
|
||||||
|
|
||||||
|
fun signApk(input: File, output: File) {
|
||||||
|
Security.addProvider(BouncyCastleProvider())
|
||||||
|
|
||||||
|
val ks = File(signingOptions.keyStoreFilePath)
|
||||||
|
if (!ks.exists()) newKeystore(ks) else {
|
||||||
|
Log.i("revanced-manager", "Found existing keystore: ${ks.name}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val keyStore = KeyStore.getInstance("BKS", "BC")
|
||||||
|
FileInputStream(ks).use { fis -> keyStore.load(fis, null) }
|
||||||
|
val alias = keyStore.aliases().nextElement()
|
||||||
|
|
||||||
|
val config = ApkSigner.SignerConfig.Builder(
|
||||||
|
signingOptions.cn,
|
||||||
|
keyStore.getKey(alias, passwordCharArray) as PrivateKey,
|
||||||
|
listOf(keyStore.getCertificate(alias) as X509Certificate)
|
||||||
|
).build()
|
||||||
|
|
||||||
|
val signer = ApkSigner.Builder(listOf(config))
|
||||||
|
signer.setCreatedBy(signingOptions.cn)
|
||||||
|
signer.setInputApk(input)
|
||||||
|
signer.setOutputApk(output)
|
||||||
|
|
||||||
|
signer.build().sign()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
package app.revanced.manager.compose.util.signing
|
||||||
|
|
||||||
|
data class SigningOptions(
|
||||||
|
val cn: String,
|
||||||
|
val password: String,
|
||||||
|
val keyStoreFilePath: String
|
||||||
|
)
|
@ -62,6 +62,10 @@
|
|||||||
<string name="app_not_supported">Some of the patches do not support this app version (%s). The patches only support the following versions: %s.</string>
|
<string name="app_not_supported">Some of the patches do not support this app version (%s). The patches only support the following versions: %s.</string>
|
||||||
|
|
||||||
<string name="installer">Installer</string>
|
<string name="installer">Installer</string>
|
||||||
|
<string name="install_app">Install</string>
|
||||||
|
<string name="export_app">Export</string>
|
||||||
|
<string name="export_app_success">Apk exported</string>
|
||||||
|
<string name="sign_fail">Failed to sign Apk: %s</string>
|
||||||
|
|
||||||
<string name="patcher_step_group_prepare">Preparation</string>
|
<string name="patcher_step_group_prepare">Preparation</string>
|
||||||
<string name="patcher_step_unpack">Unpack Apk</string>
|
<string name="patcher_step_unpack">Unpack Apk</string>
|
||||||
|
Loading…
Reference in New Issue
Block a user