Compare commits
8 Commits
v1.6.43
...
473c464bf7
Author | SHA1 | Date | |
---|---|---|---|
473c464bf7 | |||
a5b9ced9ba | |||
0438a58f3e | |||
604ab0eb43 | |||
31c4f6c1d1 | |||
0c646b55fc | |||
88cdc366c5 | |||
2bd94e8a3d |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -148,3 +148,4 @@ fabric.properties
|
||||
|
||||
/app/app-release.apk
|
||||
Automation_settings.xml
|
||||
/app/googlePlayFlavor/
|
||||
|
2
.idea/deploymentTargetDropDown.xml
generated
2
.idea/deploymentTargetDropDown.xml
generated
@@ -12,6 +12,6 @@
|
||||
</deviceKey>
|
||||
</Target>
|
||||
</targetSelectedWithDropDown>
|
||||
<timeTargetWasSelectedWithDropDown value="2021-09-24T23:07:53.935197300Z" />
|
||||
<timeTargetWasSelectedWithDropDown value="2021-11-07T11:58:40.808135100Z" />
|
||||
</component>
|
||||
</project>
|
@@ -833,6 +833,13 @@ public class Rule implements Comparable<Rule>
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if(myApp.equals(BuildConfig.APPLICATION_ID))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
If there are multiple notifications ("stacked") title or text might be null:
|
||||
@@ -897,6 +904,13 @@ public class Rule implements Comparable<Rule>
|
||||
if (!app.equalsIgnoreCase(myApp))
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
if(myApp.equals(BuildConfig.APPLICATION_ID))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (requiredTitle.length() > 0)
|
||||
{
|
||||
|
@@ -6,6 +6,9 @@ import android.annotation.TargetApi;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.bluetooth.BluetoothAdapter;
|
||||
import android.bluetooth.BluetoothDevice;
|
||||
import android.bluetooth.BluetoothManager;
|
||||
import android.bluetooth.BluetoothProfile;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
@@ -43,13 +46,16 @@ import org.apache.http.conn.util.InetAddressUtils;
|
||||
import org.apache.http.impl.client.DefaultHttpClient;
|
||||
|
||||
import java.io.File;
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.net.InetAddress;
|
||||
import java.net.NetworkInterface;
|
||||
import java.security.KeyStore;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import javax.net.ssl.SSLContext;
|
||||
|
||||
@@ -304,6 +310,118 @@ public class Actions
|
||||
return true;
|
||||
}
|
||||
|
||||
public static class BluetoothTetheringClass
|
||||
{
|
||||
static Object instance = null;
|
||||
static Method setTetheringOn = null;
|
||||
static Method isTetheringOn = null;
|
||||
static Object mutex = new Object();
|
||||
|
||||
public static Boolean setBluetoothTethering(Context context, Boolean desiredState, boolean toggleActionIfPossible)
|
||||
{
|
||||
Miscellaneous.logEvent("i", "Bluetooth Tethering", "Changing Bluetooth Tethering to " + String.valueOf(desiredState), 4);
|
||||
|
||||
boolean state = Actions.isWifiApEnabled(context);
|
||||
|
||||
if (toggleActionIfPossible)
|
||||
{
|
||||
Miscellaneous.logEvent("i", "Bluetooth Tethering", context.getResources().getString(R.string.toggling), 2);
|
||||
desiredState = !state;
|
||||
}
|
||||
|
||||
if (((state && !desiredState) || (!state && desiredState)))
|
||||
{
|
||||
BluetoothAdapter mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
|
||||
Class<?> classBluetoothPan = null;
|
||||
Constructor<?> BTPanCtor = null;
|
||||
Object BTSrvInstance = null;
|
||||
Method mBTPanConnect = null;
|
||||
|
||||
try
|
||||
{
|
||||
classBluetoothPan = Class.forName("android.bluetooth.BluetoothPan");
|
||||
mBTPanConnect = classBluetoothPan.getDeclaredMethod("connect", BluetoothDevice.class);
|
||||
BTPanCtor = classBluetoothPan.getDeclaredConstructor(Context.class, BluetoothProfile.ServiceListener.class);
|
||||
BTPanCtor.setAccessible(true);
|
||||
BTSrvInstance = BTPanCtor.newInstance(context, new BTPanServiceListener(context));
|
||||
}
|
||||
catch (ClassNotFoundException e)
|
||||
{
|
||||
Miscellaneous.logEvent("e", "Bluetooth Tethering", Log.getStackTraceString(e), 1);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Miscellaneous.logEvent("e", "Bluetooth Tethering", Log.getStackTraceString(e), 1);
|
||||
}
|
||||
|
||||
Set<BluetoothDevice> pairedDevices = mBluetoothAdapter.getBondedDevices();
|
||||
|
||||
// If there are paired devices
|
||||
if (pairedDevices.size() > 0)
|
||||
{
|
||||
// Loop through paired devices
|
||||
for (BluetoothDevice device : pairedDevices)
|
||||
{
|
||||
try
|
||||
{
|
||||
mBTPanConnect.invoke(BTSrvInstance, device);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Miscellaneous.logEvent("e", "Bluetooth Tethering", Log.getStackTraceString(e), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static class BTPanServiceListener implements BluetoothProfile.ServiceListener
|
||||
{
|
||||
private final Context context;
|
||||
|
||||
public BTPanServiceListener(final Context context)
|
||||
{
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceConnected(final int profile, final BluetoothProfile proxy)
|
||||
{
|
||||
//Some code must be here or the compiler will optimize away this callback.
|
||||
|
||||
try
|
||||
{
|
||||
synchronized (mutex)
|
||||
{
|
||||
setTetheringOn.invoke(instance, true);
|
||||
if ((Boolean) isTetheringOn.invoke(instance, null))
|
||||
{
|
||||
Miscellaneous.logEvent("e", "Bluetooth Tethering", "BT Tethering is on", 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
Miscellaneous.logEvent("e", "Bluetooth Tethering", "BT Tethering is off", 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (InvocationTargetException e)
|
||||
{
|
||||
Miscellaneous.logEvent("e", "Bluetooth Tethering", Log.getStackTraceString(e), 1);
|
||||
}
|
||||
catch (IllegalAccessException e)
|
||||
{
|
||||
Miscellaneous.logEvent("e", "Bluetooth Tethering", Log.getStackTraceString(e), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceDisconnected(final int profile)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean setUsbTethering(Context context2, Boolean desiredState, boolean toggleActionIfPossible)
|
||||
{
|
||||
//TODO:toggle not really implemented, yet
|
||||
|
@@ -201,6 +201,9 @@ public class ActivityMaintenance extends Activity
|
||||
try
|
||||
{
|
||||
XmlFileInterface.readFile();
|
||||
ActivityMainPoi.getInstance().updateListView();
|
||||
ActivityMainRules.getInstance().updateListView();
|
||||
ActivityMainProfiles.getInstance().updateListView();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
@@ -314,18 +317,9 @@ public class ActivityMaintenance extends Activity
|
||||
|
||||
String subject = "Automation logs";
|
||||
|
||||
StringBuilder emailBody = new StringBuilder();
|
||||
emailBody.append("Device details" + Miscellaneous.lineSeparator);
|
||||
emailBody.append("OS version: " + System.getProperty("os.version") + Miscellaneous.lineSeparator);
|
||||
emailBody.append("API Level: " + android.os.Build.VERSION.SDK + Miscellaneous.lineSeparator);
|
||||
emailBody.append("Device: " + android.os.Build.DEVICE + Miscellaneous.lineSeparator);
|
||||
emailBody.append("Model: " + android.os.Build.MODEL + Miscellaneous.lineSeparator);
|
||||
emailBody.append("Product: " + android.os.Build.PRODUCT);
|
||||
emailBody.append("Flavor: " + BuildConfig.FLAVOR);
|
||||
|
||||
Uri uri = Uri.parse("content://com.jens.automation2/" + Settings.zipFileName);
|
||||
|
||||
Miscellaneous.sendEmail(ActivityMaintenance.this, "android-development@gmx.de", "Automation logs", emailBody.toString(), uri);
|
||||
Miscellaneous.sendEmail(ActivityMaintenance.this, "android-development@gmx.de", "Automation logs", getSystemInfo(), uri);
|
||||
}
|
||||
});
|
||||
alertDialogBuilder.setNegativeButton(context.getResources().getString(R.string.no), null);
|
||||
@@ -334,6 +328,19 @@ public class ActivityMaintenance extends Activity
|
||||
return alertDialog;
|
||||
}
|
||||
|
||||
public static String getSystemInfo()
|
||||
{
|
||||
StringBuilder systemInfoText = new StringBuilder();
|
||||
systemInfoText.append("Device details" + Miscellaneous.lineSeparator);
|
||||
systemInfoText.append("OS version: " + System.getProperty("os.version") + Miscellaneous.lineSeparator);
|
||||
systemInfoText.append("API Level: " + android.os.Build.VERSION.SDK + Miscellaneous.lineSeparator);
|
||||
systemInfoText.append("Device: " + android.os.Build.DEVICE + Miscellaneous.lineSeparator);
|
||||
systemInfoText.append("Model: " + android.os.Build.MODEL + Miscellaneous.lineSeparator);
|
||||
systemInfoText.append("Product: " + android.os.Build.PRODUCT + Miscellaneous.lineSeparator);
|
||||
systemInfoText.append("Flavor: " + BuildConfig.FLAVOR);
|
||||
return systemInfoText.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume()
|
||||
{
|
||||
|
@@ -1113,9 +1113,7 @@ public class ActivityManageRule extends Activity
|
||||
{
|
||||
//edit TimeFrame
|
||||
if(resultCode == RESULT_OK && ActivityManageTriggerTimeFrame.editedTimeFrameTrigger != null)
|
||||
{
|
||||
this.refreshTriggerList();
|
||||
}
|
||||
else
|
||||
Miscellaneous.logEvent("w", "TimeFrameEdit", "No timeframe returned. Assuming abort.", 5);
|
||||
}
|
||||
@@ -1133,8 +1131,11 @@ public class ActivityManageRule extends Activity
|
||||
{
|
||||
if(resultCode == RESULT_OK)
|
||||
{
|
||||
newTrigger.setTriggerParameter(data.getBooleanExtra("wifiState", false));
|
||||
newTrigger.setTriggerParameter2(data.getStringExtra("wifiName"));
|
||||
Trigger editedTrigger = new Trigger();
|
||||
editedTrigger.setTriggerType(Trigger_Enum.wifiConnection);
|
||||
editedTrigger.setTriggerParameter(data.getBooleanExtra("wifiState", false));
|
||||
editedTrigger.setTriggerParameter2(data.getStringExtra("wifiName"));
|
||||
ruleToEdit.getTriggerSet().set(editIndex, editedTrigger);
|
||||
this.refreshTriggerList();
|
||||
}
|
||||
}
|
||||
|
@@ -199,6 +199,7 @@ public class AutomationService extends Service implements OnInitListener
|
||||
if (checkStartupRequirements(this, startAtBoot))
|
||||
{
|
||||
Miscellaneous.logEvent("i", "Service", this.getResources().getString(R.string.logServiceStarting) + " VERSION_CODE: " + BuildConfig.VERSION_CODE + ", VERSION_NAME: " + BuildConfig.VERSION_NAME + ", flavor: " + BuildConfig.FLAVOR, 1);
|
||||
Miscellaneous.logEvent("i", "Service", ActivityMaintenance.getSystemInfo(), 1);
|
||||
|
||||
startUpRoutine();
|
||||
|
||||
|
@@ -112,7 +112,7 @@ public class ConnectivityReceiver extends BroadcastReceiver implements Automatio
|
||||
@SuppressLint("NewApi")
|
||||
public static boolean isAirplaneMode(Context context)
|
||||
{
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1)
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1)
|
||||
{
|
||||
int value = android.provider.Settings.System.getInt(context.getContentResolver(), android.provider.Settings.System.AIRPLANE_MODE_ON, 0);
|
||||
return value != 0;
|
||||
|
@@ -45,6 +45,18 @@
|
||||
|
||||
</TableRow>
|
||||
|
||||
<ImageView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_margin="10dp"
|
||||
android:layout_marginVertical="@dimen/default_margin"
|
||||
android:background="#aa000000" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/automationNotificationsIgnored" />
|
||||
|
||||
<TableRow
|
||||
android:layout_marginBottom="@dimen/activity_vertical_margin">
|
||||
|
||||
@@ -78,6 +90,13 @@
|
||||
|
||||
</TableRow>
|
||||
|
||||
<ImageView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_margin="10dp"
|
||||
android:layout_marginVertical="@dimen/default_margin"
|
||||
android:background="#aa000000" />
|
||||
|
||||
<TableRow
|
||||
android:layout_marginBottom="@dimen/activity_vertical_margin">
|
||||
|
||||
@@ -106,6 +125,13 @@
|
||||
</LinearLayout>
|
||||
|
||||
</TableRow>
|
||||
|
||||
<ImageView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_margin="10dp"
|
||||
android:layout_marginVertical="@dimen/default_margin"
|
||||
android:background="#aa000000" />
|
||||
|
||||
<TableRow
|
||||
android:layout_marginBottom="@dimen/activity_vertical_margin">
|
||||
|
@@ -537,7 +537,7 @@
|
||||
<string name="notificationTriggerExplanation">Dieser Auslöser reagiert auf Benachrichtigungen anderer Anwendung im Benachrichtigungsbereich von Android (oder wenn diese geschlossen werden). Sie können eine bestimmte Anwendung festlegen, von die Nachricht stammen muß. Wenn nicht, zählt jede Benachrichtigung. Sie können auch Zeichenketten für Titel oder Nachrichteninhalt festlegen, die enthalten sein müssen. Die Groß-/Kleinschreibung wird hierbei nicht berücksichtigt.</string>
|
||||
<string name="addParameters">Parameter hinzufügen</string>
|
||||
<string name="errorRunningRule">Fehler beim Ausführen einer Regel.</string>
|
||||
<string name="startAppChoiceNote">Hier haben Sie 2 grundsätzliche Optionen:\n\n1. Sie können ein Programm starten, indem Sie eine Activity auswählen.\nStellen Sie sich das so vor, daß Sie ein bestimmtes Fenster einer Anwendung vorauswählen, in das man direkt springt. Behalten Sie im Kopf, daß das nicht immer funktionieren wird. Das liegt daran, daß die Fenster einer Anwendung miteinander interagieren können, sich u.U. Parameter übergeben. Wenn man jetzt ganz kalt in ein bestimmtes Fenster springt, könnte dieses zum Start z.B. bestimmte Parameter erwarten - die fehlen. So könnte es passieren, daß das Fenster zwar versucht zu öffnen, das aber nicht klappt und es somit nie wirlich sichtbar wird. Versuchen Sie\'s trotzdem!\nSie können den Pfad manuell eingeben, sollten aber den Auswählen-Knopf benutzen. Wenn Sie es dennoch manuell eingeben, geben Sie den PackageName ins obere Feld ein und den vollen Pfad der Activity ins untere.\n\n2. Auswahl per Action\nIm Gegensatz zur Auswahl eines bestimmten Fensters, können Sie ein Programm auch über eine Action starten lassen. Stellen Sie sich das so vor als würden Sie in den Wald rufen \"Ich hätte gerne XYZ\" und falls eine Anwendung installiert ist, die das liefern kann, wird sie gestartet. Ein gutes Beispiel wäre zum Beispiel "Browser starten" - es könnten sogar mehrere installiert sein, die das können (aber normalerweise gibts eine, die als Standard eingestellt ist).\nDiese Action müssen Sie manuell eingeben. Der PackageName ist hier optional. Behalten Sie dabei im Auge, daß mögliche Variablen nicht aufgelöst werden. Beispielsweise werden Sie häufig im Internet finden, daß man die Kamera über die Action \"MediaStore.ACTION_IMAGE_CAPTURE\" starten kann. Das ist grundsätzlich nicht richtig, wird aber nicht direkt funktionieren, denn das ist nur eine Variable. Sie müssen dann einen Blick in die Android Dokumentation werfen, wo Sie sehen werden, daß sich hinter dieser Variable eigentlich der Wert \"android.media.action.IMAGE_CAPTURE\" verbirgt. Gibt man diesen in das Feld ein, wird\'s funktionieren.</string>
|
||||
<string name="startAppChoiceNote">Hier haben Sie 2 grundsätzliche Optionen:\n\n1. Sie können ein Programm starten, indem Sie eine Activity auswählen.\nStellen Sie sich das so vor, daß Sie ein bestimmtes Fenster einer Anwendung vorauswählen, in das man direkt springt. Behalten Sie im Kopf, daß das nicht immer funktionieren wird. Das liegt daran, daß die Fenster einer Anwendung miteinander interagieren können, sich u.U. Parameter übergeben. Wenn man jetzt ganz kalt in ein bestimmtes Fenster springt, könnte dieses zum Start z.B. bestimmte Parameter erwarten - die fehlen. So könnte es passieren, daß das Fenster zwar versucht zu öffnen, das aber nicht klappt und es somit nie wirlich sichtbar wird. Versuchen Sie\'s trotzdem!\nSie können den Pfad manuell eingeben, sollten aber den Auswählen-Knopf benutzen. Wenn Sie es dennoch manuell eingeben, geben Sie den PackageName ins obere Feld ein und den vollen Pfad der Activity ins untere.\n\n2. Auswahl per Action\nIm Gegensatz zur Auswahl eines bestimmten Fensters, können Sie ein Programm auch über eine Action starten lassen. Stellen Sie sich das so vor als würden Sie in den Wald rufen \"Ich hätte gerne XYZ\" und falls eine Anwendung installiert ist, die das liefern kann, wird sie gestartet. Ein gutes Beispiel wäre zum Beispiel "Browser starten" - es könnten sogar mehrere installiert sein, die das können (aber normalerweise gibts eine, die als Standard eingestellt ist).\nDiese Action müssen Sie manuell eingeben. Der PackageName ist hier optional. Behalten Sie dabei im Auge, daß mögliche Variablen nicht aufgelöst werden. Beispielsweise werden Sie häufig im Internet finden, daß man die Kamera über die Action \"MediaStore.ACTION_IMAGE_CAPTURE\" starten kann. Das ist grundsätzlich richtig, wird aber nicht direkt funktionieren, denn das ist nur eine Variable. Sie müssen dann einen Blick in die Android Dokumentation werfen, wo Sie sehen werden, daß sich hinter dieser Variable eigentlich der Wert \"android.media.action.IMAGE_CAPTURE\" verbirgt. Gibt man diesen in das Feld ein, wird\'s funktionieren.</string>
|
||||
<string name="cantFindSoundFile">Kann die Audiodatei %1$s nicht finden und daher auch nicht abspielen.</string>
|
||||
<string name="startAppByActivity">per Activity</string>
|
||||
<string name="startAppByAction">per Action</string>
|
||||
|
@@ -702,4 +702,5 @@
|
||||
<string name="dndNothing">Let nothing through</string>
|
||||
<string name="dndRemarks">Fine tuning (like allowing phone calls, picking specific numbers, etc.) can only be done from the system\'s settings.</string>
|
||||
<string name="permissionsRequiredNotAvailable">Your rules required permissions which cannot be requested from this installed flavor of Automation.</string>
|
||||
<string name="automationNotificationsIgnored">If you do not choose a specific app, but choose \"Any app\" notifications from Automation will be ignored to avoid loops.</string>
|
||||
</resources>
|
6
fastlane/metadata/android/de-DE/changelogs/113.txt
Normal file
6
fastlane/metadata/android/de-DE/changelogs/113.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
* Fehler beim Erstellen einer Text-sprechen-Aktion behoben
|
||||
* Fehler beim Hinzufügen eines Benachrichtigungs-Auslösers behoben
|
||||
* Behoben: "beim Anrufen vibrieren" in Profilen hatte nicht funktioniert
|
||||
* Hinzugefügt: "Nicht stören" kann nun in Profilen gesteuert werden.
|
||||
* USB Router für Android 9 und höher unterstützt (benötigt root)
|
||||
* Berechtigung READ_EXTERNAL_STORAGE für Regeln hinzugefügt, die Tondateien abspielen.
|
6
fastlane/metadata/android/en-US/changelogs/113.txt
Normal file
6
fastlane/metadata/android/en-US/changelogs/113.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
* Fixed bug in adding speakText action
|
||||
* Fixed bug in adding notification trigger
|
||||
* Fixed: vibrateWhenRinging setting in profiles didn't save nor activate
|
||||
* Added: Do not disturb can be controlled in a profile
|
||||
* Enabled USB Tethering for Android 9 and above (requires root)
|
||||
* Added permission READ_EXTERNAL_STORAGE for rules that play sound files.
|
6
fastlane/metadata/android/es-ES/changelogs/113.txt
Normal file
6
fastlane/metadata/android/es-ES/changelogs/113.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
* Se ha corregido un error al agregar la acción speakText
|
||||
* Se ha corregido un error en la adición de un disparador de notificación
|
||||
* Corregido: vibrarCuando la configuración deRinging en los perfiles no se guardó ni se activó
|
||||
* Añadido: No molestar se puede controlar en un perfil
|
||||
* Conexión USB habilitada para Android 9 y superior (requiere root)
|
||||
* Se agregó permiso READ_EXTERNAL_STORAGE para las reglas que reproducen archivos de sonido.
|
6
fastlane/metadata/android/it-IT/changelogs/113.txt
Normal file
6
fastlane/metadata/android/it-IT/changelogs/113.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
* Corretto bug nell'aggiunta dell'azione speakText
|
||||
* Risolto bug nell'aggiunta del trigger di notifica
|
||||
* Risolto: vibrazioneQuando l'impostazioneringing nei profili non è stata salvata né attivata
|
||||
* Aggiunto: Non disturbare può essere controllato in un profilo
|
||||
* Tethering USB abilitato per Android 9 e versioni successive (richiede root)
|
||||
* Aggiunta la READ_EXTERNAL_STORAGE delle autorizzazioni per le regole che riproduceno file audio.
|
Reference in New Issue
Block a user