mirror of
https://github.com/apache/cordova-android.git
synced 2025-02-20 23:56:20 +08:00
Merge conflicts with something on the official repo, moving old pull request in
This commit is contained in:
commit
bc309c9f00
25
README.md
25
README.md
@ -1,7 +1,16 @@
|
||||
PhoneGap Android
|
||||
Cordova Android
|
||||
===
|
||||
|
||||
PhoneGap Android is an Android application library that allows for PhoneGap based projects to be built for the Android Platform. PhoneGap based applications are, at the core, an application written with web technology: HTML, CSS and JavaScript.
|
||||
Cordova Android is an Android application library that allows for Cordova based projects to be built for the Android Platform. Cordova based applications are, at the core, an application written with web technology: HTML, CSS and JavaScript.
|
||||
|
||||
Apache Cordova is an effort undergoing incubation at The Apache
|
||||
Software Foundation (ASF), sponsored by the Apache Incubator project.
|
||||
Incubation is required of all newly accepted projects until a further
|
||||
review indicates that the infrastructure, communications, and decision
|
||||
making process have stabilized in a manner consistent with other
|
||||
successful ASF projects. While incubation status is not necessarily
|
||||
a reflection of the completeness or stability of the code, it does
|
||||
indicate that the project has yet to be fully endorsed by the ASF.
|
||||
|
||||
Requires
|
||||
---
|
||||
@ -10,21 +19,21 @@ Requires
|
||||
- Apache ANT
|
||||
- Android SDK [http://developer.android.com](http://developer.android.com)
|
||||
|
||||
PhoneGap Android Developer Tools
|
||||
Cordova Android Developer Tools
|
||||
---
|
||||
|
||||
The PhoneGap developer tooling is split between general tooling and project level tooling.
|
||||
The Cordova developer tooling is split between general tooling and project level tooling.
|
||||
|
||||
General Commands
|
||||
|
||||
./bin/create [path package activity] ... create the ./example app or a phonegap android project
|
||||
./bin/create [path package activity] ... create the ./example app or a cordova android project
|
||||
./bin/bench ............................ generate a bench proj
|
||||
./bin/autotest ......................... test the cli tools
|
||||
./bin/test ............................. run mobile-spec
|
||||
|
||||
Project Commands
|
||||
|
||||
These commands live in a generated PhoneGap Android project.
|
||||
These commands live in a generated Cordova Android project.
|
||||
|
||||
./phonegap/debug [path] ..................... install to first device
|
||||
./phonegap/emulate .......................... start avd (emulator) named default
|
||||
@ -52,12 +61,12 @@ Running the [callback/callback-test](http://github.com/callback/callback-test) t
|
||||
|
||||
./bin/test
|
||||
|
||||
Creating a new PhoneGap Android Project
|
||||
Creating a new Cordova Android Project
|
||||
---
|
||||
|
||||
./bin/create ~/Desktop/myapp com.phonegap.special MyApp
|
||||
|
||||
Importing a PhoneGap Android Project into Eclipse
|
||||
Importing a Cordova Android Project into Eclipse
|
||||
----
|
||||
|
||||
1. File > New > Project...
|
||||
|
@ -127,7 +127,7 @@ Camera.prototype.getPicture = function(successCallback, errorCallback, options)
|
||||
options.maxResolution = 0;
|
||||
}
|
||||
if (options.destinationType === null || typeof options.destinationType === "undefined") {
|
||||
options.destinationType = Camera.DestinationType.DATA_URL;
|
||||
options.destinationType = Camera.DestinationType.FILE_URI;
|
||||
}
|
||||
if (options.sourceType === null || typeof options.sourceType === "undefined") {
|
||||
options.sourceType = Camera.PictureSourceType.CAMERA;
|
||||
|
51
framework/src/com/phonegap/AuthenticationToken.java
Normal file
51
framework/src/com/phonegap/AuthenticationToken.java
Normal file
@ -0,0 +1,51 @@
|
||||
package com.phonegap;
|
||||
|
||||
/**
|
||||
* The Class AuthenticationToken defines the userName and password to be used for authenticating a web resource
|
||||
*/
|
||||
public class AuthenticationToken {
|
||||
private String userName;
|
||||
private String password;
|
||||
|
||||
/**
|
||||
* Gets the user name.
|
||||
*
|
||||
* @return the user name
|
||||
*/
|
||||
public String getUserName() {
|
||||
return userName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the user name.
|
||||
*
|
||||
* @param userName
|
||||
* the new user name
|
||||
*/
|
||||
public void setUserName(String userName) {
|
||||
this.userName = userName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the password.
|
||||
*
|
||||
* @return the password
|
||||
*/
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the password.
|
||||
*
|
||||
* @param password
|
||||
* the new password
|
||||
*/
|
||||
public void setPassword(String password) {
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
@ -101,7 +101,7 @@ public class CameraLauncher extends Plugin {
|
||||
try {
|
||||
if (action.equals("takePicture")) {
|
||||
int srcType = CAMERA;
|
||||
int destType = DATA_URL;
|
||||
int destType = FILE_URI;
|
||||
this.targetHeight = 0;
|
||||
this.targetWidth = 0;
|
||||
this.encodingType = JPEG;
|
||||
|
@ -819,50 +819,49 @@ public class ContactAccessorSdk5 extends ContactAccessor {
|
||||
public String save(JSONObject contact) {
|
||||
AccountManager mgr = AccountManager.get(mApp);
|
||||
Account[] accounts = mgr.getAccounts();
|
||||
Account account = null;
|
||||
String accountName = null;
|
||||
String accountType = null;
|
||||
|
||||
if (accounts.length == 1)
|
||||
account = accounts[0];
|
||||
else if (accounts.length > 1) {
|
||||
for(Account a : accounts){
|
||||
if(a.type.contains("eas")&& a.name.matches(EMAIL_REGEXP)) /*Exchange ActiveSync*/
|
||||
{
|
||||
account = a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(account == null){
|
||||
for(Account a : accounts){
|
||||
if(a.type.contains("com.google") && a.name.matches(EMAIL_REGEXP)) /*Google sync provider*/
|
||||
{
|
||||
account = a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(account == null){
|
||||
for(Account a : accounts){
|
||||
if(a.name.matches(EMAIL_REGEXP)) /*Last resort, just look for an email address...*/
|
||||
{
|
||||
account = a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (accounts.length == 1) {
|
||||
accountName = accounts[0].name;
|
||||
accountType = accounts[0].type;
|
||||
}
|
||||
|
||||
if(account == null) {
|
||||
return null;
|
||||
else if (accounts.length > 1) {
|
||||
for(Account a : accounts) {
|
||||
if(a.type.contains("eas")&& a.name.matches(EMAIL_REGEXP)) /*Exchange ActiveSync*/ {
|
||||
accountName = a.name;
|
||||
accountType = a.type;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(accountName == null){
|
||||
for(Account a : accounts){
|
||||
if(a.type.contains("com.google") && a.name.matches(EMAIL_REGEXP)) /*Google sync provider*/ {
|
||||
accountName = a.name;
|
||||
accountType = a.type;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(accountName == null){
|
||||
for(Account a : accounts){
|
||||
if(a.name.matches(EMAIL_REGEXP)) /*Last resort, just look for an email address...*/ {
|
||||
accountName = a.name;
|
||||
accountType = a.type;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String id = getJsonString(contact, "id");
|
||||
// Create new contact
|
||||
if (id == null) {
|
||||
return createNewContact(contact, account);
|
||||
return createNewContact(contact, accountType, accountName);
|
||||
}
|
||||
// Modify existing contact
|
||||
else {
|
||||
return modifyContact(id, contact, account);
|
||||
return modifyContact(id, contact, accountType, accountName);
|
||||
}
|
||||
}
|
||||
|
||||
@ -873,7 +872,7 @@ public class ContactAccessorSdk5 extends ContactAccessor {
|
||||
* @param contact the contact to be saved
|
||||
* @param account the account to be saved under
|
||||
*/
|
||||
private String modifyContact(String id, JSONObject contact, Account account) {
|
||||
private String modifyContact(String id, JSONObject contact, String accountType, String accountName) {
|
||||
// Get the RAW_CONTACT_ID which is needed to insert new values in an already existing contact.
|
||||
// But not needed to update existing values.
|
||||
int rawId = (new Integer(getJsonString(contact,"rawId"))).intValue();
|
||||
@ -883,8 +882,8 @@ public class ContactAccessorSdk5 extends ContactAccessor {
|
||||
|
||||
//Add contact type
|
||||
ops.add(ContentProviderOperation.newUpdate(ContactsContract.RawContacts.CONTENT_URI)
|
||||
.withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type)
|
||||
.withValue(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
|
||||
.withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, accountType)
|
||||
.withValue(ContactsContract.RawContacts.ACCOUNT_NAME, accountName)
|
||||
.build());
|
||||
|
||||
// Modify name
|
||||
@ -1427,14 +1426,14 @@ public class ContactAccessorSdk5 extends ContactAccessor {
|
||||
* @param contact the contact to be saved
|
||||
* @param account the account to be saved under
|
||||
*/
|
||||
private String createNewContact(JSONObject contact, Account account) {
|
||||
private String createNewContact(JSONObject contact, String accountType, String accountName) {
|
||||
// Create a list of attributes to add to the contact database
|
||||
ArrayList<ContentProviderOperation> ops = new ArrayList<ContentProviderOperation>();
|
||||
|
||||
//Add contact type
|
||||
ops.add(ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI)
|
||||
.withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, account.type)
|
||||
.withValue(ContactsContract.RawContacts.ACCOUNT_NAME, account.name)
|
||||
.withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, accountType)
|
||||
.withValue(ContactsContract.RawContacts.ACCOUNT_NAME, accountName)
|
||||
.build());
|
||||
|
||||
// Add name
|
||||
|
@ -18,16 +18,18 @@
|
||||
*/
|
||||
package com.phonegap;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Stack;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.Iterator;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.Hashtable;
|
||||
import java.util.Iterator;
|
||||
import java.util.Stack;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.xmlpull.v1.XmlPullParserException;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.app.ProgressDialog;
|
||||
@ -55,6 +57,7 @@ import android.view.Window;
|
||||
import android.view.WindowManager;
|
||||
import android.webkit.ConsoleMessage;
|
||||
import android.webkit.GeolocationPermissions.Callback;
|
||||
import android.webkit.HttpAuthHandler;
|
||||
import android.webkit.JsPromptResult;
|
||||
import android.webkit.JsResult;
|
||||
import android.webkit.SslErrorHandler;
|
||||
@ -67,11 +70,10 @@ import android.webkit.WebViewClient;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import com.phonegap.api.IPlugin;
|
||||
import com.phonegap.api.LOG;
|
||||
import com.phonegap.api.PhonegapActivity;
|
||||
import com.phonegap.api.IPlugin;
|
||||
import com.phonegap.api.PluginManager;
|
||||
import org.xmlpull.v1.XmlPullParserException;
|
||||
|
||||
/**
|
||||
* This class is the main Android activity that represents the PhoneGap
|
||||
@ -199,6 +201,9 @@ public class DroidGap extends PhonegapActivity {
|
||||
// (this is not the color for the webview, which is set in HTML)
|
||||
private int backgroundColor = Color.BLACK;
|
||||
|
||||
/** The authorization tokens. */
|
||||
private Hashtable<String, AuthenticationToken> authenticationTokens = new Hashtable<String, AuthenticationToken>();
|
||||
|
||||
/*
|
||||
* The variables below are used to cache some of the activity properties.
|
||||
*/
|
||||
@ -217,6 +222,88 @@ public class DroidGap extends PhonegapActivity {
|
||||
|
||||
private boolean classicRender;
|
||||
|
||||
/**
|
||||
* Sets the authentication token.
|
||||
*
|
||||
* @param authenticationToken
|
||||
* the authentication token
|
||||
* @param host
|
||||
* the host
|
||||
* @param realm
|
||||
* the realm
|
||||
*/
|
||||
public void setAuthenticationToken(AuthenticationToken authenticationToken, String host, String realm) {
|
||||
|
||||
if(host == null) {
|
||||
host = "";
|
||||
}
|
||||
|
||||
if(realm == null) {
|
||||
realm = "";
|
||||
}
|
||||
|
||||
authenticationTokens.put(host.concat(realm), authenticationToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the authentication token.
|
||||
*
|
||||
* @param host
|
||||
* the host
|
||||
* @param realm
|
||||
* the realm
|
||||
* @return the authentication token or null if did not exist
|
||||
*/
|
||||
public AuthenticationToken removeAuthenticationToken(String host, String realm) {
|
||||
return authenticationTokens.remove(host.concat(realm));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the authentication token.
|
||||
*
|
||||
* In order it tries:
|
||||
* 1- host + realm
|
||||
* 2- host
|
||||
* 3- realm
|
||||
* 4- no host, no realm
|
||||
*
|
||||
* @param host
|
||||
* the host
|
||||
* @param realm
|
||||
* the realm
|
||||
* @return the authentication token
|
||||
*/
|
||||
public AuthenticationToken getAuthenticationToken(String host, String realm) {
|
||||
AuthenticationToken token = null;
|
||||
|
||||
token = authenticationTokens.get(host.concat(realm));
|
||||
|
||||
if(token == null) {
|
||||
// try with just the host
|
||||
token = authenticationTokens.get(host);
|
||||
|
||||
// Try the realm
|
||||
if(token == null) {
|
||||
token = authenticationTokens.get(realm);
|
||||
}
|
||||
|
||||
// if no host found, just query for default
|
||||
if(token == null) {
|
||||
token = authenticationTokens.get("");
|
||||
}
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all authentication tokens.
|
||||
*/
|
||||
public void clearAuthenticationTokens() {
|
||||
authenticationTokens.clear();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Called when the activity is first created.
|
||||
*
|
||||
@ -364,15 +451,15 @@ public class DroidGap extends PhonegapActivity {
|
||||
* @param url
|
||||
*/
|
||||
public void loadUrl(String url) {
|
||||
|
||||
// If first page of app, then set URL to load to be the one passed in
|
||||
if (this.initUrl == null || (this.urls.size() > 0)) {
|
||||
this.loadUrlIntoView(url);
|
||||
}
|
||||
// Otherwise use the URL specified in the activity's extras bundle
|
||||
else {
|
||||
this.loadUrlIntoView(this.initUrl);
|
||||
}
|
||||
|
||||
// If first page of app, then set URL to load to be the one passed in
|
||||
if (this.initUrl == null || (this.urls.size() > 0)) {
|
||||
this.loadUrlIntoView(url);
|
||||
}
|
||||
// Otherwise use the URL specified in the activity's extras bundle
|
||||
else {
|
||||
this.loadUrlIntoView(this.initUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -434,10 +521,10 @@ public class DroidGap extends PhonegapActivity {
|
||||
// If loadingDialog property, then show the App loading dialog for first page of app
|
||||
String loading = null;
|
||||
if (me.urls.size() == 1) {
|
||||
loading = me.getStringProperty("loadingDialog", null);
|
||||
loading = me.getStringProperty("loadingDialog", null);
|
||||
}
|
||||
else {
|
||||
loading = me.getStringProperty("loadingPageDialog", null);
|
||||
loading = me.getStringProperty("loadingPageDialog", null);
|
||||
}
|
||||
if (loading != null) {
|
||||
|
||||
@ -493,15 +580,15 @@ public class DroidGap extends PhonegapActivity {
|
||||
* @param time The number of ms to wait before loading webview
|
||||
*/
|
||||
public void loadUrl(final String url, int time) {
|
||||
|
||||
// If first page of app, then set URL to load to be the one passed in
|
||||
if (this.initUrl == null || (this.urls.size() > 0)) {
|
||||
this.loadUrlIntoView(url, time);
|
||||
}
|
||||
// Otherwise use the URL specified in the activity's extras bundle
|
||||
else {
|
||||
this.loadUrlIntoView(this.initUrl);
|
||||
}
|
||||
|
||||
// If first page of app, then set URL to load to be the one passed in
|
||||
if (this.initUrl == null || (this.urls.size() > 0)) {
|
||||
this.loadUrlIntoView(url, time);
|
||||
}
|
||||
// Otherwise use the URL specified in the activity's extras bundle
|
||||
else {
|
||||
this.loadUrlIntoView(this.initUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -515,14 +602,14 @@ public class DroidGap extends PhonegapActivity {
|
||||
|
||||
// Clear cancel flag
|
||||
this.cancelLoadUrl = false;
|
||||
|
||||
// If not first page of app, then load immediately
|
||||
if (this.urls.size() > 0) {
|
||||
this.loadUrlIntoView(url);
|
||||
}
|
||||
|
||||
if (!url.startsWith("javascript:")) {
|
||||
LOG.d(TAG, "DroidGap.loadUrl(%s, %d)", url, time);
|
||||
// If not first page of app, then load immediately
|
||||
if (this.urls.size() > 0) {
|
||||
this.loadUrlIntoView(url);
|
||||
}
|
||||
|
||||
if (!url.startsWith("javascript:")) {
|
||||
LOG.d(TAG, "DroidGap.loadUrl(%s, %d)", url, time);
|
||||
}
|
||||
final DroidGap me = this;
|
||||
|
||||
@ -1358,6 +1445,32 @@ public class DroidGap extends PhonegapActivity {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* On received http auth request.
|
||||
* The method reacts on all registered authentication tokens. There is one and only one authentication token for any host + realm combination
|
||||
*
|
||||
* @param view
|
||||
* the view
|
||||
* @param handler
|
||||
* the handler
|
||||
* @param host
|
||||
* the host
|
||||
* @param realm
|
||||
* the realm
|
||||
*/
|
||||
@Override
|
||||
public void onReceivedHttpAuthRequest(WebView view, HttpAuthHandler handler, String host,
|
||||
String realm) {
|
||||
|
||||
// get the authentication token
|
||||
AuthenticationToken token = getAuthenticationToken(host,realm);
|
||||
|
||||
if(token != null) {
|
||||
handler.proceed(token.getUserName(), token.getPassword());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void onPageStarted(WebView view, String url, Bitmap favicon) {
|
||||
|
||||
@ -1732,7 +1845,7 @@ public class DroidGap extends PhonegapActivity {
|
||||
/**
|
||||
* Load PhoneGap configuration from res/xml/phonegap.xml.
|
||||
* Approved list of URLs that can be loaded into DroidGap
|
||||
* <access origin="http://server regexp" subdomains="true" />
|
||||
* <access origin="http://server regexp" subdomains="true" />
|
||||
* Log level: ERROR, WARN, INFO, DEBUG, VERBOSE (default=ERROR)
|
||||
* <log level="DEBUG" />
|
||||
*/
|
||||
|
@ -18,17 +18,22 @@
|
||||
*/
|
||||
package com.phonegap;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
import com.phonegap.api.Plugin;
|
||||
import com.phonegap.api.PluginResult;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.database.sqlite.*;
|
||||
|
||||
/**
|
||||
* This class implements the HTML5 database support for Android 1.X devices. It
|
||||
* is not used for Android 2.X, since HTML5 database is built in to the browser.
|
||||
* This class implements the HTML5 database support to work around a bug for
|
||||
* Android 3.0 devices. It is not used for other versions of Android, since
|
||||
* HTML5 database is built in to the browser.
|
||||
*/
|
||||
public class Storage extends Plugin {
|
||||
|
||||
@ -64,11 +69,7 @@ public class Storage extends Plugin {
|
||||
String result = "";
|
||||
|
||||
try {
|
||||
// TODO: Do we want to allow a user to do this, since they could get
|
||||
// to other app databases?
|
||||
if (action.equals("setStorage")) {
|
||||
this.setStorage(args.getString(0));
|
||||
} else if (action.equals("openDatabase")) {
|
||||
if (action.equals("openDatabase")) {
|
||||
this.openDatabase(args.getString(0), args.getString(1),
|
||||
args.getString(2), args.getLong(3));
|
||||
} else if (action.equals("executeSql")) {
|
||||
@ -118,21 +119,6 @@ public class Storage extends Plugin {
|
||||
// LOCAL METHODS
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Set the application package for the database. Each application saves its
|
||||
* database files in a directory with the application package as part of the
|
||||
* file name.
|
||||
*
|
||||
* For example, application "com.phonegap.demo.Demo" would save its database
|
||||
* files in "/data/data/com.phonegap.demo/databases/" directory.
|
||||
*
|
||||
* @param appPackage
|
||||
* The application package.
|
||||
*/
|
||||
public void setStorage(String appPackage) {
|
||||
this.path = "/data/data/" + appPackage + "/databases/";
|
||||
}
|
||||
|
||||
/**
|
||||
* Open database.
|
||||
*
|
||||
@ -155,12 +141,10 @@ public class Storage extends Plugin {
|
||||
|
||||
// If no database path, generate from application package
|
||||
if (this.path == null) {
|
||||
Package pack = this.ctx.getClass().getPackage();
|
||||
String appPackage = pack.getName();
|
||||
this.setStorage(appPackage);
|
||||
this.path = this.ctx.getApplicationContext().getDir("database", Context.MODE_PRIVATE).getPath();
|
||||
}
|
||||
|
||||
this.dbName = this.path + db + ".db";
|
||||
this.dbName = this.path + File.pathSeparator + db + ".db";
|
||||
this.myDb = SQLiteDatabase.openOrCreateDatabase(this.dbName, null);
|
||||
}
|
||||
|
||||
@ -249,9 +233,7 @@ public class Storage extends Plugin {
|
||||
}
|
||||
|
||||
// Let JavaScript know that there are no more rows
|
||||
this.sendJavascript("droiddb.completeQuery('" + tx_id + "', " + result
|
||||
+ ");");
|
||||
|
||||
this.sendJavascript("droiddb.completeQuery('" + tx_id + "', " + result + ");");
|
||||
}
|
||||
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user