();
- private static String LABEL = "";
- private static DataLabel dataLabeler = new DataLabel();
-
- public static class DataLabel extends BroadcastReceiver {
- @Override
- public void onReceive(Context context, Intent intent) {
- if (intent.getAction().equals(ACTION_AWARE_BAROMETER_LABEL)) {
- LABEL = intent.getStringExtra(EXTRA_LABEL);
- }
- }
- }
@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {
@@ -93,11 +79,10 @@ public void onSensorChanged(SensorEvent event) {
// Proceed with saving as usual.
ContentValues rowData = new ContentValues();
- rowData.put(Barometer_Data.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(Barometer_Data.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(Barometer_Data.TIMESTAMP, TS);
rowData.put(Barometer_Data.AMBIENT_PRESSURE, event.values[0]);
rowData.put(Barometer_Data.ACCURACY, event.accuracy);
- rowData.put(Barometer_Data.LABEL, LABEL);
if (awareSensor != null) awareSensor.onBarometerChanged(rowData);
@@ -168,7 +153,7 @@ private void saveSensorDevice(Sensor sensor) {
Cursor sensorInfo = getContentResolver().query(Barometer_Sensor.CONTENT_URI, null, null, null, null);
if (sensorInfo == null || !sensorInfo.moveToFirst()) {
ContentValues rowData = new ContentValues();
- rowData.put(Barometer_Sensor.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(Barometer_Sensor.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(Barometer_Sensor.TIMESTAMP, System.currentTimeMillis());
rowData.put(Barometer_Sensor.MAXIMUM_RANGE, sensor.getMaximumRange());
rowData.put(Barometer_Sensor.MINIMUM_DELAY, sensor.getMinDelay());
@@ -205,10 +190,6 @@ public void onCreate() {
sensorHandler = new Handler(sensorThread.getLooper());
- IntentFilter filter = new IntentFilter();
- filter.addAction(ACTION_AWARE_BAROMETER_LABEL);
- registerReceiver(dataLabeler, filter);
-
if (Aware.DEBUG) Log.d(TAG, "Barometer service created!");
}
@@ -222,8 +203,6 @@ public void onDestroy() {
wakeLock.release();
- unregisterReceiver(dataLabeler);
-
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Barometer_Provider.getAuthority(this), false);
ContentResolver.removePeriodicSync(
Aware.getAWAREAccount(this),
@@ -250,15 +229,15 @@ public int onStartCommand(Intent intent, int flags, int startId) {
saveSensorDevice(mPressure);
if (Aware.getSetting(this, Aware_Preferences.FREQUENCY_BAROMETER).length() == 0) {
- Aware.setSetting(this, Aware_Preferences.FREQUENCY_BAROMETER, 200000);
+ Aware.setSetting(this, Aware_Preferences.FREQUENCY_BAROMETER, 1000000);
}
if (Aware.getSetting(this, Aware_Preferences.THRESHOLD_BAROMETER).length() == 0) {
Aware.setSetting(this, Aware_Preferences.THRESHOLD_BAROMETER, 0.0);
}
- int new_frequency = Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_BAROMETER));
- double new_threshold = Double.parseDouble(Aware.getSetting(getApplicationContext(), Aware_Preferences.THRESHOLD_BAROMETER));
+ int new_frequency = Aware.getSettingAsInt(getApplicationContext(), Aware_Preferences.FREQUENCY_BAROMETER, 1000000);
+ double new_threshold = Aware.getSettingAsDouble(getApplicationContext(), Aware_Preferences.THRESHOLD_BAROMETER, 0.0);
boolean new_enforce_frequency = (Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_BAROMETER_ENFORCE).equals("true")
|| Aware.getSetting(getApplicationContext(), Aware_Preferences.ENFORCE_FREQUENCY_ALL).equals("true"));
@@ -274,7 +253,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
ENFORCE_FREQUENCY = new_enforce_frequency;
}
- mSensorManager.registerListener(this, mPressure, Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_BAROMETER)), sensorHandler);
+ mSensorManager.registerListener(this, mPressure, SensorTimeUnits.samplingPeriodUs(new_frequency), sensorHandler);
LAST_SAVE = System.currentTimeMillis();
if (Aware.DEBUG) Log.d(TAG, "Barometer service active: " + FREQUENCY + "ms");
@@ -282,7 +261,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
if (Aware.isStudy(this)) {
ContentResolver.setIsSyncable(Aware.getAWAREAccount(this), Barometer_Provider.getAuthority(this), 1);
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Barometer_Provider.getAuthority(this), true);
- long frequency = Long.parseLong(Aware.getSetting(this, Aware_Preferences.FREQUENCY_WEBSERVICE)) * 60;
+ long frequency = Aware.getSettingAsLong(this, Aware_Preferences.FREQUENCY_WEBSERVICE, 30) * 60;
SyncRequest request = new SyncRequest.Builder()
.syncPeriodic(frequency, frequency / 3)
.setSyncAdapter(Aware.getAWAREAccount(this), Barometer_Provider.getAuthority(this))
@@ -299,4 +278,4 @@ public int onStartCommand(Intent intent, int flags, int startId) {
public IBinder onBind(Intent intent) {
return null;
}
-}
\ No newline at end of file
+}
diff --git a/aware-core/src/main/java/com/aware/Battery.java b/aware-core/src/main/java/com/aware/Battery.java
index 88fb39c6..5c49c80e 100644
--- a/aware-core/src/main/java/com/aware/Battery.java
+++ b/aware-core/src/main/java/com/aware/Battery.java
@@ -137,7 +137,7 @@ public void onReceive(Context context, Intent intent) {
ContentValues rowData = new ContentValues();
rowData.put(Battery_Data.TIMESTAMP, System.currentTimeMillis());
- rowData.put(Battery_Data.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ rowData.put(Battery_Data.DEVICE_ID, Aware.getDeviceID(context));
rowData.put(Battery_Data.STATUS, extras.getInt(BatteryManager.EXTRA_STATUS));
rowData.put(Battery_Data.LEVEL, extras.getInt(BatteryManager.EXTRA_LEVEL));
rowData.put(Battery_Data.SCALE, extras.getInt(BatteryManager.EXTRA_SCALE));
@@ -195,7 +195,7 @@ public void onReceive(Context context, Intent intent) {
if (lastBattery != null && lastBattery.moveToFirst()) {
ContentValues rowData = new ContentValues();
rowData.put(Battery_Charges.TIMESTAMP, System.currentTimeMillis());
- rowData.put(Battery_Charges.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ rowData.put(Battery_Charges.DEVICE_ID, Aware.getDeviceID(context));
rowData.put(Battery_Charges.BATTERY_START, lastBattery.getInt(lastBattery.getColumnIndex(Battery_Data.LEVEL)));
context.getContentResolver().insert(Battery_Charges.CONTENT_URI, rowData);
}
@@ -224,7 +224,7 @@ public void onReceive(Context context, Intent intent) {
if (lastBattery != null && lastBattery.moveToFirst()) {
ContentValues rowData = new ContentValues();
rowData.put(Battery_Discharges.TIMESTAMP, System.currentTimeMillis());
- rowData.put(Battery_Discharges.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ rowData.put(Battery_Discharges.DEVICE_ID, Aware.getDeviceID(context));
rowData.put(Battery_Discharges.BATTERY_START, lastBattery.getInt(lastBattery.getColumnIndex(Battery_Data.LEVEL)));
context.getContentResolver().insert(Battery_Discharges.CONTENT_URI, rowData);
}
@@ -251,7 +251,7 @@ public void onReceive(Context context, Intent intent) {
if (lastBattery != null && lastBattery.moveToFirst()) {
ContentValues rowData = new ContentValues();
rowData.put(Battery_Data.TIMESTAMP, System.currentTimeMillis());
- rowData.put(Battery_Data.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ rowData.put(Battery_Data.DEVICE_ID, Aware.getDeviceID(context));
rowData.put(Battery_Data.STATUS, STATUS_PHONE_SHUTDOWN);
rowData.put(Battery_Data.LEVEL, lastBattery.getInt(lastBattery.getColumnIndex(Battery_Data.LEVEL)));
rowData.put(Battery_Data.SCALE, lastBattery.getInt(lastBattery.getColumnIndex(Battery_Data.SCALE)));
@@ -282,7 +282,7 @@ public void onReceive(Context context, Intent intent) {
if (lastBattery != null && lastBattery.moveToFirst()) {
ContentValues rowData = new ContentValues();
rowData.put(Battery_Data.TIMESTAMP, System.currentTimeMillis());
- rowData.put(Battery_Data.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ rowData.put(Battery_Data.DEVICE_ID, Aware.getDeviceID(context));
rowData.put(Battery_Data.STATUS, STATUS_PHONE_REBOOT);
rowData.put(Battery_Data.LEVEL, lastBattery.getInt(lastBattery.getColumnIndex(Battery_Data.LEVEL)));
rowData.put(Battery_Data.SCALE, lastBattery.getInt(lastBattery.getColumnIndex(Battery_Data.SCALE)));
@@ -416,7 +416,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
if (Aware.isStudy(this)) {
ContentResolver.setIsSyncable(Aware.getAWAREAccount(this), Battery_Provider.getAuthority(this), 1);
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Battery_Provider.getAuthority(this), true);
- long frequency = Long.parseLong(Aware.getSetting(this, Aware_Preferences.FREQUENCY_WEBSERVICE)) * 60;
+ long frequency = Aware.getSettingAsLong(this, Aware_Preferences.FREQUENCY_WEBSERVICE, 30) * 60;
SyncRequest request = new SyncRequest.Builder()
.syncPeriodic(frequency, frequency / 3)
.setSyncAdapter(Aware.getAWAREAccount(this), Battery_Provider.getAuthority(this))
diff --git a/aware-core/src/main/java/com/aware/Bluetooth.java b/aware-core/src/main/java/com/aware/Bluetooth.java
index ba2a2291..5cc871f0 100644
--- a/aware-core/src/main/java/com/aware/Bluetooth.java
+++ b/aware-core/src/main/java/com/aware/Bluetooth.java
@@ -29,6 +29,7 @@
import com.aware.providers.Bluetooth_Provider.Bluetooth_Sensor;
import com.aware.utils.Aware_Sensor;
import com.aware.utils.Encrypter;
+import com.aware.utils.SensorTimeUnits;
import java.util.HashMap;
@@ -204,14 +205,14 @@ public int onStartCommand(Intent intent, int flags, int startId) {
save_bluetooth_device(bluetoothAdapter);
- if (FREQUENCY != Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_BLUETOOTH))) {
+ if (FREQUENCY != Aware.getSettingAsInt(getApplicationContext(), Aware_Preferences.FREQUENCY_BLUETOOTH, 60)) {
alarmManager.cancel(bluetoothScan);
alarmManager.setRepeating(AlarmManager.RTC_WAKEUP,
- System.currentTimeMillis() + Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_BLUETOOTH)) * 1000,
- Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_BLUETOOTH)) * 2 * 1000,
+ System.currentTimeMillis() + SensorTimeUnits.secondsToMillis(Aware.getSettingAsInt(getApplicationContext(), Aware_Preferences.FREQUENCY_BLUETOOTH, 60)),
+ SensorTimeUnits.doubleSecondsToMillis(Aware.getSettingAsInt(getApplicationContext(), Aware_Preferences.FREQUENCY_BLUETOOTH, 60)),
bluetoothScan);
- FREQUENCY = Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_BLUETOOTH));
+ FREQUENCY = Aware.getSettingAsInt(getApplicationContext(), Aware_Preferences.FREQUENCY_BLUETOOTH, 60);
}
if (Aware.DEBUG) Log.d(TAG, "Bluetooth service active: " + FREQUENCY + "s");
@@ -229,7 +230,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
ContentResolver.setIsSyncable(Aware.getAWAREAccount(this), Bluetooth_Provider.getAuthority(this), 1);
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Bluetooth_Provider.getAuthority(this), true);
- long frequency = Long.parseLong(Aware.getSetting(this, Aware_Preferences.FREQUENCY_WEBSERVICE)) * 60;
+ long frequency = Aware.getSettingAsLong(this, Aware_Preferences.FREQUENCY_WEBSERVICE, 30) * 60;
SyncRequest request = new SyncRequest.Builder()
.syncPeriodic(frequency, frequency / 3)
.setSyncAdapter(Aware.getAWAREAccount(this), Bluetooth_Provider.getAuthority(this))
@@ -246,13 +247,20 @@ public int onStartCommand(Intent intent, int flags, int startId) {
public void run() {
BluetoothLeScanner scanner = bluetoothAdapter.getBluetoothLeScanner();
if (scanner != null && !isBLEScanning) {
- mBLEHandler.postDelayed(stopScan, 3000);
- scanner.startScan(null, scanSettings, scanCallback);
- if (awareSensor != null) awareSensor.onBLEScanStarted();
- if (Aware.DEBUG) Log.d(TAG, ACTION_AWARE_BLUETOOTH_BLE_SCAN_STARTED);
- Intent scanStart = new Intent(ACTION_AWARE_BLUETOOTH_BLE_SCAN_STARTED);
- sendBroadcast(scanStart);
- isBLEScanning = !isBLEScanning;
+ try {
+ // startScan needs BLUETOOTH_SCAN on Android 12+; skip the scan rather than crash
+ // the app if it isn't granted.
+ mBLEHandler.postDelayed(stopScan, 3000);
+ scanner.startScan(null, scanSettings, scanCallback);
+ if (awareSensor != null) awareSensor.onBLEScanStarted();
+ if (Aware.DEBUG) Log.d(TAG, ACTION_AWARE_BLUETOOTH_BLE_SCAN_STARTED);
+ Intent scanStart = new Intent(ACTION_AWARE_BLUETOOTH_BLE_SCAN_STARTED);
+ sendBroadcast(scanStart);
+ isBLEScanning = !isBLEScanning;
+ } catch (SecurityException e) {
+ Log.w(TAG, "Skipping BLE scan: missing BLUETOOTH_SCAN permission", e);
+ mBLEHandler.removeCallbacks(stopScan);
+ }
}
}
};
@@ -262,7 +270,11 @@ public void run() {
public void run() {
BluetoothLeScanner scanner = bluetoothAdapter.getBluetoothLeScanner();
if (scanner != null && isBLEScanning) {
- scanner.stopScan(scanCallback);
+ try {
+ scanner.stopScan(scanCallback);
+ } catch (SecurityException e) {
+ Log.w(TAG, "Skipping BLE stopScan: missing BLUETOOTH_SCAN permission", e);
+ }
if (awareSensor != null) awareSensor.onBLEScanEnded();
if (Aware.DEBUG) Log.d(TAG, ACTION_AWARE_BLUETOOTH_BLE_SCAN_ENDED);
Intent scanEnd = new Intent(ACTION_AWARE_BLUETOOTH_BLE_SCAN_ENDED);
@@ -283,7 +295,7 @@ public void onScanResult(int callbackType, ScanResult result) {
discoveredBLE.put(bluetoothDevice.getAddress(), bluetoothDevice);
ContentValues rowData = new ContentValues();
- rowData.put(Bluetooth_Data.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(Bluetooth_Data.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(Bluetooth_Data.TIMESTAMP, System.currentTimeMillis());
rowData.put(Bluetooth_Data.BT_ADDRESS, Encrypter.hashMac(getApplicationContext(), bluetoothDevice.getAddress()));
rowData.put(Bluetooth_Data.BT_NAME, Encrypter.hashSsid(getApplicationContext(), bluetoothDevice.getName()));
@@ -377,7 +389,7 @@ public void onReceive(Context context, Intent intent) {
Short btDeviceRSSI = extras.getShort(BluetoothDevice.EXTRA_RSSI);
ContentValues rowData = new ContentValues();
- rowData.put(Bluetooth_Data.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ rowData.put(Bluetooth_Data.DEVICE_ID, Aware.getDeviceID(context));
rowData.put(Bluetooth_Data.TIMESTAMP, System.currentTimeMillis());
rowData.put(Bluetooth_Data.BT_ADDRESS, Encrypter.hashMac(context, btDevice.getAddress()));
rowData.put(Bluetooth_Data.BT_NAME, Encrypter.hashSsid(context, btDevice.getName()));
@@ -436,15 +448,18 @@ public void onReceive(Context context, Intent intent) {
if (intent.getAction().equals(ACTION_AWARE_BLUETOOTH_REQUEST_SCAN)) {
//interrupt ongoing scans
- if (bluetoothAdapter.isDiscovering()) bluetoothAdapter.cancelDiscovery();
- if (!bluetoothAdapter.isDiscovering()) {
- if (bluetoothAdapter.isEnabled()) {
- bluetoothAdapter.startDiscovery();
- } else {
+ try {
+ // isDiscovering/cancelDiscovery/startDiscovery need BLUETOOTH_SCAN on Android 12+;
+ // skip the scan rather than crash the app if it isn't granted.
+ if (bluetoothAdapter.isDiscovering()) bluetoothAdapter.cancelDiscovery();
+ if (!bluetoothAdapter.isDiscovering()) {
+ if (bluetoothAdapter.isEnabled()) {
+ bluetoothAdapter.startDiscovery();
+ } else {
//Bluetooth is off
if (Aware.DEBUG) Log.d(TAG, "Bluetooth is turned off...");
ContentValues rowData = new ContentValues();
- rowData.put(Bluetooth_Data.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ rowData.put(Bluetooth_Data.DEVICE_ID, Aware.getDeviceID(context));
rowData.put(Bluetooth_Data.TIMESTAMP, System.currentTimeMillis());
rowData.put(Bluetooth_Data.BT_NAME, "disabled");
rowData.put(Bluetooth_Data.BT_ADDRESS, "disabled");
@@ -460,6 +475,9 @@ public void onReceive(Context context, Intent intent) {
if (Aware.DEBUG) Log.d(TAG, e.getMessage());
}
}
+ }
+ } catch (SecurityException se) {
+ Log.w(TAG, "Skipping Bluetooth discovery: missing BLUETOOTH_SCAN permission", se);
}
}
@@ -468,7 +486,7 @@ public void onReceive(Context context, Intent intent) {
if (btDevice == null) return;
ContentValues rowData = new ContentValues();
- rowData.put(Bluetooth_Data.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ rowData.put(Bluetooth_Data.DEVICE_ID, Aware.getDeviceID(context));
rowData.put(Bluetooth_Data.TIMESTAMP, System.currentTimeMillis());
rowData.put(Bluetooth_Data.BT_ADDRESS, Encrypter.hashMac(context, btDevice.getAddress()));
rowData.put(Bluetooth_Data.BT_NAME, Encrypter.hashSsid(context, btDevice.getName()));
@@ -492,7 +510,7 @@ public void onReceive(Context context, Intent intent) {
if (btDevice == null) return;
ContentValues rowData = new ContentValues();
- rowData.put(Bluetooth_Data.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ rowData.put(Bluetooth_Data.DEVICE_ID, Aware.getDeviceID(context));
rowData.put(Bluetooth_Data.TIMESTAMP, System.currentTimeMillis());
rowData.put(Bluetooth_Data.BT_ADDRESS, Encrypter.hashMac(context, btDevice.getAddress()));
rowData.put(Bluetooth_Data.BT_NAME, Encrypter.hashSsid(context, btDevice.getName()));
@@ -520,15 +538,23 @@ private void save_bluetooth_device(BluetoothAdapter btAdapter) {
Cursor sensorBT = getContentResolver().query(Bluetooth_Sensor.CONTENT_URI, null, null, null, null);
if (sensorBT == null || !sensorBT.moveToFirst()) {
- ContentValues rowData = new ContentValues();
- rowData.put(Bluetooth_Sensor.TIMESTAMP, System.currentTimeMillis());
- rowData.put(Bluetooth_Sensor.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
- rowData.put(Bluetooth_Sensor.BT_ADDRESS, Encrypter.hashMac(getApplicationContext(), btAdapter.getAddress()));
- rowData.put(Bluetooth_Sensor.BT_NAME, Encrypter.hashSsid(getApplicationContext(), btAdapter.getName()));
+ try {
+ // getAddress()/getName() require the BLUETOOTH_CONNECT runtime permission on Android 12+
+ // (API 31). Without it the platform throws a SecurityException — and since this runs in
+ // onStartCommand on the main thread, an uncaught one crashes the whole app. Skip saving
+ // the local device info rather than crash if the permission isn't granted.
+ ContentValues rowData = new ContentValues();
+ rowData.put(Bluetooth_Sensor.TIMESTAMP, System.currentTimeMillis());
+ rowData.put(Bluetooth_Sensor.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
+ rowData.put(Bluetooth_Sensor.BT_ADDRESS, Encrypter.hashMac(getApplicationContext(), btAdapter.getAddress()));
+ rowData.put(Bluetooth_Sensor.BT_NAME, Encrypter.hashSsid(getApplicationContext(), btAdapter.getName()));
- getContentResolver().insert(Bluetooth_Sensor.CONTENT_URI, rowData);
+ getContentResolver().insert(Bluetooth_Sensor.CONTENT_URI, rowData);
- if (Aware.DEBUG) Log.d(TAG, "Bluetooth local information: " + rowData.toString());
+ if (Aware.DEBUG) Log.d(TAG, "Bluetooth local information: " + rowData.toString());
+ } catch (SecurityException e) {
+ Log.w(TAG, "Skipping local Bluetooth info: missing BLUETOOTH_CONNECT permission", e);
+ }
}
if (sensorBT != null && !sensorBT.isClosed()) sensorBT.close();
}
diff --git a/aware-core/src/main/java/com/aware/Communication.java b/aware-core/src/main/java/com/aware/Communication.java
index a6a7b0df..bd897f9c 100644
--- a/aware-core/src/main/java/com/aware/Communication.java
+++ b/aware-core/src/main/java/com/aware/Communication.java
@@ -131,7 +131,7 @@ public void onChange(boolean selfChange) {
if (Aware.getSetting(getApplicationContext(), Aware_Preferences.STATUS_CALLS).equals("true")) {
ContentValues received = new ContentValues();
received.put(Calls_Data.TIMESTAMP, lastCall.getLong(lastCall.getColumnIndex(Calls.DATE)));
- received.put(Calls_Data.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ received.put(Calls_Data.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
received.put(Calls_Data.TYPE, Calls.INCOMING_TYPE);
received.put(Calls_Data.DURATION, lastCall.getInt(lastCall.getColumnIndex(Calls.DURATION)));
received.put(Calls_Data.TRACE, Encrypter.hashPhone(getApplicationContext(), lastCall.getString(lastCall.getColumnIndex(Calls.NUMBER))));
@@ -159,7 +159,7 @@ public void onChange(boolean selfChange) {
if (Aware.getSetting(getApplicationContext(), Aware_Preferences.STATUS_CALLS).equals("true")) {
ContentValues missed = new ContentValues();
missed.put(Calls_Data.TIMESTAMP, lastCall.getLong(lastCall.getColumnIndex(Calls.DATE)));
- missed.put(Calls_Data.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ missed.put(Calls_Data.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
missed.put(Calls_Data.TYPE, Calls.MISSED_TYPE);
missed.put(Calls_Data.DURATION, lastCall.getInt(lastCall.getColumnIndex(Calls.DURATION)));
missed.put(Calls_Data.TRACE, Encrypter.hashPhone(getApplicationContext(), lastCall.getString(lastCall.getColumnIndex(Calls.NUMBER))));
@@ -185,7 +185,7 @@ public void onChange(boolean selfChange) {
if (Aware.getSetting(getApplicationContext(), Aware_Preferences.STATUS_CALLS).equals("true")) {
ContentValues outgoing = new ContentValues();
outgoing.put(Calls_Data.TIMESTAMP, lastCall.getLong(lastCall.getColumnIndex(Calls.DATE)));
- outgoing.put(Calls_Data.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ outgoing.put(Calls_Data.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
outgoing.put(Calls_Data.TYPE, Calls.OUTGOING_TYPE);
outgoing.put(Calls_Data.DURATION, lastCall.getInt(lastCall.getColumnIndex(Calls.DURATION)));
outgoing.put(Calls_Data.TRACE, Encrypter.hashPhone(getApplicationContext(), lastCall.getString(lastCall.getColumnIndex(Calls.NUMBER))));
@@ -235,7 +235,7 @@ public void onChange(boolean selfChange) {
if (Aware.getSetting(getApplicationContext(), Aware_Preferences.STATUS_MESSAGES).equals("true")) {
ContentValues inbox = new ContentValues();
inbox.put(Messages_Data.TIMESTAMP, lastMessage.getLong(lastMessage.getColumnIndex("date")));
- inbox.put(Messages_Data.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ inbox.put(Messages_Data.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
inbox.put(Messages_Data.TYPE, MESSAGE_INBOX);
inbox.put(Messages_Data.TRACE, Encrypter.hashPhone(getApplicationContext(), lastMessage.getString(lastMessage.getColumnIndex("address"))));
@@ -261,7 +261,7 @@ public void onChange(boolean selfChange) {
if (Aware.getSetting(getApplicationContext(), Aware_Preferences.STATUS_MESSAGES).equals("true")) {
ContentValues sent = new ContentValues();
sent.put(Messages_Data.TIMESTAMP, lastMessage.getLong(lastMessage.getColumnIndex("date")));
- sent.put(Messages_Data.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ sent.put(Messages_Data.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
sent.put(Messages_Data.TYPE, MESSAGE_SENT);
sent.put(Messages_Data.TRACE, Encrypter.hashPhone(getApplicationContext(), lastMessage.getString(lastMessage.getColumnIndex("address"))));
@@ -390,7 +390,6 @@ public void onCreate() {
callsObs = new CallsObserver(new Handler());
msgsObs = new MessagesObserver(new Handler());
- REQUIRED_PERMISSIONS.add(Manifest.permission.READ_CONTACTS);
REQUIRED_PERMISSIONS.add(Manifest.permission.READ_PHONE_STATE);
REQUIRED_PERMISSIONS.add(Manifest.permission.READ_CALL_LOG);
REQUIRED_PERMISSIONS.add(Manifest.permission.READ_SMS);
@@ -422,7 +421,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
if (Aware.isStudy(this)) {
ContentResolver.setIsSyncable(Aware.getAWAREAccount(this), Communication_Provider.getAuthority(this), 1);
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Communication_Provider.getAuthority(this), true);
- long frequency = Long.parseLong(Aware.getSetting(this, Aware_Preferences.FREQUENCY_WEBSERVICE)) * 60;
+ long frequency = Aware.getSettingAsLong(this, Aware_Preferences.FREQUENCY_WEBSERVICE, 30) * 60;
SyncRequest request = new SyncRequest.Builder()
.syncPeriodic(frequency, frequency / 3)
.setSyncAdapter(Aware.getAWAREAccount(this), Communication_Provider.getAuthority(this))
diff --git a/aware-core/src/main/java/com/aware/ESM.java b/aware-core/src/main/java/com/aware/ESM.java
index d1345dee..6590711c 100644
--- a/aware-core/src/main/java/com/aware/ESM.java
+++ b/aware-core/src/main/java/com/aware/ESM.java
@@ -11,6 +11,7 @@
import android.os.Build;
import android.os.Bundle;
import android.os.IBinder;
+import android.os.SystemClock;
import android.util.Log;
import android.widget.Toast;
import androidx.core.app.NotificationCompat;
@@ -207,7 +208,7 @@ public class ESM extends Aware_Sensor {
/**
* Required String extra for displaying an ESM. It should contain the JSON string that defines the ESM dialog.
- * Examples:
+ * Examples:
* Free text: [{'esm':{'esm_type':1,'esm_title':'ESM Freetext','esm_instructions':'The user can answer an open ended question.','esm_submit':'Next','esm_expiration_threshold':20,'esm_trigger':'esm trigger example'}}]
* Radio: [{'esm':{'esm_type':2,'esm_title':'ESM Radio','esm_instructions':'The user can only choose one option','esm_radios':['Option one','Option two','Other'],'esm_submit':'Next','esm_expiration_threshold':30,'esm_trigger':'esm trigger example'}}]
* Checkbox: [{'esm':{'esm_type':3,'esm_title':'ESM Checkbox','esm_instructions':'The user can choose multiple options','esm_checkboxes':['One','Two','Other'],'esm_submit':'Next','esm_expiration_threshold':40,'esm_trigger':'esm trigger example'}}]
@@ -291,7 +292,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
if (Aware.isStudy(this)) {
ContentResolver.setIsSyncable(Aware.getAWAREAccount(this), ESM_Provider.getAuthority(this), 1);
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), ESM_Provider.getAuthority(this), true);
- long frequency = Long.parseLong(Aware.getSetting(this, Aware_Preferences.FREQUENCY_WEBSERVICE)) * 60;
+ long frequency = Aware.getSettingAsLong(this, Aware_Preferences.FREQUENCY_WEBSERVICE, 30) * 60;
SyncRequest request = new SyncRequest.Builder()
.syncPeriodic(frequency, frequency / 3)
.setSyncAdapter(Aware.getAWAREAccount(this), ESM_Provider.getAuthority(this))
@@ -387,7 +388,7 @@ public static void queueESM(Context context, String queue, boolean isTrial) {
ContentValues rowData = new ContentValues();
rowData.put(ESM_Data.TIMESTAMP, esm_timestamp + i); //fix issue with synching and support ordering
- rowData.put(ESM_Data.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ rowData.put(ESM_Data.DEVICE_ID, Aware.getDeviceID(context));
rowData.put(ESM_Data.JSON, esm.toString());
rowData.put(ESM_Data.EXPIRATION_THRESHOLD, esm.optInt(ESM_Data.EXPIRATION_THRESHOLD)); //optional, defaults to 0
rowData.put(ESM_Data.NOTIFICATION_TIMEOUT, esm.optInt(ESM_Data.NOTIFICATION_TIMEOUT)); //optional, defaults to 0
@@ -414,8 +415,15 @@ public static void queueESM(Context context, String queue, boolean isTrial) {
if (notification_timeout > 0) {
try {
ESM_Question question = new ESM_Question().rebuild(new JSONObject(pendingESM.getString(pendingESM.getColumnIndex(ESM_Data.JSON))));
+ // Only one queue-expiration timer may exist. Hourly schedules can replace
+ // a still-pending ESM before its timeout; retaining every old AsyncTask
+ // created a long-lived timer backlog.
+ if (esm_notif_expire != null) esm_notif_expire.cancel(true);
esm_notif_expire = new ESMNotificationTimeout(context, System.currentTimeMillis(), notification_timeout, question.getNotificationRetry(), pendingESM.getInt(pendingESM.getColumnIndex(ESM_Data._ID)));
- esm_notif_expire.execute();
+ // This timer can sleep for hours. AsyncTask.execute() uses the process-wide
+ // serial executor, which made unrelated work (including study join after
+ // consent) wait behind the timer forever. Keep the timer off that queue.
+ esm_notif_expire.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
} catch (JSONException e) {
e.printStackTrace();
}
@@ -487,16 +495,16 @@ public static class ESMNotificationTimeout extends AsyncTask {
protected Void doInBackground(Void... params) {
if (mRetries == 0) {
while ((System.currentTimeMillis() - display_timestamp) / 1000 <= expires_in_seconds) {
- if (isCancelled()) {
- return null;
- }
+ if (isCancelled()) return null;
+ // This used to spin continuously for the full ESM timeout (often hours),
+ // consuming a CPU core for every queued notification.
+ SystemClock.sleep(1000);
}
} else {
while (mRetries > 0) {
while ((System.currentTimeMillis() - display_timestamp) / 1000 <= expires_in_seconds) {
- if (isCancelled()) {
- return null;
- }
+ if (isCancelled()) return null;
+ SystemClock.sleep(1000);
}
mRetries--;
display_timestamp = System.currentTimeMillis(); //move forward time and try again
@@ -666,7 +674,7 @@ private static void processFlow(Context context, String current_answer) {
//Queued ESM
ContentValues rowData = new ContentValues();
rowData.put(ESM_Data.TIMESTAMP, System.currentTimeMillis()); //fixed issue with synching and support ordering of esms by timestamp
- rowData.put(ESM_Data.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ rowData.put(ESM_Data.DEVICE_ID, Aware.getDeviceID(context));
rowData.put(ESM_Data.JSON, nextESM.toString());
rowData.put(ESM_Data.EXPIRATION_THRESHOLD, nextESM.optInt(ESM_Data.EXPIRATION_THRESHOLD)); //optional, defaults to 0
rowData.put(ESM_Data.NOTIFICATION_TIMEOUT, nextESM.optInt(ESM_Data.NOTIFICATION_TIMEOUT)); //optional, defaults to 0
@@ -681,7 +689,7 @@ private static void processFlow(Context context, String current_answer) {
//Branched ESM
ContentValues rowData = new ContentValues();
rowData.put(ESM_Data.TIMESTAMP, System.currentTimeMillis()); //fixed issue with synching and support ordering of esms by timestamp
- rowData.put(ESM_Data.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ rowData.put(ESM_Data.DEVICE_ID, Aware.getDeviceID(context));
rowData.put(ESM_Data.JSON, nextESM.toString());
rowData.put(ESM_Data.EXPIRATION_THRESHOLD, nextESM.optInt(ESM_Data.EXPIRATION_THRESHOLD)); //optional, defaults to 0
rowData.put(ESM_Data.NOTIFICATION_TIMEOUT, nextESM.optInt(ESM_Data.NOTIFICATION_TIMEOUT)); //optional, defaults to 0
@@ -698,4 +706,4 @@ private static void processFlow(Context context, String current_answer) {
e.printStackTrace();
}
}
-}
\ No newline at end of file
+}
diff --git a/aware-core/src/main/java/com/aware/Gravity.java b/aware-core/src/main/java/com/aware/Gravity.java
index 9c8b25ed..4f32077b 100644
--- a/aware-core/src/main/java/com/aware/Gravity.java
+++ b/aware-core/src/main/java/com/aware/Gravity.java
@@ -1,12 +1,10 @@
package com.aware;
-import android.content.BroadcastReceiver;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
-import android.content.IntentFilter;
import android.content.SyncRequest;
import android.database.Cursor;
import android.database.SQLException;
@@ -26,6 +24,7 @@
import com.aware.providers.Gravity_Provider.Gravity_Data;
import com.aware.providers.Gravity_Provider.Gravity_Sensor;
import com.aware.utils.Aware_Sensor;
+import com.aware.utils.SensorTimeUnits;
import java.util.ArrayList;
import java.util.List;
@@ -64,8 +63,6 @@ public class Gravity extends Aware_Sensor implements SensorEventListener {
* ContentProvider: Gravity_Provider
*/
public static final String ACTION_AWARE_GRAVITY = "ACTION_AWARE_GRAVITY";
- public static final String ACTION_AWARE_GRAVITY_LABEL = "ACTION_AWARE_GRAVITY_LABEL";
- public static final String EXTRA_LABEL = "label";
/**
* Until today, no available Android phone samples higher than 208Hz (Nexus 7).
@@ -73,19 +70,6 @@ public class Gravity extends Aware_Sensor implements SensorEventListener {
*/
private List data_values = new ArrayList();
- private static String LABEL = "";
-
- private static DataLabel dataLabeler = new DataLabel();
-
- public static class DataLabel extends BroadcastReceiver {
- @Override
- public void onReceive(Context context, Intent intent) {
- if (intent.getAction().equals(ACTION_AWARE_GRAVITY_LABEL)) {
- LABEL = intent.getStringExtra(EXTRA_LABEL);
- }
- }
- }
-
@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {
//We log current accuracy on the sensor changed event
@@ -131,13 +115,12 @@ public void run() {
LAST_VALUES = new Float[]{event.values[0], event.values[1], event.values[2]};
ContentValues rowData = new ContentValues();
- rowData.put(Gravity_Data.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(Gravity_Data.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(Gravity_Data.TIMESTAMP, TS);
rowData.put(Gravity_Data.VALUES_0, event.values[0]);
rowData.put(Gravity_Data.VALUES_1, event.values[1]);
rowData.put(Gravity_Data.VALUES_2, event.values[2]);
rowData.put(Gravity_Data.ACCURACY, event.accuracy);
- rowData.put(Gravity_Data.LABEL, LABEL);
if (awareSensor != null) awareSensor.onGravityChanged(rowData);
@@ -207,7 +190,7 @@ private void saveSensorDevice(Sensor sensor) {
Cursor sensorInfo = getContentResolver().query(Gravity_Sensor.CONTENT_URI, null, null, null, null);
if (sensorInfo == null || !sensorInfo.moveToFirst()) {
ContentValues rowData = new ContentValues();
- rowData.put(Gravity_Sensor.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(Gravity_Sensor.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(Gravity_Sensor.TIMESTAMP, System.currentTimeMillis());
rowData.put(Gravity_Sensor.MAXIMUM_RANGE, sensor.getMaximumRange());
rowData.put(Gravity_Sensor.MINIMUM_DELAY, sensor.getMinDelay());
@@ -243,10 +226,6 @@ public void onCreate() {
sensorHandler = new Handler(sensorThread.getLooper());
- IntentFilter filter = new IntentFilter();
- filter.addAction(ACTION_AWARE_GRAVITY_LABEL);
- registerReceiver(dataLabeler, filter);
-
if (Aware.DEBUG) Log.d(TAG, "Gravity service created!");
}
@@ -261,8 +240,6 @@ public void onDestroy() {
wakeLock.release();
- unregisterReceiver(dataLabeler);
-
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Gravity_Provider.getAuthority(this), false);
ContentResolver.removePeriodicSync(
Aware.getAWAREAccount(this),
@@ -287,15 +264,15 @@ public int onStartCommand(Intent intent, int flags, int startId) {
saveSensorDevice(mGravity);
if (Aware.getSetting(this, Aware_Preferences.FREQUENCY_GRAVITY).length() == 0) {
- Aware.setSetting(this, Aware_Preferences.FREQUENCY_GRAVITY, 200000);
+ Aware.setSetting(this, Aware_Preferences.FREQUENCY_GRAVITY, 20000);
}
if (Aware.getSetting(this, Aware_Preferences.THRESHOLD_GRAVITY).length() == 0) {
Aware.setSetting(this, Aware_Preferences.THRESHOLD_GRAVITY, 0.0);
}
- int new_frequency = Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_GRAVITY));
- double new_threshold = Double.parseDouble(Aware.getSetting(getApplicationContext(), Aware_Preferences.THRESHOLD_GRAVITY));
+ int new_frequency = Aware.getSettingAsInt(getApplicationContext(), Aware_Preferences.FREQUENCY_GRAVITY, 20000);
+ double new_threshold = Aware.getSettingAsDouble(getApplicationContext(), Aware_Preferences.THRESHOLD_GRAVITY, 0.0);
boolean new_enforce_frequency = (Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_GRAVITY_ENFORCE).equals("true")
|| Aware.getSetting(getApplicationContext(), Aware_Preferences.ENFORCE_FREQUENCY_ALL).equals("true"));
@@ -311,7 +288,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
ENFORCE_FREQUENCY = new_enforce_frequency;
}
- mSensorManager.registerListener(this, mGravity, Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_GRAVITY)), sensorHandler);
+ mSensorManager.registerListener(this, mGravity, SensorTimeUnits.samplingPeriodUs(new_frequency), sensorHandler);
LAST_SAVE = System.currentTimeMillis();
if (Aware.DEBUG) Log.d(TAG, "Gravity service active: " + FREQUENCY + "ms");
@@ -319,7 +296,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
if (Aware.isStudy(this)) {
ContentResolver.setIsSyncable(Aware.getAWAREAccount(this), Gravity_Provider.getAuthority(this), 1);
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Gravity_Provider.getAuthority(this), true);
- long frequency = Long.parseLong(Aware.getSetting(this, Aware_Preferences.FREQUENCY_WEBSERVICE)) * 60;
+ long frequency = Aware.getSettingAsLong(this, Aware_Preferences.FREQUENCY_WEBSERVICE, 30) * 60;
SyncRequest request = new SyncRequest.Builder()
.syncPeriodic(frequency, frequency / 3)
.setSyncAdapter(Aware.getAWAREAccount(this), Gravity_Provider.getAuthority(this))
@@ -336,4 +313,4 @@ public int onStartCommand(Intent intent, int flags, int startId) {
public IBinder onBind(Intent intent) {
return null;
}
-}
\ No newline at end of file
+}
diff --git a/aware-core/src/main/java/com/aware/Gyroscope.java b/aware-core/src/main/java/com/aware/Gyroscope.java
index 611cdafd..6bb90504 100644
--- a/aware-core/src/main/java/com/aware/Gyroscope.java
+++ b/aware-core/src/main/java/com/aware/Gyroscope.java
@@ -1,12 +1,10 @@
package com.aware;
-import android.content.BroadcastReceiver;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
-import android.content.IntentFilter;
import android.content.SyncRequest;
import android.database.Cursor;
import android.database.SQLException;
@@ -26,6 +24,7 @@
import com.aware.providers.Gyroscope_Provider.Gyroscope_Data;
import com.aware.providers.Gyroscope_Provider.Gyroscope_Sensor;
import com.aware.utils.Aware_Sensor;
+import com.aware.utils.SensorTimeUnits;
import java.util.ArrayList;
import java.util.List;
@@ -66,28 +65,12 @@ public class Gyroscope extends Aware_Sensor implements SensorEventListener {
public static final String EXTRA_SENSOR = "sensor";
public static final String EXTRA_DATA = "data";
- public static final String ACTION_AWARE_GYROSCOPE_LABEL = "ACTION_AWARE_GYROSCOPE_LABEL";
- public static final String EXTRA_LABEL = "label";
-
/**
* Until today, no available Android phone samples higher than 208Hz (Nexus 7).
* http://ilessendata.blogspot.com/2012/11/android-accelerometer-sampling-rates.html
*/
private List data_values = new ArrayList<>();
- private static String LABEL = "";
-
- private static DataLabel dataLabeler = new DataLabel();
-
- public static class DataLabel extends BroadcastReceiver {
- @Override
- public void onReceive(Context context, Intent intent) {
- if (intent.getAction().equals(ACTION_AWARE_GYROSCOPE_LABEL)) {
- LABEL = intent.getStringExtra(EXTRA_LABEL);
- }
- }
- }
-
@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {
//we log accuracy on the sensor changed values
@@ -135,13 +118,12 @@ public void run() {
// Proceed with saving as usual.
ContentValues rowData = new ContentValues();
- rowData.put(Gyroscope_Data.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(Gyroscope_Data.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(Gyroscope_Data.TIMESTAMP, TS);
rowData.put(Gyroscope_Data.VALUES_0, event.values[0]);
rowData.put(Gyroscope_Data.VALUES_1, event.values[1]);
rowData.put(Gyroscope_Data.VALUES_2, event.values[2]);
rowData.put(Gyroscope_Data.ACCURACY, event.accuracy);
- rowData.put(Gyroscope_Data.LABEL, LABEL);
if (awareSensor != null) awareSensor.onGyroscopeChanged(rowData);
@@ -210,7 +192,7 @@ private void saveGyroscopeDevice(Sensor gyro) {
Cursor gyroInfo = getContentResolver().query(Gyroscope_Sensor.CONTENT_URI, null, null, null, null);
if (gyroInfo == null || !gyroInfo.moveToFirst()) {
ContentValues rowData = new ContentValues();
- rowData.put(Gyroscope_Sensor.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(Gyroscope_Sensor.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(Gyroscope_Sensor.TIMESTAMP, System.currentTimeMillis());
rowData.put(Gyroscope_Sensor.MAXIMUM_RANGE, gyro.getMaximumRange());
rowData.put(Gyroscope_Sensor.MINIMUM_DELAY, gyro.getMinDelay());
@@ -252,10 +234,6 @@ public void onCreate() {
sensorHandler = new Handler(sensorThread.getLooper());
- IntentFilter filter = new IntentFilter();
- filter.addAction(ACTION_AWARE_GYROSCOPE_LABEL);
- registerReceiver(dataLabeler, filter);
-
if (Aware.DEBUG) Log.d(TAG, "Gyroscope service created!");
}
@@ -269,8 +247,6 @@ public void onDestroy() {
wakeLock.release();
- unregisterReceiver(dataLabeler);
-
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Gyroscope_Provider.getAuthority(this), false);
ContentResolver.removePeriodicSync(
Aware.getAWAREAccount(this),
@@ -298,15 +274,15 @@ public int onStartCommand(Intent intent, int flags, int startId) {
saveGyroscopeDevice(mGyroscope);
if (Aware.getSetting(this, Aware_Preferences.FREQUENCY_GYROSCOPE).length() == 0) {
- Aware.setSetting(this, Aware_Preferences.FREQUENCY_GYROSCOPE, 200000);
+ Aware.setSetting(this, Aware_Preferences.FREQUENCY_GYROSCOPE, 20000);
}
if (Aware.getSetting(this, Aware_Preferences.THRESHOLD_GYROSCOPE).length() == 0) {
Aware.setSetting(this, Aware_Preferences.THRESHOLD_GYROSCOPE, 0.0);
}
- int new_frequency = Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_GYROSCOPE));
- double new_threshold = Double.parseDouble(Aware.getSetting(getApplicationContext(), Aware_Preferences.THRESHOLD_GYROSCOPE));
+ int new_frequency = Aware.getSettingAsInt(getApplicationContext(), Aware_Preferences.FREQUENCY_GYROSCOPE, 20000);
+ double new_threshold = Aware.getSettingAsDouble(getApplicationContext(), Aware_Preferences.THRESHOLD_GYROSCOPE, 0.0);
boolean new_enforce_frequency = (Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_GYROSCOPE_ENFORCE).equals("true")
|| Aware.getSetting(getApplicationContext(), Aware_Preferences.ENFORCE_FREQUENCY_ALL).equals("true"));
@@ -322,7 +298,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
ENFORCE_FREQUENCY = new_enforce_frequency;
}
- mSensorManager.registerListener(this, mGyroscope, Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_GYROSCOPE)), sensorHandler);
+ mSensorManager.registerListener(this, mGyroscope, SensorTimeUnits.samplingPeriodUs(new_frequency), sensorHandler);
LAST_SAVE = System.currentTimeMillis();
}
@@ -331,7 +307,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
if (Aware.isStudy(this)) {
ContentResolver.setIsSyncable(Aware.getAWAREAccount(this), Gyroscope_Provider.getAuthority(this), 1);
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Gyroscope_Provider.getAuthority(this), true);
- long frequency = Long.parseLong(Aware.getSetting(this, Aware_Preferences.FREQUENCY_WEBSERVICE)) * 60;
+ long frequency = Aware.getSettingAsLong(this, Aware_Preferences.FREQUENCY_WEBSERVICE, 30) * 60;
SyncRequest request = new SyncRequest.Builder()
.syncPeriodic(frequency, frequency / 3)
.setSyncAdapter(Aware.getAWAREAccount(this), Gyroscope_Provider.getAuthority(this))
@@ -347,4 +323,4 @@ public int onStartCommand(Intent intent, int flags, int startId) {
public IBinder onBind(Intent intent) {
return null;
}
-}
\ No newline at end of file
+}
diff --git a/aware-core/src/main/java/com/aware/Installations.java b/aware-core/src/main/java/com/aware/Installations.java
index 992a13d3..ab77b72f 100644
--- a/aware-core/src/main/java/com/aware/Installations.java
+++ b/aware-core/src/main/java/com/aware/Installations.java
@@ -133,7 +133,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
if (Aware.isStudy(this)) {
ContentResolver.setIsSyncable(Aware.getAWAREAccount(this), Installations_Provider.getAuthority(this), 1);
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Installations_Provider.getAuthority(this), true);
- long frequency = Long.parseLong(Aware.getSetting(this, Aware_Preferences.FREQUENCY_WEBSERVICE)) * 60;
+ long frequency = Aware.getSettingAsLong(this, Aware_Preferences.FREQUENCY_WEBSERVICE, 30) * 60;
SyncRequest request = new SyncRequest.Builder()
.syncPeriodic(frequency, frequency / 3)
.setSyncAdapter(Aware.getAWAREAccount(this), Installations_Provider.getAuthority(this))
@@ -207,7 +207,7 @@ public void onReceive(Context context, Intent intent) {
ContentValues rowData = new ContentValues();
rowData.put(Installations_Data.TIMESTAMP, System.currentTimeMillis());
- rowData.put(Installations_Data.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ rowData.put(Installations_Data.DEVICE_ID, Aware.getDeviceID(context));
rowData.put(Installations_Data.PACKAGE_NAME, packageName);
rowData.put(Installations_Data.APPLICATION_NAME, appName);
rowData.put(Installations_Data.INSTALLATION_STATUS, STATUS_ADDED);
@@ -261,7 +261,7 @@ public void onReceive(Context context, Intent intent) {
ContentValues rowData = new ContentValues();
rowData.put(Installations_Data.TIMESTAMP, System.currentTimeMillis());
- rowData.put(Installations_Data.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ rowData.put(Installations_Data.DEVICE_ID, Aware.getDeviceID(context));
rowData.put(Installations_Data.PACKAGE_NAME, packageName);
rowData.put(Installations_Data.APPLICATION_NAME, appName);
rowData.put(Installations_Data.INSTALLATION_STATUS, STATUS_REMOVED);
@@ -307,7 +307,7 @@ public void onReceive(Context context, Intent intent) {
ContentValues rowData = new ContentValues();
rowData.put(Installations_Data.TIMESTAMP, System.currentTimeMillis());
- rowData.put(Installations_Data.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ rowData.put(Installations_Data.DEVICE_ID, Aware.getDeviceID(context));
rowData.put(Installations_Data.PACKAGE_NAME, packageName);
rowData.put(Installations_Data.APPLICATION_NAME, appName);
rowData.put(Installations_Data.INSTALLATION_STATUS, STATUS_UPDATED);
diff --git a/aware-core/src/main/java/com/aware/Keyboard.java b/aware-core/src/main/java/com/aware/Keyboard.java
index 48d8206a..9e4dcf13 100644
--- a/aware-core/src/main/java/com/aware/Keyboard.java
+++ b/aware-core/src/main/java/com/aware/Keyboard.java
@@ -55,7 +55,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
if (Aware.isStudy(this)) {
ContentResolver.setIsSyncable(Aware.getAWAREAccount(this), Keyboard_Provider.getAuthority(this), 1);
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Keyboard_Provider.getAuthority(this), true);
- long frequency = Long.parseLong(Aware.getSetting(this, Aware_Preferences.FREQUENCY_WEBSERVICE)) * 60;
+ long frequency = Aware.getSettingAsLong(this, Aware_Preferences.FREQUENCY_WEBSERVICE, 30) * 60;
SyncRequest request = new SyncRequest.Builder()
.syncPeriodic(frequency, frequency / 3)
.setSyncAdapter(Aware.getAWAREAccount(this), Keyboard_Provider.getAuthority(this))
diff --git a/aware-core/src/main/java/com/aware/Light.java b/aware-core/src/main/java/com/aware/Light.java
index 496d4264..d8576beb 100644
--- a/aware-core/src/main/java/com/aware/Light.java
+++ b/aware-core/src/main/java/com/aware/Light.java
@@ -1,12 +1,10 @@
package com.aware;
-import android.content.BroadcastReceiver;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
-import android.content.IntentFilter;
import android.content.SyncRequest;
import android.database.Cursor;
import android.database.SQLException;
@@ -26,6 +24,7 @@
import com.aware.providers.Light_Provider.Light_Data;
import com.aware.providers.Light_Provider.Light_Sensor;
import com.aware.utils.Aware_Sensor;
+import com.aware.utils.SensorTimeUnits;
import java.util.ArrayList;
import java.util.List;
@@ -66,8 +65,6 @@ public class Light extends Aware_Sensor implements SensorEventListener {
* ContentProvider: LightProvider
*/
public static final String ACTION_AWARE_LIGHT = "ACTION_AWARE_LIGHT";
- public static final String ACTION_AWARE_LIGHT_LABEL = "ACTION_AWARE_LIGHT_LABEL";
- public static final String EXTRA_LABEL = "label";
/**
* Until today, no available Android phone samples higher than 208Hz (Nexus 7).
@@ -75,19 +72,6 @@ public class Light extends Aware_Sensor implements SensorEventListener {
*/
private List data_values = new ArrayList();
- private static String LABEL = "";
-
- private static DataLabel dataLabeler = new DataLabel();
-
- public static class DataLabel extends BroadcastReceiver {
- @Override
- public void onReceive(Context context, Intent intent) {
- if (intent.getAction().equals(ACTION_AWARE_LIGHT_LABEL)) {
- LABEL = intent.getStringExtra(EXTRA_LABEL);
- }
- }
- }
-
@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {
//We log current accuracy on the sensor changed event
@@ -106,11 +90,10 @@ public void onSensorChanged(SensorEvent event) {
LAST_VALUE = event.values[0];
ContentValues rowData = new ContentValues();
- rowData.put(Light_Data.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(Light_Data.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(Light_Data.TIMESTAMP, TS);
rowData.put(Light_Data.LIGHT_LUX, event.values[0]);
rowData.put(Light_Data.ACCURACY, event.accuracy);
- rowData.put(Light_Data.LABEL, LABEL);
if (awareSensor != null) awareSensor.onLightChanged(rowData);
@@ -180,7 +163,7 @@ private void saveSensorDevice(Sensor sensor) {
Cursor sensorInfo = getContentResolver().query(Light_Sensor.CONTENT_URI, null, null, null, null);
if (sensorInfo == null || !sensorInfo.moveToFirst()) {
ContentValues rowData = new ContentValues();
- rowData.put(Light_Sensor.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(Light_Sensor.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(Light_Sensor.TIMESTAMP, System.currentTimeMillis());
rowData.put(Light_Sensor.MAXIMUM_RANGE, sensor.getMaximumRange());
rowData.put(Light_Sensor.MINIMUM_DELAY, sensor.getMinDelay());
@@ -216,11 +199,6 @@ public void onCreate() {
sensorHandler = new Handler(sensorThread.getLooper());
- IntentFilter filter = new IntentFilter();
- filter.addAction(ACTION_AWARE_LIGHT_LABEL);
- registerReceiver(dataLabeler, filter);
-
-
if (Aware.DEBUG) Log.d(TAG, "Light service created!");
}
@@ -234,8 +212,6 @@ public void onDestroy() {
wakeLock.release();
- unregisterReceiver(dataLabeler);
-
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Light_Provider.getAuthority(this), false);
ContentResolver.removePeriodicSync(
Aware.getAWAREAccount(this),
@@ -268,8 +244,8 @@ public int onStartCommand(Intent intent, int flags, int startId) {
Aware.setSetting(this, Aware_Preferences.THRESHOLD_LIGHT, 0.0);
}
- int new_frequency = Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_LIGHT));
- double new_threshold = Double.parseDouble(Aware.getSetting(getApplicationContext(), Aware_Preferences.THRESHOLD_LIGHT));
+ int new_frequency = Aware.getSettingAsInt(getApplicationContext(), Aware_Preferences.FREQUENCY_LIGHT, 200000);
+ double new_threshold = Aware.getSettingAsDouble(getApplicationContext(), Aware_Preferences.THRESHOLD_LIGHT, 0.0);
boolean new_enforce_frequency = (Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_LIGHT_ENFORCE).equals("true")
|| Aware.getSetting(getApplicationContext(), Aware_Preferences.ENFORCE_FREQUENCY_ALL).equals("true"));
@@ -285,12 +261,12 @@ public int onStartCommand(Intent intent, int flags, int startId) {
ENFORCE_FREQUENCY = new_enforce_frequency;
}
- mSensorManager.registerListener(this, mLight, Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_LIGHT)), sensorHandler);
+ mSensorManager.registerListener(this, mLight, SensorTimeUnits.samplingPeriodUs(new_frequency), sensorHandler);
if (Aware.isStudy(this)) {
ContentResolver.setIsSyncable(Aware.getAWAREAccount(this), Light_Provider.getAuthority(this), 1);
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Light_Provider.getAuthority(this), true);
- long frequency = Long.parseLong(Aware.getSetting(this, Aware_Preferences.FREQUENCY_WEBSERVICE)) * 60;
+ long frequency = Aware.getSettingAsLong(this, Aware_Preferences.FREQUENCY_WEBSERVICE, 30) * 60;
SyncRequest request = new SyncRequest.Builder()
.syncPeriodic(frequency, frequency / 3)
.setSyncAdapter(Aware.getAWAREAccount(this), Light_Provider.getAuthority(this))
@@ -309,4 +285,4 @@ public int onStartCommand(Intent intent, int flags, int startId) {
public IBinder onBind(Intent intent) {
return null;
}
-}
\ No newline at end of file
+}
diff --git a/aware-core/src/main/java/com/aware/LinearAccelerometer.java b/aware-core/src/main/java/com/aware/LinearAccelerometer.java
index dcec63a2..82f93077 100644
--- a/aware-core/src/main/java/com/aware/LinearAccelerometer.java
+++ b/aware-core/src/main/java/com/aware/LinearAccelerometer.java
@@ -1,12 +1,10 @@
package com.aware;
-import android.content.BroadcastReceiver;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
-import android.content.IntentFilter;
import android.content.SyncRequest;
import android.database.Cursor;
import android.database.SQLException;
@@ -26,6 +24,7 @@
import com.aware.providers.Linear_Accelerometer_Provider.Linear_Accelerometer_Data;
import com.aware.providers.Linear_Accelerometer_Provider.Linear_Accelerometer_Sensor;
import com.aware.utils.Aware_Sensor;
+import com.aware.utils.SensorTimeUnits;
import java.util.ArrayList;
import java.util.List;
@@ -61,8 +60,6 @@ public class LinearAccelerometer extends Aware_Sensor implements SensorEventList
* ContentProvider: LinearAccelerationProvider
*/
public static final String ACTION_AWARE_LINEAR_ACCELEROMETER = "ACTION_AWARE_LINEAR_ACCELEROMETER";
- public static final String ACTION_AWARE_LINEAR_LABEL = "ACTION_AWARE_LINEAR_LABEL";
- public static final String EXTRA_LABEL = "label";
/**
* Until today, no available Android phone samples higher than 208Hz (Nexus 7).
@@ -70,19 +67,6 @@ public class LinearAccelerometer extends Aware_Sensor implements SensorEventList
*/
private List data_values = new ArrayList();
- private static String LABEL = "";
-
- private static DataLabel dataLabeler = new DataLabel();
-
- public static class DataLabel extends BroadcastReceiver {
- @Override
- public void onReceive(Context context, Intent intent) {
- if (intent.getAction().equals(ACTION_AWARE_LINEAR_LABEL)) {
- LABEL = intent.getStringExtra(EXTRA_LABEL);
- }
- }
- }
-
@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {
//We log current accuracy on the sensor changed event
@@ -129,13 +113,12 @@ public void run() {
LAST_VALUES = new Float[]{event.values[0], event.values[1], event.values[2]};
ContentValues rowData = new ContentValues();
- rowData.put(Linear_Accelerometer_Data.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(Linear_Accelerometer_Data.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(Linear_Accelerometer_Data.TIMESTAMP, TS);
rowData.put(Linear_Accelerometer_Data.VALUES_0, event.values[0]);
rowData.put(Linear_Accelerometer_Data.VALUES_1, event.values[1]);
rowData.put(Linear_Accelerometer_Data.VALUES_2, event.values[2]);
rowData.put(Linear_Accelerometer_Data.ACCURACY, event.accuracy);
- rowData.put(Linear_Accelerometer_Data.LABEL, LABEL);
data_values.add(rowData);
LAST_TS = TS;
@@ -205,7 +188,7 @@ private void saveAccelerometerDevice(Sensor acc) {
Cursor accelInfo = getContentResolver().query(Linear_Accelerometer_Sensor.CONTENT_URI, null, null, null, null);
if (accelInfo == null || !accelInfo.moveToFirst()) {
ContentValues rowData = new ContentValues();
- rowData.put(Linear_Accelerometer_Sensor.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(Linear_Accelerometer_Sensor.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(Linear_Accelerometer_Sensor.TIMESTAMP, System.currentTimeMillis());
rowData.put(Linear_Accelerometer_Sensor.MAXIMUM_RANGE, acc.getMaximumRange());
rowData.put(Linear_Accelerometer_Sensor.MINIMUM_DELAY, acc.getMinDelay());
@@ -241,10 +224,6 @@ public void onCreate() {
sensorHandler = new Handler(sensorThread.getLooper());
- IntentFilter filter = new IntentFilter();
- filter.addAction(ACTION_AWARE_LINEAR_LABEL);
- registerReceiver(dataLabeler, filter);
-
if (Aware.DEBUG) Log.d(TAG, "Linear-accelerometer service created!");
}
@@ -258,8 +237,6 @@ public void onDestroy() {
wakeLock.release();
- unregisterReceiver(dataLabeler);
-
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Linear_Accelerometer_Provider.getAuthority(this), false);
ContentResolver.removePeriodicSync(
Aware.getAWAREAccount(this),
@@ -285,15 +262,15 @@ public int onStartCommand(Intent intent, int flags, int startId) {
Aware.setSetting(this, Aware_Preferences.STATUS_LINEAR_ACCELEROMETER, true);
if (Aware.getSetting(this, Aware_Preferences.FREQUENCY_LINEAR_ACCELEROMETER).length() == 0) {
- Aware.setSetting(this, Aware_Preferences.FREQUENCY_LINEAR_ACCELEROMETER, 200000);
+ Aware.setSetting(this, Aware_Preferences.FREQUENCY_LINEAR_ACCELEROMETER, 20000);
}
if (Aware.getSetting(this, Aware_Preferences.THRESHOLD_LINEAR_ACCELEROMETER).length() == 0) {
Aware.setSetting(this, Aware_Preferences.THRESHOLD_LINEAR_ACCELEROMETER, 0.0);
}
- int new_frequency = Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_LINEAR_ACCELEROMETER));
- double new_threshold = Double.parseDouble(Aware.getSetting(getApplicationContext(), Aware_Preferences.THRESHOLD_LINEAR_ACCELEROMETER));
+ int new_frequency = Aware.getSettingAsInt(getApplicationContext(), Aware_Preferences.FREQUENCY_LINEAR_ACCELEROMETER, 20000);
+ double new_threshold = Aware.getSettingAsDouble(getApplicationContext(), Aware_Preferences.THRESHOLD_LINEAR_ACCELEROMETER, 0.0);
boolean new_enforce_frequency = (Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_LINEAR_ACCELEROMETER_ENFORCE).equals("true")
|| Aware.getSetting(getApplicationContext(), Aware_Preferences.ENFORCE_FREQUENCY_ALL).equals("true"));
@@ -309,7 +286,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
ENFORCE_FREQUENCY = new_enforce_frequency;
}
- mSensorManager.registerListener(this, mLinearAccelerator, Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_LINEAR_ACCELEROMETER)), sensorHandler);
+ mSensorManager.registerListener(this, mLinearAccelerator, SensorTimeUnits.samplingPeriodUs(new_frequency), sensorHandler);
LAST_SAVE = System.currentTimeMillis();
if (Aware.DEBUG)
@@ -318,7 +295,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
if (Aware.isStudy(this)) {
ContentResolver.setIsSyncable(Aware.getAWAREAccount(this), Linear_Accelerometer_Provider.getAuthority(this), 1);
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Linear_Accelerometer_Provider.getAuthority(this), true);
- long frequency = Long.parseLong(Aware.getSetting(this, Aware_Preferences.FREQUENCY_WEBSERVICE)) * 60;
+ long frequency = Aware.getSettingAsLong(this, Aware_Preferences.FREQUENCY_WEBSERVICE, 30) * 60;
SyncRequest request = new SyncRequest.Builder()
.syncPeriodic(frequency, frequency / 3)
.setSyncAdapter(Aware.getAWAREAccount(this), Linear_Accelerometer_Provider.getAuthority(this))
@@ -335,4 +312,4 @@ public int onStartCommand(Intent intent, int flags, int startId) {
public IBinder onBind(Intent intent) {
return null;
}
-}
\ No newline at end of file
+}
diff --git a/aware-core/src/main/java/com/aware/Locations.java b/aware-core/src/main/java/com/aware/Locations.java
index 0faeebdb..a17107fa 100644
--- a/aware-core/src/main/java/com/aware/Locations.java
+++ b/aware-core/src/main/java/com/aware/Locations.java
@@ -18,6 +18,7 @@
import com.aware.providers.Locations_Provider;
import com.aware.providers.Locations_Provider.Locations_Data;
import com.aware.utils.Aware_Sensor;
+import com.aware.utils.SensorTimeUnits;
/**
* Location service for Aware framework
@@ -74,7 +75,7 @@ public void onGpsStatusChanged(int event) {
if (bestLocation != null) {
ContentValues rowData = new ContentValues();
rowData.put(Locations_Data.TIMESTAMP, System.currentTimeMillis());
- rowData.put(Locations_Data.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(Locations_Data.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(Locations_Data.PROVIDER, bestLocation.getProvider());
if (permitted) {
rowData.put(Locations_Data.LATITUDE, bestLocation.getLatitude());
@@ -149,32 +150,37 @@ public Boolean testGeoFence(Double lat0, Double lon0) {
// Test each part separately, if any part is true, return true.
for (Integer i = 0; i < fences.length; i++) {
String[] parts = fences[i].split(",");
- // Circular fences. Distance in METERS.
- if (parts.length == 3) {
- Double lat1 = Double.parseDouble(parts[0]);
- Double lon1 = Double.parseDouble(parts[1]);
- Double radius = Double.parseDouble(parts[2]);
- if (wgs84_dist(lat0, lon0, lat1, lon1) < radius) {
- if (Aware.DEBUG) Log.d(TAG, "Location geofence: within " + fences[i]);
- return true;
+ try {
+ // Circular fences. Distance in METERS.
+ if (parts.length == 3) {
+ Double lat1 = Double.parseDouble(parts[0]);
+ Double lon1 = Double.parseDouble(parts[1]);
+ Double radius = Double.parseDouble(parts[2]);
+ if (wgs84_dist(lat0, lon0, lat1, lon1) < radius) {
+ if (Aware.DEBUG) Log.d(TAG, "Location geofence: within " + fences[i]);
+ return true;
+ }
}
- }
- // Rectungular fence
- if (parts[0].equals("rect") && parts.length == 5) {
- Double lat1 = Double.parseDouble(parts[1]);
- Double lon1 = Double.parseDouble(parts[2]);
- Double lat2 = Double.parseDouble(parts[3]);
- Double lon2 = Double.parseDouble(parts[4]);
- // Be safe in case order of xxx1 and xxx2 are reversed,
- // so test twice. Is there a better way to do this?
- if (((lat1 < lat0 && lat0 < lat2)
- || (lat2 < lat0 && lat0 < lat1))
- && ((lon1 < lon0 && lon0 < lon2)
- || (lon2 < lon0 && lon0 < lon1))
- ) {
- if (Aware.DEBUG) Log.d(TAG, "Location geofence: within " + fences[i]);
- return true;
+ // Rectungular fence
+ if (parts[0].equals("rect") && parts.length == 5) {
+ Double lat1 = Double.parseDouble(parts[1]);
+ Double lon1 = Double.parseDouble(parts[2]);
+ Double lat2 = Double.parseDouble(parts[3]);
+ Double lon2 = Double.parseDouble(parts[4]);
+ // Be safe in case order of xxx1 and xxx2 are reversed,
+ // so test twice. Is there a better way to do this?
+ if (((lat1 < lat0 && lat0 < lat2)
+ || (lat2 < lat0 && lat0 < lat1))
+ && ((lon1 < lon0 && lon0 < lon2)
+ || (lon2 < lon0 && lon0 < lon1))
+ ) {
+ if (Aware.DEBUG) Log.d(TAG, "Location geofence: within " + fences[i]);
+ return true;
+ }
}
+ } catch (NumberFormatException e) {
+ // Malformed fence entry (bad study config) — skip it instead of crashing.
+ if (Aware.DEBUG) Log.d(TAG, "Location geofence: skipping malformed entry " + fences[i]);
}
}
if (Aware.DEBUG) Log.d(TAG, "Location geofence: not in any fences");
@@ -228,8 +234,9 @@ private boolean isBetterLocation(Location newLocation, Location lastLocation) {
if (newLocation == null) return false;
long timeDelta = newLocation.getTime() - lastLocation.getTime();
- boolean isSignificantlyNewer = timeDelta > 1000 * Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.LOCATION_EXPIRATION_TIME));
- boolean isSignificantlyOlder = timeDelta < -(1000 * Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.LOCATION_EXPIRATION_TIME)));
+ int locationExpirationTime = Aware.getSettingAsInt(getApplicationContext(), Aware_Preferences.LOCATION_EXPIRATION_TIME, 300);
+ boolean isSignificantlyNewer = timeDelta > 1000 * locationExpirationTime;
+ boolean isSignificantlyOlder = timeDelta < -(1000 * locationExpirationTime);
boolean isNewer = timeDelta > 0;
if (isSignificantlyNewer) {
@@ -285,6 +292,15 @@ public void onDestroy() {
if (PERMISSIONS_OK) locationManager.removeUpdates(this);
locationManager.removeGpsStatusListener(gps_status_listener);
+ // removeUpdates cancels all three providers, so these frequencies no longer describe a live
+ // registration. They are static and outlive the service, and onStartCommand only calls
+ // requestLocationUpdates when the configured frequency differs from them — so leaving them
+ // set makes a restarted service skip registration entirely and collect nothing for the rest
+ // of the process's life, while still logging itself as active.
+ FREQUENCY_GPS = -1;
+ FREQUENCY_NETWORK = -1;
+ FREQUENCY_PASSIVE = -1;
+
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Locations_Provider.getAuthority(this), false);
ContentResolver.removePeriodicSync(
Aware.getAWAREAccount(this),
@@ -311,7 +327,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
}
if (Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_LOCATION_NETWORK).length() == 0) {
- Aware.setSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_LOCATION_NETWORK, 300);
+ Aware.setSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_LOCATION_NETWORK, 180);
}
if (Aware.getSetting(getApplicationContext(), Aware_Preferences.MIN_LOCATION_NETWORK_ACCURACY).length() == 0) {
Aware.setSetting(getApplicationContext(), Aware_Preferences.MIN_LOCATION_NETWORK_ACCURACY, 1500);
@@ -322,22 +338,23 @@ public int onStartCommand(Intent intent, int flags, int startId) {
if (Aware.getSetting(getApplicationContext(), Aware_Preferences.STATUS_LOCATION_GPS).equals("true")) {
if (locationManager.getProvider(LocationManager.GPS_PROVIDER) != null) {
- if (FREQUENCY_GPS != Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_LOCATION_GPS))) {
+ int frequencyLocationGps = Aware.getSettingAsInt(getApplicationContext(), Aware_Preferences.FREQUENCY_LOCATION_GPS, 180);
+ if (FREQUENCY_GPS != frequencyLocationGps) {
locationManager.requestLocationUpdates(
LocationManager.GPS_PROVIDER,
- Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_LOCATION_GPS)) * 1000,
- Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.MIN_LOCATION_GPS_ACCURACY)), this);
+ SensorTimeUnits.secondsToMillis(frequencyLocationGps),
+ Aware.getSettingAsInt(getApplicationContext(), Aware_Preferences.MIN_LOCATION_GPS_ACCURACY, 150), this);
locationManager.removeGpsStatusListener(gps_status_listener);
locationManager.addGpsStatusListener(gps_status_listener);
- FREQUENCY_GPS = Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_LOCATION_GPS));
+ FREQUENCY_GPS = frequencyLocationGps;
}
if (Aware.DEBUG)
Log.d(TAG, "Location tracking with GPS is active: " + FREQUENCY_GPS + "s");
} else {
ContentValues rowData = new ContentValues();
rowData.put(Locations_Data.TIMESTAMP, System.currentTimeMillis());
- rowData.put(Locations_Data.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(Locations_Data.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(Locations_Data.PROVIDER, LocationManager.GPS_PROVIDER);
rowData.put(Locations_Data.LABEL, "disabled");
try {
@@ -352,20 +369,21 @@ public int onStartCommand(Intent intent, int flags, int startId) {
}
if (Aware.getSetting(getApplicationContext(), Aware_Preferences.STATUS_LOCATION_NETWORK).equals("true")) {
if (locationManager.getProvider(LocationManager.NETWORK_PROVIDER) != null) {
- if (FREQUENCY_NETWORK != Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_LOCATION_NETWORK))) {
+ int frequencyLocationNetwork = Aware.getSettingAsInt(getApplicationContext(), Aware_Preferences.FREQUENCY_LOCATION_NETWORK, 180);
+ if (FREQUENCY_NETWORK != frequencyLocationNetwork) {
locationManager.requestLocationUpdates(
LocationManager.NETWORK_PROVIDER,
- Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_LOCATION_NETWORK)) * 1000,
- Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.MIN_LOCATION_NETWORK_ACCURACY)), this);
+ SensorTimeUnits.secondsToMillis(frequencyLocationNetwork),
+ Aware.getSettingAsInt(getApplicationContext(), Aware_Preferences.MIN_LOCATION_NETWORK_ACCURACY, 1500), this);
- FREQUENCY_NETWORK = Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_LOCATION_NETWORK));
+ FREQUENCY_NETWORK = frequencyLocationNetwork;
}
if (Aware.DEBUG)
Log.d(TAG, "Location tracking with Network is active: " + FREQUENCY_NETWORK + "s");
} else {
ContentValues rowData = new ContentValues();
rowData.put(Locations_Data.TIMESTAMP, System.currentTimeMillis());
- rowData.put(Locations_Data.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(Locations_Data.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(Locations_Data.PROVIDER, LocationManager.NETWORK_PROVIDER);
rowData.put(Locations_Data.LABEL, "disabled");
try {
@@ -395,7 +413,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
} else {
ContentValues rowData = new ContentValues();
rowData.put(Locations_Data.TIMESTAMP, System.currentTimeMillis());
- rowData.put(Locations_Data.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(Locations_Data.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(Locations_Data.PROVIDER, LocationManager.PASSIVE_PROVIDER);
rowData.put(Locations_Data.LABEL, "disabled");
try {
@@ -413,7 +431,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
if (Aware.isStudy(this)) {
ContentResolver.setIsSyncable(Aware.getAWAREAccount(this), Locations_Provider.getAuthority(this), 1);
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Locations_Provider.getAuthority(this), true);
- long frequency = Long.parseLong(Aware.getSetting(this, Aware_Preferences.FREQUENCY_WEBSERVICE)) * 60;
+ long frequency = Aware.getSettingAsLong(this, Aware_Preferences.FREQUENCY_WEBSERVICE, 30) * 60;
SyncRequest request = new SyncRequest.Builder()
.syncPeriodic(frequency, frequency / 3)
.setSyncAdapter(Aware.getAWAREAccount(this), Locations_Provider.getAuthority(this))
@@ -478,7 +496,7 @@ public void onProviderDisabled(String provider) {
ContentValues rowData = new ContentValues();
rowData.put(Locations_Data.TIMESTAMP, System.currentTimeMillis());
- rowData.put(Locations_Data.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(Locations_Data.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(Locations_Data.PROVIDER, LocationManager.GPS_PROVIDER);
rowData.put(Locations_Data.LABEL, "disabled");
getContentResolver().insert(Locations_Data.CONTENT_URI, rowData);
@@ -490,7 +508,7 @@ public void onProviderDisabled(String provider) {
ContentValues rowData = new ContentValues();
rowData.put(Locations_Data.TIMESTAMP, System.currentTimeMillis());
- rowData.put(Locations_Data.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(Locations_Data.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(Locations_Data.PROVIDER, LocationManager.NETWORK_PROVIDER);
rowData.put(Locations_Data.LABEL, "disabled");
getContentResolver().insert(Locations_Data.CONTENT_URI, rowData);
@@ -508,7 +526,7 @@ public void onProviderEnabled(String provider) {
ContentValues rowData = new ContentValues();
rowData.put(Locations_Data.TIMESTAMP, System.currentTimeMillis());
- rowData.put(Locations_Data.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(Locations_Data.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(Locations_Data.PROVIDER, LocationManager.GPS_PROVIDER);
rowData.put(Locations_Data.LABEL, "enabled");
getContentResolver().insert(Locations_Data.CONTENT_URI, rowData);
@@ -520,7 +538,7 @@ public void onProviderEnabled(String provider) {
ContentValues rowData = new ContentValues();
rowData.put(Locations_Data.TIMESTAMP, System.currentTimeMillis());
- rowData.put(Locations_Data.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(Locations_Data.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(Locations_Data.PROVIDER, LocationManager.NETWORK_PROVIDER);
rowData.put(Locations_Data.LABEL, "enabled");
getContentResolver().insert(Locations_Data.CONTENT_URI, rowData);
@@ -592,7 +610,7 @@ public void saveLocation(Location bestLocation) {
ContentValues rowData = new ContentValues();
rowData.put(Locations_Data.TIMESTAMP, System.currentTimeMillis());
- rowData.put(Locations_Data.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(Locations_Data.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(Locations_Data.PROVIDER, bestLocation.getProvider());
if (permitted) {
rowData.put(Locations_Data.LATITUDE, bestLocation.getLatitude());
diff --git a/aware-core/src/main/java/com/aware/Magnetometer.java b/aware-core/src/main/java/com/aware/Magnetometer.java
index a9782f02..b30c560a 100644
--- a/aware-core/src/main/java/com/aware/Magnetometer.java
+++ b/aware-core/src/main/java/com/aware/Magnetometer.java
@@ -1,12 +1,10 @@
package com.aware;
-import android.content.BroadcastReceiver;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
-import android.content.IntentFilter;
import android.content.SyncRequest;
import android.database.Cursor;
import android.database.SQLException;
@@ -26,6 +24,7 @@
import com.aware.providers.Magnetometer_Provider.Magnetometer_Data;
import com.aware.providers.Magnetometer_Provider.Magnetometer_Sensor;
import com.aware.utils.Aware_Sensor;
+import com.aware.utils.SensorTimeUnits;
import java.util.ArrayList;
import java.util.List;
@@ -65,8 +64,6 @@ public class Magnetometer extends Aware_Sensor implements SensorEventListener {
* ContentProvider: MagnetometerProvider
*/
public static final String ACTION_AWARE_MAGNETOMETER = "ACTION_AWARE_MAGNETOMETER";
- public static final String ACTION_AWARE_MAGNETOMETER_LABEL = "ACTION_AWARE_MAGNETOMETER_LABEL";
- public static final String EXTRA_LABEL = "label";
/**
* Until today, no available Android phone samples higher than 208Hz (Nexus 7).
@@ -74,19 +71,6 @@ public class Magnetometer extends Aware_Sensor implements SensorEventListener {
*/
private List data_values = new ArrayList();
- private static String LABEL = "";
-
- private static DataLabel dataLabeler = new DataLabel();
-
- public static class DataLabel extends BroadcastReceiver {
- @Override
- public void onReceive(Context context, Intent intent) {
- if (intent.getAction().equals(ACTION_AWARE_MAGNETOMETER_LABEL)) {
- LABEL = intent.getStringExtra(EXTRA_LABEL);
- }
- }
- }
-
@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {
//We log current accuracy on the sensor changed event
@@ -99,21 +83,20 @@ public void onSensorChanged(SensorEvent event) {
return;
if (LAST_VALUES != null && THRESHOLD > 0 &&
Math.abs(event.values[0] - LAST_VALUES[0]) < THRESHOLD &&
- Math.abs(event.values[0] - LAST_VALUES[1]) < THRESHOLD &&
- Math.abs(event.values[0] - LAST_VALUES[2]) < THRESHOLD) {
+ Math.abs(event.values[1] - LAST_VALUES[1]) < THRESHOLD &&
+ Math.abs(event.values[2] - LAST_VALUES[2]) < THRESHOLD) {
return;
}
LAST_VALUES = new Float[]{event.values[0], event.values[1], event.values[2]};
ContentValues rowData = new ContentValues();
- rowData.put(Magnetometer_Data.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(Magnetometer_Data.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(Magnetometer_Data.TIMESTAMP, TS);
rowData.put(Magnetometer_Data.VALUES_0, event.values[0]);
rowData.put(Magnetometer_Data.VALUES_1, event.values[1]);
rowData.put(Magnetometer_Data.VALUES_2, event.values[2]);
rowData.put(Magnetometer_Data.ACCURACY, event.accuracy);
- rowData.put(Magnetometer_Data.LABEL, LABEL);
if (awareSensor != null) awareSensor.onMagnetometerChanged(rowData);
@@ -183,7 +166,7 @@ private void saveSensorDevice(Sensor sensor) {
Cursor sensorInfo = getContentResolver().query(Magnetometer_Sensor.CONTENT_URI, null, null, null, null);
if (sensorInfo == null || !sensorInfo.moveToFirst()) {
ContentValues rowData = new ContentValues();
- rowData.put(Magnetometer_Sensor.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(Magnetometer_Sensor.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(Magnetometer_Sensor.TIMESTAMP, System.currentTimeMillis());
rowData.put(Magnetometer_Sensor.MAXIMUM_RANGE, sensor.getMaximumRange());
rowData.put(Magnetometer_Sensor.MINIMUM_DELAY, sensor.getMinDelay());
@@ -219,10 +202,6 @@ public void onCreate() {
sensorHandler = new Handler(sensorThread.getLooper());
- IntentFilter filter = new IntentFilter();
- filter.addAction(ACTION_AWARE_MAGNETOMETER_LABEL);
- registerReceiver(dataLabeler, filter);
-
if (Aware.DEBUG) Log.d(TAG, "Magnetometer service created!");
}
@@ -236,8 +215,6 @@ public void onDestroy() {
wakeLock.release();
- unregisterReceiver(dataLabeler);
-
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Magnetometer_Provider.getAuthority(this), false);
ContentResolver.removePeriodicSync(
Aware.getAWAREAccount(this),
@@ -260,18 +237,28 @@ public int onStartCommand(Intent intent, int flags, int startId) {
} else {
DEBUG = Aware.getSetting(this, Aware_Preferences.DEBUG_FLAG).equals("true");
Aware.setSetting(this, Aware_Preferences.STATUS_MAGNETOMETER, true);
- saveSensorDevice(mMagnetometer);
+ // Opening this provider can include a one-time schema migration. A long-running
+ // study can accumulate a multi-gigabyte magnetometer table, so never make provider
+ // startup part of onStartCommand's main-thread work. The provider serializes its
+ // own queries/inserts, so sensor writes safely wait for the migration.
+ final Sensor magnetometer = mMagnetometer;
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ saveSensorDevice(magnetometer);
+ }
+ }, TAG + "::database").start();
if (Aware.getSetting(this, Aware_Preferences.FREQUENCY_MAGNETOMETER).length() == 0) {
- Aware.setSetting(this, Aware_Preferences.FREQUENCY_MAGNETOMETER, 200000);
+ Aware.setSetting(this, Aware_Preferences.FREQUENCY_MAGNETOMETER, 50000);
}
if (Aware.getSetting(this, Aware_Preferences.THRESHOLD_MAGNETOMETER).length() == 0) {
Aware.setSetting(this, Aware_Preferences.THRESHOLD_MAGNETOMETER, 0.0);
}
- int new_frequency = Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_MAGNETOMETER));
- double new_threshold = Double.parseDouble(Aware.getSetting(getApplicationContext(), Aware_Preferences.THRESHOLD_MAGNETOMETER));
+ int new_frequency = Aware.getSettingAsInt(getApplicationContext(), Aware_Preferences.FREQUENCY_MAGNETOMETER, 50000);
+ double new_threshold = Aware.getSettingAsDouble(getApplicationContext(), Aware_Preferences.THRESHOLD_MAGNETOMETER, 0.0);
boolean new_enforce_frequency = (Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_MAGNETOMETER_ENFORCE).equals("true")
|| Aware.getSetting(getApplicationContext(), Aware_Preferences.ENFORCE_FREQUENCY_ALL).equals("true"));
@@ -287,7 +274,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
ENFORCE_FREQUENCY = new_enforce_frequency;
}
- mSensorManager.registerListener(this, mMagnetometer, Integer.parseInt(Aware.getSetting(this, Aware_Preferences.FREQUENCY_MAGNETOMETER)), sensorHandler);
+ mSensorManager.registerListener(this, mMagnetometer, SensorTimeUnits.samplingPeriodUs(new_frequency), sensorHandler);
LAST_SAVE = System.currentTimeMillis();
if (Aware.DEBUG) Log.d(TAG, "Magnetometer service active...");
@@ -295,7 +282,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
if (Aware.isStudy(this)) {
ContentResolver.setIsSyncable(Aware.getAWAREAccount(this), Magnetometer_Provider.getAuthority(this), 1);
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Magnetometer_Provider.getAuthority(this), true);
- long frequency = Long.parseLong(Aware.getSetting(this, Aware_Preferences.FREQUENCY_WEBSERVICE)) * 60;
+ long frequency = Aware.getSettingAsLong(this, Aware_Preferences.FREQUENCY_WEBSERVICE, 30) * 60;
SyncRequest request = new SyncRequest.Builder()
.syncPeriodic(frequency, frequency / 3)
.setSyncAdapter(Aware.getAWAREAccount(this), Magnetometer_Provider.getAuthority(this))
@@ -312,4 +299,4 @@ public int onStartCommand(Intent intent, int flags, int startId) {
public IBinder onBind(Intent intent) {
return null;
}
-}
\ No newline at end of file
+}
diff --git a/aware-core/src/main/java/com/aware/Mqtt.java b/aware-core/src/main/java/com/aware/Mqtt.java
index 89812ad6..a1c10590 100644
--- a/aware-core/src/main/java/com/aware/Mqtt.java
+++ b/aware-core/src/main/java/com/aware/Mqtt.java
@@ -185,7 +185,7 @@ public interface AWARESensorObserver {
public void messageArrived(String topic, MqttMessage message) throws Exception {
ContentValues rowData = new ContentValues();
rowData.put(Mqtt_Messages.TIMESTAMP, System.currentTimeMillis());
- rowData.put(Mqtt_Messages.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(Mqtt_Messages.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(Mqtt_Messages.TOPIC, topic);
rowData.put(Mqtt_Messages.MESSAGE, message.toString());
rowData.put(Mqtt_Messages.STATUS, MQTT_MSG_RECEIVED);
@@ -218,23 +218,23 @@ public void messageArrived(String topic, MqttMessage message) throws Exception {
if (studyInfo != null && !studyInfo.isClosed()) studyInfo.close();
}
- if (topic.equalsIgnoreCase(Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID) + "/broadcasts") || topic.equalsIgnoreCase(study_id + "/" + Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID) + "/broadcasts")) {
+ if (topic.equalsIgnoreCase(Aware.getDeviceID(getApplicationContext()) + "/broadcasts") || topic.equalsIgnoreCase(study_id + "/" + Aware.getDeviceID(getApplicationContext()) + "/broadcasts")) {
Intent broadcast = new Intent(message.toString());
sendBroadcast(broadcast);
}
- if (topic.equalsIgnoreCase(Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID) + "/esm") || topic.equalsIgnoreCase(study_id + "/" + Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID) + "/esm")) {
+ if (topic.equalsIgnoreCase(Aware.getDeviceID(getApplicationContext()) + "/esm") || topic.equalsIgnoreCase(study_id + "/" + Aware.getDeviceID(getApplicationContext()) + "/esm")) {
Intent queueESM = new Intent(ESM.ACTION_AWARE_QUEUE_ESM);
queueESM.putExtra(ESM.EXTRA_ESM, message.toString());
sendBroadcast(queueESM);
}
- if (topic.equalsIgnoreCase(Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID) + "/configuration") || topic.equalsIgnoreCase(study_id + "/" + Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID) + "/configuration")) {
+ if (topic.equalsIgnoreCase(Aware.getDeviceID(getApplicationContext()) + "/configuration") || topic.equalsIgnoreCase(study_id + "/" + Aware.getDeviceID(getApplicationContext()) + "/configuration")) {
JSONArray configs = new JSONArray(message.toString());
Aware.tweakSettings(getApplicationContext(), configs);
}
- if (topic.equalsIgnoreCase(Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID) + "/schedulers") || topic.equalsIgnoreCase(study_id + "/" + Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID) + "/schedulers")) {
+ if (topic.equalsIgnoreCase(Aware.getDeviceID(getApplicationContext()) + "/schedulers") || topic.equalsIgnoreCase(study_id + "/" + Aware.getDeviceID(getApplicationContext()) + "/schedulers")) {
JSONArray schedules = new JSONArray(message.toString());
try {
Log.d(TAG, "Setting schedules: " + schedules.toString(5));
@@ -275,7 +275,7 @@ public void onReceive(Context context, Intent intent) {
if (publish(topic, message)) {
ContentValues rowData = new ContentValues();
rowData.put(Mqtt_Messages.TIMESTAMP, System.currentTimeMillis());
- rowData.put(Mqtt_Messages.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ rowData.put(Mqtt_Messages.DEVICE_ID, Aware.getDeviceID(context));
rowData.put(Mqtt_Messages.TOPIC, topic);
rowData.put(Mqtt_Messages.MESSAGE, message);
rowData.put(Mqtt_Messages.STATUS, MQTT_MSG_PUBLISHED);
@@ -300,7 +300,7 @@ public void onReceive(Context context, Intent intent) {
if (subscriptions == null || !subscriptions.moveToFirst()) {
ContentValues rowData = new ContentValues();
rowData.put(Mqtt_Subscriptions.TIMESTAMP, System.currentTimeMillis());
- rowData.put(Mqtt_Subscriptions.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ rowData.put(Mqtt_Subscriptions.DEVICE_ID, Aware.getDeviceID(context));
rowData.put(Mqtt_Subscriptions.TOPIC, topic);
try {
@@ -410,14 +410,20 @@ private void initializeMQTT() {
}
MQTT_SERVER = server;
- MQTT_PORT = Aware.getSetting(getApplicationContext(), Aware_Preferences.MQTT_PORT);
+ int mqttPort = Aware.getSettingAsInt(
+ getApplicationContext(), Aware_Preferences.MQTT_PORT, 8883);
+ if (mqttPort < 1 || mqttPort > 65535) mqttPort = 8883;
+ MQTT_PORT = Integer.toString(mqttPort);
MQTT_USERNAME = Aware.getSetting(getApplicationContext(), Aware_Preferences.MQTT_USERNAME);
MQTT_PASSWORD = Aware.getSetting(getApplicationContext(), Aware_Preferences.MQTT_PASSWORD);
- MQTT_KEEPALIVE = (Aware.getSetting(getApplicationContext(), Aware_Preferences.MQTT_KEEP_ALIVE).length() > 0 ? Aware.getSetting(getApplicationContext(), Aware_Preferences.MQTT_KEEP_ALIVE) : "600");
- MQTT_QoS = Aware.getSetting(getApplicationContext(), Aware_Preferences.MQTT_QOS);
+ int keepAlive = Math.max(10, Math.min(3600, Aware.getSettingAsInt(
+ getApplicationContext(), Aware_Preferences.MQTT_KEEP_ALIVE, 600)));
+ MQTT_KEEPALIVE = Integer.toString(keepAlive);
+ int qos = Math.max(0, Math.min(2, Aware.getSettingAsInt(
+ getApplicationContext(), Aware_Preferences.MQTT_QOS, 2)));
+ MQTT_QoS = Integer.toString(qos);
- if (Integer.parseInt(MQTT_PORT) == 1883) MQTT_PROTOCOL = "tcp";
- if (Integer.parseInt(MQTT_PORT) == 8883) MQTT_PROTOCOL = "ssl";
+ MQTT_PROTOCOL = mqttPort == 1883 ? "tcp" : "ssl";
String MQTT_URL = MQTT_PROTOCOL + "://" + MQTT_SERVER + ":" + MQTT_PORT;
@@ -426,8 +432,8 @@ private void initializeMQTT() {
MqttConnectOptions MQTT_OPTIONS = new MqttConnectOptions();
MQTT_OPTIONS.setCleanSession(false); //resume pending messages from server
- MQTT_OPTIONS.setConnectionTimeout(Integer.parseInt(MQTT_KEEPALIVE) + 10); //add 10 seconds to keep alive as options timeout
- MQTT_OPTIONS.setKeepAliveInterval(Integer.parseInt(MQTT_KEEPALIVE));
+ MQTT_OPTIONS.setConnectionTimeout(Math.min(60, keepAlive + 10));
+ MQTT_OPTIONS.setKeepAliveInterval(keepAlive);
MQTT_OPTIONS.setMqttVersion(MqttConnectOptions.MQTT_VERSION_3_1_1);
MQTT_OPTIONS.setAutomaticReconnect(true);
@@ -446,7 +452,7 @@ private void initializeMQTT() {
MQTT_CLIENT = new MqttClient(
MQTT_URL,
- String.valueOf(Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID).hashCode()),
+ String.valueOf(Aware.getDeviceID(getApplicationContext()).hashCode()),
MQTT_MESSAGES_PERSISTENCE);
MQTT_CLIENT.setCallback(this);
@@ -502,23 +508,23 @@ protected void onPostExecute(Boolean result) {
Cursor studyInfo = Aware.getStudy(getApplicationContext(), Aware.getSetting(getApplicationContext(), Aware_Preferences.WEBSERVICE_SERVER));
if (studyInfo != null && studyInfo.moveToFirst()) {
Intent studySubscribe = new Intent(ACTION_AWARE_MQTT_TOPIC_SUBSCRIBE);
- studySubscribe.putExtra(EXTRA_TOPIC, studyInfo.getInt(studyInfo.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_KEY)) + "/" + Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID) + "/broadcasts");
+ studySubscribe.putExtra(EXTRA_TOPIC, studyInfo.getInt(studyInfo.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_KEY)) + "/" + Aware.getDeviceID(getApplicationContext()) + "/broadcasts");
sendBroadcast(studySubscribe);
studySubscribe = new Intent(ACTION_AWARE_MQTT_TOPIC_SUBSCRIBE);
- studySubscribe.putExtra(EXTRA_TOPIC, studyInfo.getInt(studyInfo.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_KEY)) + "/" + Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID) + "/esm");
+ studySubscribe.putExtra(EXTRA_TOPIC, studyInfo.getInt(studyInfo.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_KEY)) + "/" + Aware.getDeviceID(getApplicationContext()) + "/esm");
sendBroadcast(studySubscribe);
studySubscribe = new Intent(ACTION_AWARE_MQTT_TOPIC_SUBSCRIBE);
- studySubscribe.putExtra(EXTRA_TOPIC, studyInfo.getInt(studyInfo.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_KEY)) + "/" + Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID) + "/configuration");
+ studySubscribe.putExtra(EXTRA_TOPIC, studyInfo.getInt(studyInfo.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_KEY)) + "/" + Aware.getDeviceID(getApplicationContext()) + "/configuration");
sendBroadcast(studySubscribe);
studySubscribe = new Intent(ACTION_AWARE_MQTT_TOPIC_SUBSCRIBE);
- studySubscribe.putExtra(EXTRA_TOPIC, studyInfo.getInt(studyInfo.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_KEY)) + "/" + Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID) + "/schedulers");
+ studySubscribe.putExtra(EXTRA_TOPIC, studyInfo.getInt(studyInfo.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_KEY)) + "/" + Aware.getDeviceID(getApplicationContext()) + "/schedulers");
sendBroadcast(studySubscribe);
studySubscribe = new Intent(ACTION_AWARE_MQTT_TOPIC_SUBSCRIBE);
- studySubscribe.putExtra(EXTRA_TOPIC, studyInfo.getInt(studyInfo.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_KEY)) + "/" + Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID) + "/#");
+ studySubscribe.putExtra(EXTRA_TOPIC, studyInfo.getInt(studyInfo.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_KEY)) + "/" + Aware.getDeviceID(getApplicationContext()) + "/#");
sendBroadcast(studySubscribe);
}
if (studyInfo != null && !studyInfo.isClosed()) studyInfo.close();
@@ -526,23 +532,23 @@ protected void onPostExecute(Boolean result) {
//Self-subscribes
Intent selfSubscribe = new Intent(ACTION_AWARE_MQTT_TOPIC_SUBSCRIBE);
- selfSubscribe.putExtra(EXTRA_TOPIC, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID) + "/broadcasts");
+ selfSubscribe.putExtra(EXTRA_TOPIC, Aware.getDeviceID(getApplicationContext()) + "/broadcasts");
sendBroadcast(selfSubscribe);
selfSubscribe = new Intent(ACTION_AWARE_MQTT_TOPIC_SUBSCRIBE);
- selfSubscribe.putExtra(EXTRA_TOPIC, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID) + "/esm");
+ selfSubscribe.putExtra(EXTRA_TOPIC, Aware.getDeviceID(getApplicationContext()) + "/esm");
sendBroadcast(selfSubscribe);
selfSubscribe = new Intent(ACTION_AWARE_MQTT_TOPIC_SUBSCRIBE);
- selfSubscribe.putExtra(EXTRA_TOPIC, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID) + "/configuration");
+ selfSubscribe.putExtra(EXTRA_TOPIC, Aware.getDeviceID(getApplicationContext()) + "/configuration");
sendBroadcast(selfSubscribe);
selfSubscribe = new Intent(ACTION_AWARE_MQTT_TOPIC_SUBSCRIBE);
- selfSubscribe.putExtra(EXTRA_TOPIC, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID) + "/schedulers");
+ selfSubscribe.putExtra(EXTRA_TOPIC, Aware.getDeviceID(getApplicationContext()) + "/schedulers");
sendBroadcast(selfSubscribe);
selfSubscribe = new Intent(ACTION_AWARE_MQTT_TOPIC_SUBSCRIBE);
- selfSubscribe.putExtra(EXTRA_TOPIC, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID) + "/#");
+ selfSubscribe.putExtra(EXTRA_TOPIC, Aware.getDeviceID(getApplicationContext()) + "/#");
sendBroadcast(selfSubscribe);
if (MQTT_CLIENT != null && MQTT_CLIENT.isConnected()) {
@@ -622,4 +628,4 @@ public static boolean unsubscribe(String topicName) {
}
return true;
}
-}
\ No newline at end of file
+}
diff --git a/aware-core/src/main/java/com/aware/Network.java b/aware-core/src/main/java/com/aware/Network.java
index 6e477056..04937508 100644
--- a/aware-core/src/main/java/com/aware/Network.java
+++ b/aware-core/src/main/java/com/aware/Network.java
@@ -202,7 +202,7 @@ public void onServiceStateChanged(android.telephony.ServiceState serviceState) {
if (serviceState.getState() == ServiceState.STATE_POWER_OFF) {
ContentValues mobile = new ContentValues();
mobile.put(Network_Data.TIMESTAMP, System.currentTimeMillis());
- mobile.put(Network_Data.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ mobile.put(Network_Data.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
mobile.put(Network_Data.TYPE, NETWORK_TYPE_MOBILE);
mobile.put(Network_Data.SUBTYPE, "MOBILE");
mobile.put(Network_Data.STATE, STATUS_OFF);
@@ -223,7 +223,7 @@ public void onServiceStateChanged(android.telephony.ServiceState serviceState) {
} else {
ContentValues mobile = new ContentValues();
mobile.put(Network_Data.TIMESTAMP, System.currentTimeMillis());
- mobile.put(Network_Data.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ mobile.put(Network_Data.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
mobile.put(Network_Data.TYPE, NETWORK_TYPE_MOBILE);
mobile.put(Network_Data.SUBTYPE, "MOBILE");
mobile.put(Network_Data.STATE, STATUS_ON);
@@ -261,7 +261,7 @@ public void onReceive(Context context, Intent intent) {
if (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
ContentValues started = new ContentValues();
started.put(Network_Data.TIMESTAMP, System.currentTimeMillis());
- started.put(Network_Data.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ started.put(Network_Data.DEVICE_ID, Aware.getDeviceID(context));
started.put(Network_Data.TYPE, NETWORK_TYPE_GPS);
started.put(Network_Data.SUBTYPE, "GPS");
started.put(Network_Data.STATE, STATUS_ON);
@@ -282,7 +282,7 @@ public void onReceive(Context context, Intent intent) {
} else {
ContentValues stopped = new ContentValues();
stopped.put(Network_Data.TIMESTAMP, System.currentTimeMillis());
- stopped.put(Network_Data.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ stopped.put(Network_Data.DEVICE_ID, Aware.getDeviceID(context));
stopped.put(Network_Data.TYPE, NETWORK_TYPE_GPS);
stopped.put(Network_Data.SUBTYPE, "GPS");
stopped.put(Network_Data.STATE, STATUS_OFF);
@@ -310,7 +310,7 @@ public void onReceive(Context context, Intent intent) {
if (is_airplane) {
ContentValues rowData = new ContentValues();
rowData.put(Network_Data.TIMESTAMP, System.currentTimeMillis());
- rowData.put(Network_Data.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ rowData.put(Network_Data.DEVICE_ID, Aware.getDeviceID(context));
rowData.put(Network_Data.TYPE, NETWORK_TYPE_AIRPLANE);
rowData.put(Network_Data.SUBTYPE, "AIRPLANE");
rowData.put(Network_Data.STATE, STATUS_ON);
@@ -331,7 +331,7 @@ public void onReceive(Context context, Intent intent) {
} else {
ContentValues rowData = new ContentValues();
rowData.put(Network_Data.TIMESTAMP, System.currentTimeMillis());
- rowData.put(Network_Data.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ rowData.put(Network_Data.DEVICE_ID, Aware.getDeviceID(context));
rowData.put(Network_Data.TYPE, NETWORK_TYPE_AIRPLANE);
rowData.put(Network_Data.SUBTYPE, "AIRPLANE");
rowData.put(Network_Data.STATE, STATUS_OFF);
@@ -359,7 +359,7 @@ public void onReceive(Context context, Intent intent) {
if (wifi_state == WifiManager.WIFI_STATE_ENABLED) {
ContentValues data = new ContentValues();
data.put(Network_Data.TIMESTAMP, System.currentTimeMillis());
- data.put(Network_Data.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ data.put(Network_Data.DEVICE_ID, Aware.getDeviceID(context));
data.put(Network_Data.TYPE, NETWORK_TYPE_WIFI);
data.put(Network_Data.SUBTYPE, "WIFI");
data.put(Network_Data.STATE, STATUS_ON);
@@ -380,7 +380,7 @@ public void onReceive(Context context, Intent intent) {
} else if (wifi_state == WifiManager.WIFI_STATE_DISABLED) {
ContentValues data = new ContentValues();
data.put(Network_Data.TIMESTAMP, System.currentTimeMillis());
- data.put(Network_Data.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ data.put(Network_Data.DEVICE_ID, Aware.getDeviceID(context));
data.put(Network_Data.TYPE, NETWORK_TYPE_WIFI);
data.put(Network_Data.SUBTYPE, "WIFI");
data.put(Network_Data.STATE, STATUS_OFF);
@@ -408,7 +408,7 @@ public void onReceive(Context context, Intent intent) {
if (bt_state == BluetoothAdapter.STATE_ON) {
ContentValues rowData = new ContentValues();
rowData.put(Network_Data.TIMESTAMP, System.currentTimeMillis());
- rowData.put(Network_Data.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ rowData.put(Network_Data.DEVICE_ID, Aware.getDeviceID(context));
rowData.put(Network_Data.TYPE, NETWORK_TYPE_BLUETOOTH);
rowData.put(Network_Data.SUBTYPE, "BLUETOOTH");
rowData.put(Network_Data.STATE, STATUS_ON);
@@ -429,7 +429,7 @@ public void onReceive(Context context, Intent intent) {
} else if (bt_state == BluetoothAdapter.STATE_OFF) {
ContentValues rowData = new ContentValues();
rowData.put(Network_Data.TIMESTAMP, System.currentTimeMillis());
- rowData.put(Network_Data.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ rowData.put(Network_Data.DEVICE_ID, Aware.getDeviceID(context));
rowData.put(Network_Data.TYPE, NETWORK_TYPE_BLUETOOTH);
rowData.put(Network_Data.SUBTYPE, "BLUETOOTH");
rowData.put(Network_Data.STATE, STATUS_OFF);
@@ -456,7 +456,7 @@ public void onReceive(Context context, Intent intent) {
if (wimax.getState() == NetworkInfo.State.CONNECTED) {
ContentValues data = new ContentValues();
data.put(Network_Data.TIMESTAMP, System.currentTimeMillis());
- data.put(Network_Data.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ data.put(Network_Data.DEVICE_ID, Aware.getDeviceID(context));
data.put(Network_Data.TYPE, NETWORK_TYPE_WIMAX);
data.put(Network_Data.SUBTYPE, "WIMAX");
data.put(Network_Data.STATE, STATUS_ON);
@@ -477,7 +477,7 @@ public void onReceive(Context context, Intent intent) {
} else if (wimax.getState() == NetworkInfo.State.DISCONNECTED) {
ContentValues data = new ContentValues();
data.put(Network_Data.TIMESTAMP, System.currentTimeMillis());
- data.put(Network_Data.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ data.put(Network_Data.DEVICE_ID, Aware.getDeviceID(context));
data.put(Network_Data.TYPE, NETWORK_TYPE_WIMAX);
data.put(Network_Data.SUBTYPE, "WIMAX");
data.put(Network_Data.STATE, STATUS_OFF);
@@ -592,7 +592,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
if (Aware.isStudy(this)) {
ContentResolver.setIsSyncable(Aware.getAWAREAccount(this), Network_Provider.getAuthority(this), 1);
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Network_Provider.getAuthority(this), true);
- long frequency = Long.parseLong(Aware.getSetting(this, Aware_Preferences.FREQUENCY_WEBSERVICE)) * 60;
+ long frequency = Aware.getSettingAsLong(this, Aware_Preferences.FREQUENCY_WEBSERVICE, 30) * 60;
SyncRequest request = new SyncRequest.Builder()
.syncPeriodic(frequency, frequency / 3)
.setSyncAdapter(Aware.getAWAREAccount(this), Network_Provider.getAuthority(this))
diff --git a/aware-core/src/main/java/com/aware/Notes.java b/aware-core/src/main/java/com/aware/Notes.java
index ccb71c0e..40533aaf 100644
--- a/aware-core/src/main/java/com/aware/Notes.java
+++ b/aware-core/src/main/java/com/aware/Notes.java
@@ -77,7 +77,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
Log.d(TAG, "sett up sync frequency");
ContentResolver.setIsSyncable(Aware.getAWAREAccount(this), Notes_Provider.getAuthority(this), 1);
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Notes_Provider.getAuthority(this), true);
- long frequency = Long.parseLong(Aware.getSetting(this, Aware_Preferences.FREQUENCY_WEBSERVICE)) * 60;
+ long frequency = Aware.getSettingAsLong(this, Aware_Preferences.FREQUENCY_WEBSERVICE, 30) * 60;
SyncRequest request = new SyncRequest.Builder()
.syncPeriodic(frequency, frequency / 3)
.setSyncAdapter(Aware.getAWAREAccount(this), Notes_Provider.getAuthority(this))
diff --git a/aware-core/src/main/java/com/aware/Processor.java b/aware-core/src/main/java/com/aware/Processor.java
index d81e016b..b4e7225b 100644
--- a/aware-core/src/main/java/com/aware/Processor.java
+++ b/aware-core/src/main/java/com/aware/Processor.java
@@ -13,11 +13,11 @@
import android.os.Handler;
import android.os.IBinder;
import android.util.Log;
-import android.widget.Toast;
import com.aware.providers.Processor_Provider;
import com.aware.providers.Processor_Provider.Processor_Data;
import com.aware.utils.Aware_Sensor;
+import com.aware.utils.SensorTimeUnits;
import java.io.BufferedReader;
import java.io.FileInputStream;
@@ -81,7 +81,7 @@ public void run() {
ContentValues rowData = new ContentValues();
rowData.put(Processor_Data.TIMESTAMP, System.currentTimeMillis());
- rowData.put(Processor_Data.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(Processor_Data.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(Processor_Data.LAST_USER, processorNow.get("user"));
rowData.put(Processor_Data.LAST_SYSTEM, processorNow.get("system"));
rowData.put(Processor_Data.LAST_IDLE, processorNow.get("idle"));
@@ -119,7 +119,7 @@ public void run() {
if (awareSensor != null) awareSensor.onIdle();
}
- mHandler.postDelayed(mRunnable, Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_PROCESSOR)) * 1000);
+ mHandler.postDelayed(mRunnable, SensorTimeUnits.secondsToMillis(Aware.getSettingAsInt(getApplicationContext(), Aware_Preferences.FREQUENCY_PROCESSOR, 10)));
}
};
@@ -174,32 +174,22 @@ public int onStartCommand(Intent intent, int flags, int startId) {
if (PERMISSIONS_OK) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
- Log.d(TAG, "Processor service is not allowed by Google, buuuu. Disabling sensor...");
-
- Toast.makeText(getApplicationContext(), "Google has disabled processor sensor: Android N (7+).", Toast.LENGTH_LONG).show();
+ Log.w(TAG, "Processor sensor is unavailable on Android N (7+) because /proc/stat is restricted; disabling it");
Aware.setSetting(getApplicationContext(), Aware_Preferences.STATUS_PROCESSOR, false);
Aware.stopProcessor(getApplicationContext());
stopSelf();
- return START_STICKY;
+ return START_NOT_STICKY;
}
DEBUG = Aware.getSetting(this, Aware_Preferences.DEBUG_FLAG).equals("true");
- if (Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_PROCESSOR).length() == 0) {
- Aware.setSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_PROCESSOR, 10);
- }
-
- try {
- Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_PROCESSOR));
- } catch (NumberFormatException e) {
- Aware.setSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_PROCESSOR, 10);
- }
Aware.setSetting(this, Aware_Preferences.STATUS_PROCESSOR, true);
- if (FREQUENCY != Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_PROCESSOR))) {
+ int frequencyProcessor = Aware.getSettingAsInt(getApplicationContext(), Aware_Preferences.FREQUENCY_PROCESSOR, 10);
+ if (FREQUENCY != frequencyProcessor) {
mHandler.removeCallbacks(mRunnable);
mHandler.post(mRunnable);
- FREQUENCY = Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_PROCESSOR));
+ FREQUENCY = frequencyProcessor;
}
if (Aware.DEBUG) Log.d(TAG, "Processor service active: " + FREQUENCY + "s");
@@ -207,7 +197,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
if (Aware.isStudy(this)) {
ContentResolver.setIsSyncable(Aware.getAWAREAccount(this), Processor_Provider.getAuthority(this), 1);
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Processor_Provider.getAuthority(this), true);
- long frequency = Long.parseLong(Aware.getSetting(this, Aware_Preferences.FREQUENCY_WEBSERVICE)) * 60;
+ long frequency = Aware.getSettingAsLong(this, Aware_Preferences.FREQUENCY_WEBSERVICE, 30) * 60;
SyncRequest request = new SyncRequest.Builder()
.syncPeriodic(frequency, frequency / 3)
.setSyncAdapter(Aware.getAWAREAccount(this), Processor_Provider.getAuthority(this))
diff --git a/aware-core/src/main/java/com/aware/Proximity.java b/aware-core/src/main/java/com/aware/Proximity.java
index 1ceedc25..9c631240 100644
--- a/aware-core/src/main/java/com/aware/Proximity.java
+++ b/aware-core/src/main/java/com/aware/Proximity.java
@@ -1,12 +1,10 @@
package com.aware;
-import android.content.BroadcastReceiver;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
-import android.content.IntentFilter;
import android.content.SyncRequest;
import android.database.Cursor;
import android.database.SQLException;
@@ -26,6 +24,7 @@
import com.aware.providers.Proximity_Provider.Proximity_Data;
import com.aware.providers.Proximity_Provider.Proximity_Sensor;
import com.aware.utils.Aware_Sensor;
+import com.aware.utils.SensorTimeUnits;
import java.util.ArrayList;
import java.util.List;
@@ -60,8 +59,6 @@ public class Proximity extends Aware_Sensor implements SensorEventListener {
* ContentProvider: ProximityProvider
*/
public static final String ACTION_AWARE_PROXIMITY = "ACTION_AWARE_PROXIMITY";
- public static final String ACTION_AWARE_PROXIMITY_LABEL = "ACTION_AWARE_PROXIMITY_LABEL";
- public static final String EXTRA_LABEL = "label";
/**
* Until today, no available Android phone samples higher than 208Hz (Nexus 7).
@@ -69,19 +66,6 @@ public class Proximity extends Aware_Sensor implements SensorEventListener {
*/
private List data_values = new ArrayList();
- private static String LABEL = "";
-
- private static DataLabel dataLabeler = new DataLabel();
-
- public static class DataLabel extends BroadcastReceiver {
- @Override
- public void onReceive(Context context, Intent intent) {
- if (intent.getAction().equals(ACTION_AWARE_PROXIMITY_LABEL)) {
- LABEL = intent.getStringExtra(EXTRA_LABEL);
- }
- }
- }
-
@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {
//We log current accuracy on the sensor changed event
@@ -99,11 +83,10 @@ public void onSensorChanged(SensorEvent event) {
LAST_VALUE = event.values[0];
ContentValues rowData = new ContentValues();
- rowData.put(Proximity_Data.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(Proximity_Data.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(Proximity_Data.TIMESTAMP, TS);
rowData.put(Proximity_Data.PROXIMITY, event.values[0]);
rowData.put(Proximity_Data.ACCURACY, event.accuracy);
- rowData.put(Proximity_Data.LABEL, LABEL);
if (awareSensor != null) awareSensor.onProximityChanged(rowData);
@@ -172,7 +155,7 @@ private void saveSensorDevice(Sensor sensor) {
Cursor sensorInfo = getContentResolver().query(Proximity_Sensor.CONTENT_URI, null, null, null, null);
if (sensorInfo == null || !sensorInfo.moveToFirst()) {
ContentValues rowData = new ContentValues();
- rowData.put(Proximity_Sensor.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(Proximity_Sensor.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(Proximity_Sensor.TIMESTAMP, System.currentTimeMillis());
rowData.put(Proximity_Sensor.MAXIMUM_RANGE, sensor.getMaximumRange());
rowData.put(Proximity_Sensor.MINIMUM_DELAY, sensor.getMinDelay());
@@ -210,10 +193,6 @@ public void onCreate() {
sensorHandler = new Handler(sensorThread.getLooper());
- IntentFilter filter = new IntentFilter();
- filter.addAction(ACTION_AWARE_PROXIMITY_LABEL);
- registerReceiver(dataLabeler, filter);
-
if (Aware.DEBUG) Log.d(TAG, "Proximity service created!");
}
@@ -227,8 +206,6 @@ public void onDestroy() {
wakeLock.release();
- unregisterReceiver(dataLabeler);
-
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Proximity_Provider.getAuthority(this), false);
ContentResolver.removePeriodicSync(
Aware.getAWAREAccount(this),
@@ -255,15 +232,15 @@ public int onStartCommand(Intent intent, int flags, int startId) {
saveSensorDevice(mProximity);
if (Aware.getSetting(this, Aware_Preferences.FREQUENCY_PROXIMITY).length() == 0) {
- Aware.setSetting(this, Aware_Preferences.FREQUENCY_PROXIMITY, 200000);
+ Aware.setSetting(this, Aware_Preferences.FREQUENCY_PROXIMITY, 1000000);
}
if (Aware.getSetting(this, Aware_Preferences.THRESHOLD_PROXIMITY).length() == 0) {
Aware.setSetting(this, Aware_Preferences.THRESHOLD_PROXIMITY, 0.0);
}
- int new_frequency = Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_PROXIMITY));
- double new_threshold = Double.parseDouble(Aware.getSetting(getApplicationContext(), Aware_Preferences.THRESHOLD_PROXIMITY));
+ int new_frequency = Aware.getSettingAsInt(getApplicationContext(), Aware_Preferences.FREQUENCY_PROXIMITY, 1000000);
+ double new_threshold = Aware.getSettingAsDouble(getApplicationContext(), Aware_Preferences.THRESHOLD_PROXIMITY, 0.0);
boolean new_enforce_frequency = (Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_PROXIMITY_ENFORCE).equals("true")
|| Aware.getSetting(getApplicationContext(), Aware_Preferences.ENFORCE_FREQUENCY_ALL).equals("true"));
@@ -279,7 +256,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
ENFORCE_FREQUENCY = new_enforce_frequency;
}
- mSensorManager.registerListener(this, mProximity, Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_PROXIMITY)), sensorHandler);
+ mSensorManager.registerListener(this, mProximity, SensorTimeUnits.samplingPeriodUs(new_frequency), sensorHandler);
LAST_SAVE = System.currentTimeMillis();
if (Aware.DEBUG) Log.d(TAG, "Proximity service active: " + FREQUENCY + "ms");
@@ -288,7 +265,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
if (Aware.isStudy(this)) {
ContentResolver.setIsSyncable(Aware.getAWAREAccount(this), Proximity_Provider.getAuthority(this), 1);
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Proximity_Provider.getAuthority(this), true);
- long frequency = Long.parseLong(Aware.getSetting(this, Aware_Preferences.FREQUENCY_WEBSERVICE)) * 60;
+ long frequency = Aware.getSettingAsLong(this, Aware_Preferences.FREQUENCY_WEBSERVICE, 30) * 60;
SyncRequest request = new SyncRequest.Builder()
.syncPeriodic(frequency, frequency / 3)
.setSyncAdapter(Aware.getAWAREAccount(this), Proximity_Provider.getAuthority(this))
@@ -304,4 +281,4 @@ public int onStartCommand(Intent intent, int flags, int startId) {
public IBinder onBind(Intent intent) {
return null;
}
-}
\ No newline at end of file
+}
diff --git a/aware-core/src/main/java/com/aware/Rotation.java b/aware-core/src/main/java/com/aware/Rotation.java
index 12959876..78ad027f 100644
--- a/aware-core/src/main/java/com/aware/Rotation.java
+++ b/aware-core/src/main/java/com/aware/Rotation.java
@@ -1,12 +1,10 @@
package com.aware;
-import android.content.BroadcastReceiver;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
-import android.content.IntentFilter;
import android.content.SyncRequest;
import android.database.Cursor;
import android.database.SQLException;
@@ -26,6 +24,7 @@
import com.aware.providers.Rotation_Provider.Rotation_Data;
import com.aware.providers.Rotation_Provider.Rotation_Sensor;
import com.aware.utils.Aware_Sensor;
+import com.aware.utils.SensorTimeUnits;
import java.util.ArrayList;
import java.util.List;
@@ -65,8 +64,6 @@ public class Rotation extends Aware_Sensor implements SensorEventListener {
* ContentProvider: RotationProvider
*/
public static final String ACTION_AWARE_ROTATION = "ACTION_AWARE_ROTATION";
- public static final String ACTION_AWARE_ROTATION_LABEL = "ACTION_AWARE_ROTATION_LABEL";
- public static final String EXTRA_LABEL = "label";
/**
* Until today, no available Android phone samples higher than 208Hz (Nexus 7).
@@ -74,19 +71,6 @@ public class Rotation extends Aware_Sensor implements SensorEventListener {
*/
private List data_values = new ArrayList<>();
- private static String LABEL = "";
-
- private static DataLabel dataLabeler = new DataLabel();
-
- public static class DataLabel extends BroadcastReceiver {
- @Override
- public void onReceive(Context context, Intent intent) {
- if (intent.getAction().equals(ACTION_AWARE_ROTATION_LABEL)) {
- LABEL = intent.getStringExtra(EXTRA_LABEL);
- }
- }
- }
-
@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {
//We log current accuracy on the sensor changed event
@@ -132,7 +116,7 @@ public void run() {
LAST_VALUES = new Float[]{event.values[0], event.values[1], event.values[2]};
ContentValues rowData = new ContentValues();
- rowData.put(Rotation_Data.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(Rotation_Data.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(Rotation_Data.TIMESTAMP, TS);
rowData.put(Rotation_Data.VALUES_0, event.values[0]);
rowData.put(Rotation_Data.VALUES_1, event.values[1]);
@@ -141,7 +125,6 @@ public void run() {
rowData.put(Rotation_Data.VALUES_3, event.values[3]);
}
rowData.put(Rotation_Data.ACCURACY, event.accuracy);
- rowData.put(Rotation_Data.LABEL, LABEL);
if (awareSensor != null) awareSensor.onRotationChanged(rowData);
@@ -212,7 +195,7 @@ private void saveSensorDevice(Sensor sensor) {
Cursor sensorInfo = getContentResolver().query(Rotation_Sensor.CONTENT_URI, null, null, null, null);
if (sensorInfo == null || !sensorInfo.moveToFirst()) {
ContentValues rowData = new ContentValues();
- rowData.put(Rotation_Sensor.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(Rotation_Sensor.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(Rotation_Sensor.TIMESTAMP, System.currentTimeMillis());
rowData.put(Rotation_Sensor.MAXIMUM_RANGE, sensor.getMaximumRange());
rowData.put(Rotation_Sensor.MINIMUM_DELAY, sensor.getMinDelay());
@@ -250,10 +233,6 @@ public void onCreate() {
sensorHandler = new Handler(sensorThread.getLooper());
- IntentFilter filter = new IntentFilter();
- filter.addAction(ACTION_AWARE_ROTATION_LABEL);
- registerReceiver(dataLabeler, filter);
-
if (Aware.DEBUG) Log.d(TAG, "Rotation service created!");
}
@@ -267,8 +246,6 @@ public void onDestroy() {
wakeLock.release();
- unregisterReceiver(dataLabeler);
-
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Rotation_Provider.getAuthority(this), false);
ContentResolver.removePeriodicSync(
Aware.getAWAREAccount(this),
@@ -294,14 +271,14 @@ public int onStartCommand(Intent intent, int flags, int startId) {
saveSensorDevice(mRotation);
if (Aware.getSetting(this, Aware_Preferences.FREQUENCY_ROTATION).length() == 0) {
- Aware.setSetting(this, Aware_Preferences.FREQUENCY_ROTATION, 200000);
+ Aware.setSetting(this, Aware_Preferences.FREQUENCY_ROTATION, 20000);
}
if (Aware.getSetting(this, Aware_Preferences.THRESHOLD_ROTATION).length() == 0) {
Aware.setSetting(this, Aware_Preferences.THRESHOLD_ROTATION, 0.0);
}
- int new_frequency = Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_ROTATION));
- double new_threshold = Double.parseDouble(Aware.getSetting(getApplicationContext(), Aware_Preferences.THRESHOLD_ROTATION));
+ int new_frequency = Aware.getSettingAsInt(getApplicationContext(), Aware_Preferences.FREQUENCY_ROTATION, 20000);
+ double new_threshold = Aware.getSettingAsDouble(getApplicationContext(), Aware_Preferences.THRESHOLD_ROTATION, 0.0);
boolean new_enforce_frequency = (Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_ROTATION_ENFORCE).equals("true")
|| Aware.getSetting(getApplicationContext(), Aware_Preferences.ENFORCE_FREQUENCY_ALL).equals("true"));
@@ -317,7 +294,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
ENFORCE_FREQUENCY = new_enforce_frequency;
}
- mSensorManager.registerListener(this, mRotation, Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_ROTATION)), sensorHandler);
+ mSensorManager.registerListener(this, mRotation, SensorTimeUnits.samplingPeriodUs(new_frequency), sensorHandler);
LAST_SAVE = System.currentTimeMillis();
if (Aware.DEBUG) Log.d(TAG, "Rotation service active...");
@@ -326,7 +303,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
if (Aware.isStudy(this)) {
ContentResolver.setIsSyncable(Aware.getAWAREAccount(this), Rotation_Provider.getAuthority(this), 1);
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Rotation_Provider.getAuthority(this), true);
- long frequency = Long.parseLong(Aware.getSetting(this, Aware_Preferences.FREQUENCY_WEBSERVICE)) * 60;
+ long frequency = Aware.getSettingAsLong(this, Aware_Preferences.FREQUENCY_WEBSERVICE, 30) * 60;
SyncRequest request = new SyncRequest.Builder()
.syncPeriodic(frequency, frequency / 3)
.setSyncAdapter(Aware.getAWAREAccount(this), Rotation_Provider.getAuthority(this))
@@ -342,4 +319,4 @@ public int onStartCommand(Intent intent, int flags, int startId) {
public IBinder onBind(Intent intent) {
return null;
}
-}
\ No newline at end of file
+}
diff --git a/aware-core/src/main/java/com/aware/Screen.java b/aware-core/src/main/java/com/aware/Screen.java
index 3eeb1765..06643467 100644
--- a/aware-core/src/main/java/com/aware/Screen.java
+++ b/aware-core/src/main/java/com/aware/Screen.java
@@ -147,7 +147,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
if (Aware.isStudy(this)) {
ContentResolver.setIsSyncable(Aware.getAWAREAccount(this), Screen_Provider.getAuthority(this), 1);
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Screen_Provider.getAuthority(this), true);
- long frequency = Long.parseLong(Aware.getSetting(this, Aware_Preferences.FREQUENCY_WEBSERVICE)) * 60;
+ long frequency = Aware.getSettingAsLong(this, Aware_Preferences.FREQUENCY_WEBSERVICE, 30) * 60;
SyncRequest request = new SyncRequest.Builder()
.syncPeriodic(frequency, frequency / 3)
.setSyncAdapter(Aware.getAWAREAccount(this), Screen_Provider.getAuthority(this))
@@ -166,7 +166,7 @@ public void onReceive(Context context, Intent intent) {
if (intent.getAction().equalsIgnoreCase(Intent.ACTION_SCREEN_ON)) {
ContentValues rowData = new ContentValues();
rowData.put(Screen_Data.TIMESTAMP, System.currentTimeMillis());
- rowData.put(Screen_Data.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ rowData.put(Screen_Data.DEVICE_ID, Aware.getDeviceID(context));
rowData.put(Screen_Data.SCREEN_STATUS, Screen.STATUS_SCREEN_ON);
try {
context.getContentResolver().insert(Screen_Data.CONTENT_URI, rowData);
@@ -187,7 +187,7 @@ public void onReceive(Context context, Intent intent) {
ContentValues rowData = new ContentValues();
rowData.put(Screen_Data.TIMESTAMP, System.currentTimeMillis());
- rowData.put(Screen_Data.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ rowData.put(Screen_Data.DEVICE_ID, Aware.getDeviceID(context));
rowData.put(Screen_Data.SCREEN_STATUS, Screen.STATUS_SCREEN_OFF);
try {
context.getContentResolver().insert(Screen_Data.CONTENT_URI, rowData);
@@ -209,7 +209,7 @@ public void onReceive(Context context, Intent intent) {
if (km.inKeyguardRestrictedInputMode()) {
rowData = new ContentValues();
rowData.put(Screen_Data.TIMESTAMP, System.currentTimeMillis());
- rowData.put(Screen_Data.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ rowData.put(Screen_Data.DEVICE_ID, Aware.getDeviceID(context));
rowData.put(Screen_Data.SCREEN_STATUS, Screen.STATUS_SCREEN_LOCKED);
try {
context.getContentResolver().insert(Screen_Data.CONTENT_URI, rowData);
@@ -230,7 +230,7 @@ public void onReceive(Context context, Intent intent) {
if (intent.getAction().equals(Intent.ACTION_USER_PRESENT)) {
ContentValues rowData = new ContentValues();
rowData.put(Screen_Data.TIMESTAMP, System.currentTimeMillis());
- rowData.put(Screen_Data.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ rowData.put(Screen_Data.DEVICE_ID, Aware.getDeviceID(context));
rowData.put(Screen_Data.SCREEN_STATUS, Screen.STATUS_SCREEN_UNLOCKED);
try {
context.getContentResolver().insert(Screen_Data.CONTENT_URI, rowData);
diff --git a/aware-core/src/main/java/com/aware/ScreenShot.java b/aware-core/src/main/java/com/aware/ScreenShot.java
index 327c1d95..11ba0f31 100644
--- a/aware-core/src/main/java/com/aware/ScreenShot.java
+++ b/aware-core/src/main/java/com/aware/ScreenShot.java
@@ -33,15 +33,14 @@
import com.aware.providers.ScreenShot_Provider;
import com.aware.providers.ScreenText_Provider;
import com.aware.utils.Aware_Sensor;
+import com.aware.utils.UtcTime;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
-import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.List;
-import java.util.Locale;
public class ScreenShot extends Aware_Sensor {
public static final String CAPTURE_TIME_INTERVAL = "capture_time_interval";
@@ -78,6 +77,7 @@ public class ScreenShot extends Aware_Sensor {
private String foregroundApp;
private String application_name;
private final Object imageReaderLock = new Object();
+ private boolean resourcesCleaned = true;
private final BroadcastReceiver screenStateReceiver = new BroadcastReceiver() {
@Override
@@ -128,6 +128,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
stopSelf();
return START_NOT_STICKY;
}
+ if (!PERMISSIONS_OK) return START_NOT_STICKY;
if (PERMISSIONS_OK) {
DEBUG = Aware.getSetting(this, Aware_Preferences.DEBUG_FLAG).equals("true");
@@ -137,7 +138,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
if (Aware.isStudy(this)) {
ContentResolver.setIsSyncable(Aware.getAWAREAccount(this), ScreenShot_Provider.getAuthority(this), 1);
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), ScreenShot_Provider.getAuthority(this), true);
- long frequency = Long.parseLong(Aware.getSetting(this, Aware_Preferences.FREQUENCY_WEBSERVICE)) * 60;
+ long frequency = Aware.getSettingAsLong(this, Aware_Preferences.FREQUENCY_WEBSERVICE, 30) * 60;
SyncRequest request = new SyncRequest.Builder()
.syncPeriodic(frequency, frequency / 3)
.setSyncAdapter(Aware.getAWAREAccount(this), ScreenShot_Provider.getAuthority(this))
@@ -146,22 +147,33 @@ public int onStartCommand(Intent intent, int flags, int startId) {
}
}
- int resultCode = intent.getIntExtra(MEDIA_PROJECTION_RESULT_CODE, Activity.RESULT_CANCELED);
- Intent data = intent.getParcelableExtra(MEDIA_PROJECTION_RESULT_DATA);
+ int resultCode = intent == null
+ ? Activity.RESULT_CANCELED
+ : intent.getIntExtra(MEDIA_PROJECTION_RESULT_CODE, Activity.RESULT_CANCELED);
+ Intent data = intent == null ? null : intent.getParcelableExtra(MEDIA_PROJECTION_RESULT_DATA);
if (resultCode != Activity.RESULT_CANCELED && data != null) {
mediaProjectionResultCode = resultCode;
mediaProjectionResultData = data;
}
- capture_delay = intent.getIntExtra(CAPTURE_TIME_INTERVAL, capture_delay);
- compressionRate = intent.getIntExtra(COMPRESS_RATE, compressionRate);
- saveToLocalStorage = intent.getBooleanExtra(STATUS_SCREENSHOT_LOCAL_STORAGE, saveToLocalStorage);
+ if (intent != null) {
+ capture_delay = Math.max(1000, intent.getIntExtra(CAPTURE_TIME_INTERVAL, capture_delay));
+ compressionRate = Math.max(0, Math.min(100,
+ intent.getIntExtra(COMPRESS_RATE, compressionRate)));
+ saveToLocalStorage = intent.getBooleanExtra(
+ STATUS_SCREENSHOT_LOCAL_STORAGE, saveToLocalStorage);
+ }
if (mediaProjectionResultCode != 0 && mediaProjectionResultData != null) {
- startForegroundService(mediaProjectionResultCode, mediaProjectionResultData);
+ // Repeated starts are normal during keep-alive and config reconciliation. Reusing the
+ // active projection prevents a new HandlerThread/Runnable chain on every start.
+ if (mediaProjection == null || virtualDisplay == null) {
+ startForegroundService(mediaProjectionResultCode, mediaProjectionResultData);
+ }
} else {
stopSelf();
+ return START_NOT_STICKY;
}
return START_STICKY;
@@ -206,6 +218,7 @@ private void registerScreenStateReceiver() {
* @param data The intent data from the media projection permission request.
*/
private void startForegroundService(int resultCode, Intent data) {
+ resourcesCleaned = false;
Intent stopSelf = new Intent(this, ScreenShot.class);
stopSelf.setAction(ACTION_STOP_CAPTURE);
PendingIntent pStopSelf = PendingIntent.getService(this, 0, stopSelf, PendingIntent.FLAG_CANCEL_CURRENT);
@@ -284,6 +297,7 @@ public void run() {
retryCount++;
if (retryCount > MAX_RETRY_COUNT) {
sendRetryExceededBroadcast();
+ stopSelf();
return;
}
int retryDelay = Math.min(capture_delay, retryCount * 100);
@@ -380,7 +394,7 @@ private void saveBitmap(Bitmap bitmap, long timestamp) {
}
- String formattedTimestamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(timestamp);
+ String formattedTimestamp = UtcTime.fileStamp(timestamp);
File path = new File(downloadsDirectory, "screenshot_" + formattedTimestamp + ".jpg");
try (FileOutputStream fos = new FileOutputStream(path)) {
bitmap.compress(Bitmap.CompressFormat.JPEG, compressionRate, fos); // Use the selected compression rate
@@ -416,7 +430,7 @@ private byte[] convertBitmapToByteArray(Bitmap bitmap) {
private void storeScreenshotMetadata(byte[] imageData, long timestamp) {
ContentValues values = new ContentValues();
values.put(ScreenShot_Provider.ScreenshotData.TIMESTAMP, timestamp);
- values.put(ScreenShot_Provider.ScreenshotData.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ values.put(ScreenShot_Provider.ScreenshotData.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
values.put(ScreenShot_Provider.ScreenshotData.IMAGE_DATA, imageData);
values.put(ScreenShot_Provider.ScreenshotData.PACKAGE_NAME, foregroundApp);
values.put(ScreenShot_Provider.ScreenshotData.APPLICATION_NAME, application_name);
@@ -478,19 +492,22 @@ private void startCapturing() {
* Cleans up resources used by the screen capturing process.
*/
private void cleanupResources() {
+ if (resourcesCleaned) return;
+ resourcesCleaned = true;
Log.d(TAG, "Cleaning up resources");
stopCapturing();
if (handlerThread != null) {
handlerThread.quitSafely();
- }
- if (imageReader != null) {
- imageReader.close();
+ handlerThread = null;
}
if (virtualDisplay != null) {
virtualDisplay.release();
+ virtualDisplay = null;
}
- if (mediaProjection != null) {
- mediaProjection.stop();
+ MediaProjection projection = mediaProjection;
+ mediaProjection = null;
+ if (projection != null) {
+ projection.stop();
}
synchronized (imageReaderLock) {
@@ -499,6 +516,7 @@ private void cleanupResources() {
imageReader = null;
}
}
+ handler = null;
// Broadcast that the service has stopped
Intent intent = new Intent(ACTION_SCREENSHOT_SERVICE_STOPPED);
diff --git a/aware-core/src/main/java/com/aware/ScreenText.java b/aware-core/src/main/java/com/aware/ScreenText.java
index ed445c9d..81f193fa 100644
--- a/aware-core/src/main/java/com/aware/ScreenText.java
+++ b/aware-core/src/main/java/com/aware/ScreenText.java
@@ -96,7 +96,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
if (Aware.isStudy(this)) {
ContentResolver.setIsSyncable(Aware.getAWAREAccount(this), ScreenText_Provider.getAuthority(this), 1);
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), ScreenText_Provider.getAuthority(this), true);
- long frequency = Long.parseLong(Aware.getSetting(this, Aware_Preferences.FREQUENCY_WEBSERVICE)) * 60;
+ long frequency = Aware.getSettingAsLong(this, Aware_Preferences.FREQUENCY_WEBSERVICE, 30) * 60;
SyncRequest request = new SyncRequest.Builder()
.syncPeriodic(frequency, frequency / 3)
.setSyncAdapter(Aware.getAWAREAccount(this), ScreenText_Provider.getAuthority(this))
diff --git a/aware-core/src/main/java/com/aware/SignificantMotion.java b/aware-core/src/main/java/com/aware/SignificantMotion.java
index 20db4165..d923e3da 100644
--- a/aware-core/src/main/java/com/aware/SignificantMotion.java
+++ b/aware-core/src/main/java/com/aware/SignificantMotion.java
@@ -22,7 +22,7 @@
/**
* Created by denzil on 10/01/2017.
- *
+ *
* This sensor is used to track device significant motion.
* Also used internally by AWARE if available to save battery when the device is still with high-frequency sensors
* Based of:
@@ -90,7 +90,7 @@ public void onCreate() {
@Override
public void onContext() {
ContentValues rowData = new ContentValues();
- rowData.put(Significant_Provider.Significant_Data.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(Significant_Provider.Significant_Data.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(Significant_Provider.Significant_Data.TIMESTAMP, System.currentTimeMillis());
rowData.put(Significant_Provider.Significant_Data.IS_MOVING, CURRENT_SIGMOTION_STATE);
getContentResolver().insert(Significant_Provider.Significant_Data.CONTENT_URI, rowData);
@@ -138,7 +138,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
if (Aware.isStudy(this)) {
ContentResolver.setIsSyncable(Aware.getAWAREAccount(this), Significant_Provider.getAuthority(this), 1);
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Significant_Provider.getAuthority(this), true);
- long frequency = Long.parseLong(Aware.getSetting(this, Aware_Preferences.FREQUENCY_WEBSERVICE)) * 60;
+ long frequency = Aware.getSettingAsLong(this, Aware_Preferences.FREQUENCY_WEBSERVICE, 30) * 60;
SyncRequest request = new SyncRequest.Builder()
.syncPeriodic(frequency, frequency / 3)
.setSyncAdapter(Aware.getAWAREAccount(this), Significant_Provider.getAuthority(this))
diff --git a/aware-core/src/main/java/com/aware/Telephony.java b/aware-core/src/main/java/com/aware/Telephony.java
index 522e7dd6..5107807b 100644
--- a/aware-core/src/main/java/com/aware/Telephony.java
+++ b/aware-core/src/main/java/com/aware/Telephony.java
@@ -131,7 +131,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
if (Aware.isStudy(this)) {
ContentResolver.setIsSyncable(Aware.getAWAREAccount(this), Telephony_Provider.getAuthority(this), 1);
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Telephony_Provider.getAuthority(this), true);
- long frequency = Long.parseLong(Aware.getSetting(this, Aware_Preferences.FREQUENCY_WEBSERVICE)) * 60;
+ long frequency = Aware.getSettingAsLong(this, Aware_Preferences.FREQUENCY_WEBSERVICE, 30) * 60;
SyncRequest request = new SyncRequest.Builder()
.syncPeriodic(frequency, frequency / 3)
.setSyncAdapter(Aware.getAWAREAccount(this), Telephony_Provider.getAuthority(this))
@@ -186,7 +186,7 @@ public void onCellLocationChanged(CellLocation location) {
if (lastSignalStrength == null) return;
- String device_id = Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID);
+ String device_id = Aware.getDeviceID(getApplicationContext());
if (location instanceof GsmCellLocation) {
GsmCellLocation loc = (GsmCellLocation) location;
diff --git a/aware-core/src/main/java/com/aware/Temperature.java b/aware-core/src/main/java/com/aware/Temperature.java
index 5f2d2cb4..70d465f9 100644
--- a/aware-core/src/main/java/com/aware/Temperature.java
+++ b/aware-core/src/main/java/com/aware/Temperature.java
@@ -1,12 +1,10 @@
package com.aware;
-import android.content.BroadcastReceiver;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
-import android.content.IntentFilter;
import android.content.SyncRequest;
import android.database.Cursor;
import android.database.SQLException;
@@ -26,6 +24,7 @@
import com.aware.providers.Temperature_Provider.Temperature_Data;
import com.aware.providers.Temperature_Provider.Temperature_Sensor;
import com.aware.utils.Aware_Sensor;
+import com.aware.utils.SensorTimeUnits;
import java.util.ArrayList;
import java.util.List;
@@ -65,8 +64,6 @@ public class Temperature extends Aware_Sensor implements SensorEventListener {
* ContentProvider: Temperature_Provider
*/
public static final String ACTION_AWARE_TEMPERATURE = "ACTION_AWARE_TEMPERATURE";
- public static final String ACTION_AWARE_TEMPERATURE_LABEL = "ACTION_AWARE_TEMPERATURE_LABEL";
- public static final String EXTRA_LABEL = "label";
/**
* Until today, no available Android phone samples higher than 208Hz (Nexus 7).
@@ -74,19 +71,6 @@ public class Temperature extends Aware_Sensor implements SensorEventListener {
*/
private List data_values = new ArrayList();
- private static String LABEL = "";
-
- private static DataLabel dataLabeler = new DataLabel();
-
- public static class DataLabel extends BroadcastReceiver {
- @Override
- public void onReceive(Context context, Intent intent) {
- if (intent.getAction().equals(ACTION_AWARE_TEMPERATURE_LABEL)) {
- LABEL = intent.getStringExtra(EXTRA_LABEL);
- }
- }
- }
-
@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {
//We log current accuracy on the sensor changed event
@@ -104,11 +88,10 @@ public void onSensorChanged(SensorEvent event) {
LAST_VALUE = event.values[0];
ContentValues rowData = new ContentValues();
- rowData.put(Temperature_Data.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(Temperature_Data.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(Temperature_Data.TIMESTAMP, TS);
rowData.put(Temperature_Data.TEMPERATURE_CELSIUS, event.values[0]);
rowData.put(Temperature_Data.ACCURACY, event.accuracy);
- rowData.put(Temperature_Data.LABEL, LABEL);
if (awareSensor != null) awareSensor.onTemperatureChanged(rowData);
@@ -178,7 +161,7 @@ private void saveSensorDevice(Sensor sensor) {
Cursor sensorInfo = getContentResolver().query(Temperature_Sensor.CONTENT_URI, null, null, null, null);
if (sensorInfo == null || !sensorInfo.moveToFirst()) {
ContentValues rowData = new ContentValues();
- rowData.put(Temperature_Sensor.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(Temperature_Sensor.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(Temperature_Sensor.TIMESTAMP, System.currentTimeMillis());
rowData.put(Temperature_Sensor.MAXIMUM_RANGE, sensor.getMaximumRange());
rowData.put(Temperature_Sensor.MINIMUM_DELAY, sensor.getMinDelay());
@@ -213,10 +196,6 @@ public void onCreate() {
sensorHandler = new Handler(sensorThread.getLooper());
- IntentFilter filter = new IntentFilter();
- filter.addAction(ACTION_AWARE_TEMPERATURE_LABEL);
- registerReceiver(dataLabeler, filter);
-
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.HONEYCOMB) {
mTemperature = mSensorManager.getDefaultSensor(Sensor.TYPE_TEMPERATURE);
} else {
@@ -236,8 +215,6 @@ public void onDestroy() {
wakeLock.release();
- unregisterReceiver(dataLabeler);
-
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Temperature_Provider.getAuthority(this), false);
ContentResolver.removePeriodicSync(
Aware.getAWAREAccount(this),
@@ -264,15 +241,15 @@ public int onStartCommand(Intent intent, int flags, int startId) {
saveSensorDevice(mTemperature);
if (Aware.getSetting(this, Aware_Preferences.FREQUENCY_TEMPERATURE).length() == 0) {
- Aware.setSetting(this, Aware_Preferences.FREQUENCY_TEMPERATURE, 200000);
+ Aware.setSetting(this, Aware_Preferences.FREQUENCY_TEMPERATURE, 10000000);
}
if (Aware.getSetting(this, Aware_Preferences.THRESHOLD_TEMPERATURE).length() == 0) {
Aware.setSetting(this, Aware_Preferences.THRESHOLD_TEMPERATURE, 0.0);
}
- int new_frequency = Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_TEMPERATURE));
- double new_threshold = Double.parseDouble(Aware.getSetting(getApplicationContext(), Aware_Preferences.THRESHOLD_TEMPERATURE));
+ int new_frequency = Aware.getSettingAsInt(getApplicationContext(), Aware_Preferences.FREQUENCY_TEMPERATURE, 10000000);
+ double new_threshold = Aware.getSettingAsDouble(getApplicationContext(), Aware_Preferences.THRESHOLD_TEMPERATURE, 0.0);
boolean new_enforce_frequency = (Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_TEMPERATURE_ENFORCE).equals("true")
|| Aware.getSetting(getApplicationContext(), Aware_Preferences.ENFORCE_FREQUENCY_ALL).equals("true"));
@@ -288,7 +265,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
ENFORCE_FREQUENCY = new_enforce_frequency;
}
- mSensorManager.registerListener(this, mTemperature, Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_TEMPERATURE)), sensorHandler);
+ mSensorManager.registerListener(this, mTemperature, SensorTimeUnits.samplingPeriodUs(new_frequency), sensorHandler);
LAST_SAVE = System.currentTimeMillis();
if (Aware.DEBUG) Log.d(TAG, "Temperature service active...");
@@ -297,7 +274,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
if (Aware.isStudy(this)) {
ContentResolver.setIsSyncable(Aware.getAWAREAccount(this), Temperature_Provider.getAuthority(this), 1);
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Temperature_Provider.getAuthority(this), true);
- long frequency = Long.parseLong(Aware.getSetting(this, Aware_Preferences.FREQUENCY_WEBSERVICE)) * 60;
+ long frequency = Aware.getSettingAsLong(this, Aware_Preferences.FREQUENCY_WEBSERVICE, 30) * 60;
SyncRequest request = new SyncRequest.Builder()
.syncPeriodic(frequency, frequency / 3)
.setSyncAdapter(Aware.getAWAREAccount(this), Temperature_Provider.getAuthority(this))
@@ -313,4 +290,4 @@ public int onStartCommand(Intent intent, int flags, int startId) {
public IBinder onBind(Intent intent) {
return null;
}
-}
\ No newline at end of file
+}
diff --git a/aware-core/src/main/java/com/aware/Timezone.java b/aware-core/src/main/java/com/aware/Timezone.java
index a283d8a5..b9db744a 100644
--- a/aware-core/src/main/java/com/aware/Timezone.java
+++ b/aware-core/src/main/java/com/aware/Timezone.java
@@ -26,7 +26,7 @@
*
* @author Denzil
* Made sensor event-based, instead of polling data.
- *
+ *
* Original @author Nikola
*/
public class Timezone extends Aware_Sensor {
@@ -99,7 +99,7 @@ private void retrieveTimezone() {
lastTimezone = TimeZone.getDefault().getID();
ContentValues rowData = new ContentValues();
rowData.put(TimeZone_Data.TIMESTAMP, System.currentTimeMillis());
- rowData.put(TimeZone_Data.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(TimeZone_Data.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(TimeZone_Data.TIMEZONE, lastTimezone);
try {
@@ -137,7 +137,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
if (Aware.isStudy(this)) {
ContentResolver.setIsSyncable(Aware.getAWAREAccount(this), TimeZone_Provider.getAuthority(this), 1);
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), TimeZone_Provider.getAuthority(this), true);
- long frequency = Long.parseLong(Aware.getSetting(this, Aware_Preferences.FREQUENCY_WEBSERVICE)) * 60;
+ long frequency = Aware.getSettingAsLong(this, Aware_Preferences.FREQUENCY_WEBSERVICE, 30) * 60;
SyncRequest request = new SyncRequest.Builder()
.syncPeriodic(frequency, frequency / 3)
.setSyncAdapter(Aware.getAWAREAccount(this), TimeZone_Provider.getAuthority(this))
diff --git a/aware-core/src/main/java/com/aware/Traffic.java b/aware-core/src/main/java/com/aware/Traffic.java
index 92accba0..0307d9e8 100644
--- a/aware-core/src/main/java/com/aware/Traffic.java
+++ b/aware-core/src/main/java/com/aware/Traffic.java
@@ -9,13 +9,12 @@
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
-import android.telephony.PhoneStateListener;
-import android.telephony.TelephonyManager;
import android.util.Log;
import com.aware.providers.Traffic_Provider;
import com.aware.providers.Traffic_Provider.Traffic_Data;
import com.aware.utils.Aware_Sensor;
+import com.aware.utils.SensorTimeUnits;
/**
* Service that logs I/O traffic from WiFi & mobile network
@@ -37,26 +36,37 @@ public class Traffic extends Aware_Sensor {
public static final int NETWORK_TYPE_MOBILE = 1;
public static final int NETWORK_TYPE_WIFI = 2;
- private TelephonyManager telephonyManager;
-
- private static Handler mHandler = new Handler();
+ private final Handler mHandler = new Handler();
private final Runnable mRunnable = new Runnable() {
@Override
public void run() {
-
- long d_mobileRxBytes = TrafficStats.getMobileRxBytes() - mobileRxBytes;
- long d_mobileRxPackets = TrafficStats.getMobileRxPackets() - mobileRxPackets;
- long d_mobileTxBytes = TrafficStats.getMobileTxBytes() - mobileTxBytes;
- long d_mobileTxPackets = TrafficStats.getMobileTxPackets() - mobileTxPackets;
-
- long d_wifiRxBytes = (TrafficStats.getTotalRxBytes() - TrafficStats.getMobileRxBytes()) - wifiRxBytes;
- long d_wifiRxPackets = (TrafficStats.getTotalRxPackets() - TrafficStats.getMobileRxPackets()) - wifiRxPackets;
- long d_wifiTxBytes = (TrafficStats.getTotalTxBytes() - TrafficStats.getMobileTxBytes()) - wifiTxBytes;
- long d_wifiTxPackets = (TrafficStats.getTotalTxPackets() - TrafficStats.getMobileTxPackets()) - wifiTxPackets;
+ long currentMobileRxBytes = supportedCounter(TrafficStats.getMobileRxBytes());
+ long currentMobileRxPackets = supportedCounter(TrafficStats.getMobileRxPackets());
+ long currentMobileTxBytes = supportedCounter(TrafficStats.getMobileTxBytes());
+ long currentMobileTxPackets = supportedCounter(TrafficStats.getMobileTxPackets());
+
+ long currentWifiRxBytes = nonMobileCounter(
+ TrafficStats.getTotalRxBytes(), TrafficStats.getMobileRxBytes());
+ long currentWifiRxPackets = nonMobileCounter(
+ TrafficStats.getTotalRxPackets(), TrafficStats.getMobileRxPackets());
+ long currentWifiTxBytes = nonMobileCounter(
+ TrafficStats.getTotalTxBytes(), TrafficStats.getMobileTxBytes());
+ long currentWifiTxPackets = nonMobileCounter(
+ TrafficStats.getTotalTxPackets(), TrafficStats.getMobileTxPackets());
+
+ long d_mobileRxBytes = counterDelta(currentMobileRxBytes, mobileRxBytes);
+ long d_mobileRxPackets = counterDelta(currentMobileRxPackets, mobileRxPackets);
+ long d_mobileTxBytes = counterDelta(currentMobileTxBytes, mobileTxBytes);
+ long d_mobileTxPackets = counterDelta(currentMobileTxPackets, mobileTxPackets);
+
+ long d_wifiRxBytes = counterDelta(currentWifiRxBytes, wifiRxBytes);
+ long d_wifiRxPackets = counterDelta(currentWifiRxPackets, wifiRxPackets);
+ long d_wifiTxBytes = counterDelta(currentWifiTxBytes, wifiTxBytes);
+ long d_wifiTxPackets = counterDelta(currentWifiTxPackets, wifiTxPackets);
ContentValues wifi = new ContentValues();
wifi.put(Traffic_Data.TIMESTAMP, System.currentTimeMillis());
- wifi.put(Traffic_Data.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ wifi.put(Traffic_Data.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
wifi.put(Traffic_Data.NETWORK_TYPE, NETWORK_TYPE_WIFI);
wifi.put(Traffic_Data.RECEIVED_BYTES, d_wifiRxBytes);
wifi.put(Traffic_Data.SENT_BYTES, d_wifiTxBytes);
@@ -70,7 +80,7 @@ public void run() {
ContentValues network = new ContentValues();
network.put(Traffic_Data.TIMESTAMP, System.currentTimeMillis());
- network.put(Traffic_Data.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ network.put(Traffic_Data.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
network.put(Traffic_Data.NETWORK_TYPE, NETWORK_TYPE_MOBILE);
network.put(Traffic_Data.RECEIVED_BYTES, d_mobileRxBytes);
network.put(Traffic_Data.SENT_BYTES, d_mobileTxBytes);
@@ -84,26 +94,27 @@ public void run() {
Intent traffic = new Intent(ACTION_AWARE_NETWORK_TRAFFIC);
sendBroadcast(traffic);
- //refresh old values
- //mobile
- mobileRxBytes = TrafficStats.getMobileRxBytes();
- mobileRxPackets = TrafficStats.getMobileRxPackets();
- mobileTxBytes = TrafficStats.getMobileTxBytes();
- mobileTxPackets = TrafficStats.getMobileTxPackets();
- //wifi
- wifiRxBytes = TrafficStats.getTotalRxBytes() - mobileRxBytes;
- wifiTxBytes = TrafficStats.getTotalTxBytes() - mobileTxBytes;
- wifiRxPackets = TrafficStats.getTotalRxPackets() - mobileRxPackets;
- wifiTxPackets = TrafficStats.getTotalTxPackets() - mobileTxPackets;
+ if (awareSensor != null
+ && d_mobileRxBytes == 0 && d_mobileRxPackets == 0
+ && d_mobileTxBytes == 0 && d_mobileTxPackets == 0
+ && d_wifiRxBytes == 0 && d_wifiRxPackets == 0
+ && d_wifiTxBytes == 0 && d_wifiTxPackets == 0) {
+ awareSensor.onIdleTraffic();
+ }
+
+ mobileRxBytes = currentMobileRxBytes;
+ mobileRxPackets = currentMobileRxPackets;
+ mobileTxBytes = currentMobileTxBytes;
+ mobileTxPackets = currentMobileTxPackets;
+ wifiRxBytes = currentWifiRxBytes;
+ wifiTxBytes = currentWifiTxBytes;
+ wifiRxPackets = currentWifiRxPackets;
+ wifiTxPackets = currentWifiTxPackets;
+
+ mHandler.postDelayed(this, getSamplingIntervalMillis());
}
};
- //All stats
- private long startTotalRxBytes = 0;
- private long startTotalRxPackets = 0;
- private long startTotalTxBytes = 0;
- private long startTotalTxPackets = 0;
-
//Mobile stats
private long mobileRxBytes = 0;
private long mobileTxBytes = 0;
@@ -144,11 +155,7 @@ public void onCreate() {
super.onCreate();
AUTHORITY = Traffic_Provider.getAuthority(this);
-
- startTotalRxBytes = TrafficStats.getTotalRxBytes();
- startTotalTxBytes = TrafficStats.getTotalTxBytes();
- startTotalRxPackets = TrafficStats.getTotalRxPackets();
- startTotalTxPackets = TrafficStats.getTotalTxPackets();
+ resetTrafficBaselines();
if (Aware.DEBUG) Log.d(TAG, "Traffic service created!");
}
@@ -159,7 +166,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
if (PERMISSIONS_OK) {
- if (startTotalRxBytes == TrafficStats.UNSUPPORTED) {
+ if (TrafficStats.getTotalRxBytes() == TrafficStats.UNSUPPORTED) {
Aware.setSetting(getApplicationContext(), Aware_Preferences.STATUS_NETWORK_TRAFFIC, false);
if (Aware.DEBUG)
Log.d(TAG, "Device doesn't support traffic statistics! Disabling sensor...");
@@ -170,27 +177,19 @@ public int onStartCommand(Intent intent, int flags, int startId) {
DEBUG = Aware.getSetting(this, Aware_Preferences.DEBUG_FLAG).equals("true");
Aware.setSetting(this, Aware_Preferences.STATUS_NETWORK_TRAFFIC, true);
- if (telephonyManager == null)
- telephonyManager = (TelephonyManager) getSystemService(TELEPHONY_SERVICE);
-
- telephonyManager.listen(networkTrafficObserver, PhoneStateListener.LISTEN_DATA_ACTIVITY);
-
- if (mobileRxBytes == 0) mobileRxBytes = TrafficStats.getMobileRxBytes();
- if (mobileTxBytes == 0) mobileTxBytes = TrafficStats.getMobileTxBytes();
- if (mobileRxPackets == 0) mobileRxPackets = TrafficStats.getMobileRxPackets();
- if (mobileTxPackets == 0) mobileTxPackets = TrafficStats.getMobileTxPackets();
-
- if (wifiRxBytes == 0) wifiRxBytes = startTotalRxBytes - mobileRxBytes;
- if (wifiTxBytes == 0) wifiTxBytes = startTotalTxBytes - mobileTxBytes;
- if (wifiRxPackets == 0) wifiRxPackets = startTotalRxPackets - mobileRxPackets;
- if (wifiTxPackets == 0) wifiTxPackets = startTotalTxPackets - mobileTxPackets;
+ // PhoneStateListener data-activity callbacks are permission-dependent and may
+ // never fire for Wi-Fi-only traffic. Sample the system counters periodically
+ // instead, using the study's configured traffic frequency (seconds).
+ mHandler.removeCallbacks(mRunnable);
+ resetTrafficBaselines();
+ mHandler.postDelayed(mRunnable, getSamplingIntervalMillis());
if (Aware.DEBUG) Log.d(TAG, "Traffic service active...");
if (Aware.isStudy(this)) {
ContentResolver.setIsSyncable(Aware.getAWAREAccount(this), Traffic_Provider.getAuthority(this), 1);
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Traffic_Provider.getAuthority(this), true);
- long frequency = Long.parseLong(Aware.getSetting(this, Aware_Preferences.FREQUENCY_WEBSERVICE)) * 60;
+ long frequency = Aware.getSettingAsLong(this, Aware_Preferences.FREQUENCY_WEBSERVICE, 30) * 60;
SyncRequest request = new SyncRequest.Builder()
.syncPeriodic(frequency, frequency / 3)
.setSyncAdapter(Aware.getAWAREAccount(this), Traffic_Provider.getAuthority(this))
@@ -203,43 +202,48 @@ public int onStartCommand(Intent intent, int flags, int startId) {
return START_STICKY;
}
- private NetworkTrafficObserver networkTrafficObserver = new NetworkTrafficObserver();
+ private long getSamplingIntervalMillis() {
+ int frequencySeconds = Math.max(1, Aware.getSettingAsInt(
+ getApplicationContext(),
+ Aware_Preferences.FREQUENCY_NETWORK_TRAFFIC,
+ 30));
+ return SensorTimeUnits.secondsToMillis(frequencySeconds);
+ }
- public class NetworkTrafficObserver extends PhoneStateListener {
- @Override
- public void onDataActivity(int direction) {
- super.onDataActivity(direction);
-
- switch (direction) {
- case TelephonyManager.DATA_ACTIVITY_IN:
- //update stats
- mHandler.post(mRunnable);
- break;
- case TelephonyManager.DATA_ACTIVITY_OUT:
- //update stats
- mHandler.post(mRunnable);
- break;
- case TelephonyManager.DATA_ACTIVITY_INOUT:
- //update stats
- mHandler.post(mRunnable);
- break;
- case TelephonyManager.DATA_ACTIVITY_NONE:
- //no-op.
- if (awareSensor != null) awareSensor.onIdleTraffic();
- break;
- }
- }
+ private void resetTrafficBaselines() {
+ mobileRxBytes = supportedCounter(TrafficStats.getMobileRxBytes());
+ mobileRxPackets = supportedCounter(TrafficStats.getMobileRxPackets());
+ mobileTxBytes = supportedCounter(TrafficStats.getMobileTxBytes());
+ mobileTxPackets = supportedCounter(TrafficStats.getMobileTxPackets());
+ wifiRxBytes = nonMobileCounter(
+ TrafficStats.getTotalRxBytes(), TrafficStats.getMobileRxBytes());
+ wifiRxPackets = nonMobileCounter(
+ TrafficStats.getTotalRxPackets(), TrafficStats.getMobileRxPackets());
+ wifiTxBytes = nonMobileCounter(
+ TrafficStats.getTotalTxBytes(), TrafficStats.getMobileTxBytes());
+ wifiTxPackets = nonMobileCounter(
+ TrafficStats.getTotalTxPackets(), TrafficStats.getMobileTxPackets());
+ }
+
+ static long supportedCounter(long counter) {
+ return counter == TrafficStats.UNSUPPORTED ? 0 : Math.max(0, counter);
+ }
+
+ static long nonMobileCounter(long total, long mobile) {
+ long supportedTotal = supportedCounter(total);
+ if (mobile == TrafficStats.UNSUPPORTED) return supportedTotal;
+ return Math.max(0, supportedTotal - supportedCounter(mobile));
+ }
+
+ static long counterDelta(long current, long previous) {
+ return current >= previous ? current - previous : 0;
}
@Override
public void onDestroy() {
super.onDestroy();
- try {
- telephonyManager.listen(networkTrafficObserver, PhoneStateListener.LISTEN_NONE);
- } catch (NullPointerException e) {
- e.printStackTrace();
- }
+ mHandler.removeCallbacks(mRunnable);
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Traffic_Provider.getAuthority(this), false);
ContentResolver.removePeriodicSync(
diff --git a/aware-core/src/main/java/com/aware/WiFi.java b/aware-core/src/main/java/com/aware/WiFi.java
index bd9a9305..849fdfae 100644
--- a/aware-core/src/main/java/com/aware/WiFi.java
+++ b/aware-core/src/main/java/com/aware/WiFi.java
@@ -26,6 +26,7 @@
import com.aware.providers.WiFi_Provider.WiFi_Sensor;
import com.aware.utils.Aware_Sensor;
import com.aware.utils.Encrypter;
+import com.aware.utils.SensorTimeUnits;
import java.util.List;
import java.util.concurrent.Callable;
@@ -133,7 +134,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
}
alarmManager.cancel(wifiScan);
- alarmManager.setRepeating(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + 1000, Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_WIFI)) * 1000, wifiScan);
+ alarmManager.setRepeating(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + 1000, SensorTimeUnits.secondsToMillis(Aware.getSettingAsInt(getApplicationContext(), Aware_Preferences.FREQUENCY_WIFI, 60)), wifiScan);
if (Aware.DEBUG) Log.d(TAG, "WiFi service active...");
}
@@ -141,7 +142,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
if (Aware.isStudy(this)) {
ContentResolver.setIsSyncable(Aware.getAWAREAccount(this), WiFi_Provider.getAuthority(this), 1);
ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), WiFi_Provider.getAuthority(this), true);
- long frequency = Long.parseLong(Aware.getSetting(this, Aware_Preferences.FREQUENCY_WEBSERVICE)) * 60;
+ long frequency = Aware.getSettingAsLong(this, Aware_Preferences.FREQUENCY_WEBSERVICE, 30) * 60;
SyncRequest request = new SyncRequest.Builder()
.syncPeriodic(frequency, frequency / 3)
.setSyncAdapter(Aware.getAWAREAccount(this), WiFi_Provider.getAuthority(this))
@@ -203,7 +204,7 @@ private static class WifiInfoFetch implements Callable {
@Override
public String call() throws Exception {
ContentValues rowData = new ContentValues();
- rowData.put(WiFi_Sensor.DEVICE_ID, Aware.getSetting(mContext, Aware_Preferences.DEVICE_ID));
+ rowData.put(WiFi_Sensor.DEVICE_ID, Aware.getDeviceID(mContext));
rowData.put(WiFi_Sensor.TIMESTAMP, System.currentTimeMillis());
rowData.put(WiFi_Sensor.MAC_ADDRESS, Encrypter.hashMac(mContext, mWifi.getMacAddress()));
rowData.put(WiFi_Sensor.BSSID, Encrypter.hashMac(mContext, mWifi.getBSSID()));
@@ -247,7 +248,7 @@ public String call() throws Exception {
for (ScanResult ap : mAPS) {
ContentValues rowData = new ContentValues();
- rowData.put(WiFi_Data.DEVICE_ID, Aware.getSetting(mContext, Aware_Preferences.DEVICE_ID));
+ rowData.put(WiFi_Data.DEVICE_ID, Aware.getDeviceID(mContext));
rowData.put(WiFi_Data.TIMESTAMP, currentScan);
rowData.put(WiFi_Data.BSSID, Encrypter.hashMac(mContext, ap.BSSID));
rowData.put(WiFi_Data.SSID, Encrypter.hashSsid(mContext, ap.SSID));
@@ -320,7 +321,7 @@ protected void onHandleIntent(Intent intent) {
}
ContentValues rowData = new ContentValues();
- rowData.put(WiFi_Data.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(WiFi_Data.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(WiFi_Data.TIMESTAMP, System.currentTimeMillis());
rowData.put(WiFi_Data.LABEL, "disabled");
@@ -328,13 +329,17 @@ protected void onHandleIntent(Intent intent) {
if (awareSensor != null) awareSensor.onWiFiDisabled();
}
- } catch (NullPointerException e) {
+ } catch (NullPointerException | SecurityException e) {
+ // SecurityException: wifiManager.startScan() throws this when the phone's
+ // OS-level Location service is off — required system-wide for WiFi scan
+ // results regardless of any app permission. Previously uncaught here, this
+ // crashed the service every time Location was disabled.
if (Aware.DEBUG) {
- Log.d(WiFi.TAG, "WiFi is off");
+ Log.d(WiFi.TAG, "WiFi scan failed: " + e.getMessage());
}
ContentValues rowData = new ContentValues();
- rowData.put(WiFi_Data.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ rowData.put(WiFi_Data.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
rowData.put(WiFi_Data.TIMESTAMP, System.currentTimeMillis());
rowData.put(WiFi_Data.LABEL, "disabled");
diff --git a/aware-core/src/main/java/com/aware/providers/Accelerometer_Provider.java b/aware-core/src/main/java/com/aware/providers/Accelerometer_Provider.java
index 9a7c762d..b78154d3 100644
--- a/aware-core/src/main/java/com/aware/providers/Accelerometer_Provider.java
+++ b/aware-core/src/main/java/com/aware/providers/Accelerometer_Provider.java
@@ -17,6 +17,7 @@
import com.aware.Accelerometer;
import com.aware.Aware;
import com.aware.utils.DatabaseHelper;
+import com.aware.utils.DatabaseTransaction;
import java.util.HashMap;
@@ -29,7 +30,7 @@
*/
public class Accelerometer_Provider extends ContentProvider {
- public static final int DATABASE_VERSION = 5;
+ public static final int DATABASE_VERSION = 6;
/**
* Authority of content provider
@@ -93,7 +94,6 @@ private Accelerometer_Data() {
public static final String VALUES_1 = "double_values_1";
public static final String VALUES_2 = "double_values_2";
public static final String ACCURACY = "accuracy";
- public static final String LABEL = "label";
}
public static String DATABASE_NAME = "accelerometer.db";
@@ -120,15 +120,16 @@ private Accelerometer_Data() {
+ Accelerometer_Data.VALUES_0 + " real default 0,"
+ Accelerometer_Data.VALUES_1 + " real default 0,"
+ Accelerometer_Data.VALUES_2 + " real default 0,"
- + Accelerometer_Data.ACCURACY + " integer default 0,"
- + Accelerometer_Data.LABEL + " text default ''"};
+ + Accelerometer_Data.ACCURACY + " integer default 0"};
private DatabaseHelper dbHelper;
private static SQLiteDatabase database;
private void initialiseDatabase() {
- if (dbHelper == null)
+ if (dbHelper == null) {
dbHelper = new DatabaseHelper(getContext(), DATABASE_NAME, null, DATABASE_VERSION, DATABASE_TABLES, TABLES_FIELDS);
+ dbHelper.setMetadataOnlyTrailingColumnDrops("label");
+ }
if (database == null)
database = dbHelper.getWritableDatabase();
}
@@ -141,27 +142,26 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs
initialiseDatabase();
- database.beginTransaction();
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
- int count;
- switch (sUriMatcher.match(uri)) {
- case ACCEL_DEV:
- count = database.delete(DATABASE_TABLES[0], selection, selectionArgs);
- break;
- case ACCEL_DATA:
- count = database.delete(DATABASE_TABLES[1], selection, selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
- }
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case ACCEL_DEV:
+ count = database.delete(DATABASE_TABLES[0], selection, selectionArgs);
+ break;
+ case ACCEL_DATA:
+ count = database.delete(DATABASE_TABLES[1], selection, selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
- database.setTransactionSuccessful();
- database.endTransaction();
+ transaction.commit();
- getContext().getContentResolver().notifyChange(uri, null, false);
+ getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
+ return count;
+ }
}
@Override
@@ -190,34 +190,30 @@ public synchronized Uri insert(Uri uri, ContentValues initialValues) {
ContentValues values = (initialValues != null) ? new ContentValues(initialValues) : new ContentValues();
- database.beginTransaction();
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
- switch (sUriMatcher.match(uri)) {
- case ACCEL_DEV:
- long accel_id = database.insertWithOnConflict(DATABASE_TABLES[0], Accelerometer_Sensor.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- if (accel_id > 0) {
- Uri accelUri = ContentUris.withAppendedId(Accelerometer_Sensor.CONTENT_URI, accel_id);
- getContext().getContentResolver().notifyChange(accelUri, null, false);
- database.setTransactionSuccessful();
- database.endTransaction();
- return accelUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- case ACCEL_DATA:
- long accelData_id = database.insertWithOnConflict(DATABASE_TABLES[1], Accelerometer_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- if (accelData_id > 0) {
- Uri accelDataUri = ContentUris.withAppendedId(Accelerometer_Data.CONTENT_URI, accelData_id);
- getContext().getContentResolver().notifyChange(accelDataUri, null, false);
- database.setTransactionSuccessful();
- database.endTransaction();
- return accelDataUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ switch (sUriMatcher.match(uri)) {
+ case ACCEL_DEV:
+ long accel_id = database.insertWithOnConflict(DATABASE_TABLES[0], Accelerometer_Sensor.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ if (accel_id > 0) {
+ Uri accelUri = ContentUris.withAppendedId(Accelerometer_Sensor.CONTENT_URI, accel_id);
+ transaction.commit();
+ getContext().getContentResolver().notifyChange(accelUri, null, false);
+ return accelUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ case ACCEL_DATA:
+ long accelData_id = database.insertWithOnConflict(DATABASE_TABLES[1], Accelerometer_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ if (accelData_id > 0) {
+ Uri accelDataUri = ContentUris.withAppendedId(Accelerometer_Data.CONTENT_URI, accelData_id);
+ transaction.commit();
+ getContext().getContentResolver().notifyChange(accelDataUri, null, false);
+ return accelDataUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
}
}
@@ -233,51 +229,50 @@ public synchronized int bulkInsert(Uri uri, ContentValues[] values) {
initialiseDatabase();
- database.beginTransaction();
-
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case ACCEL_DEV:
- for (ContentValues v : values) {
- long id;
- try {
- id = database.insertOrThrow(DATABASE_TABLES[0], Accelerometer_Sensor.DEVICE_ID, v);
- } catch (SQLException e) {
- id = database.replace(DATABASE_TABLES[0], Accelerometer_Sensor.DEVICE_ID, v);
- }
- if (id <= 0) {
- Log.w(Accelerometer.TAG, "Failed to insert/replace row into " + uri);
- } else {
- count++;
- }
- }
- break;
- case ACCEL_DATA:
- for (ContentValues v : values) {
- long id;
- try {
- id = database.insertOrThrow(DATABASE_TABLES[1], Accelerometer_Data.DEVICE_ID, v);
- } catch (SQLException e) {
- id = database.replace(DATABASE_TABLES[1], Accelerometer_Data.DEVICE_ID, v);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case ACCEL_DEV:
+ for (ContentValues v : values) {
+ long id;
+ try {
+ id = database.insertOrThrow(DATABASE_TABLES[0], Accelerometer_Sensor.DEVICE_ID, v);
+ } catch (SQLException e) {
+ id = database.replace(DATABASE_TABLES[0], Accelerometer_Sensor.DEVICE_ID, v);
+ }
+ if (id <= 0) {
+ Log.w(Accelerometer.TAG, "Failed to insert/replace row into " + uri);
+ } else {
+ count++;
+ }
}
- if (id <= 0) {
- Log.w(Accelerometer.TAG, "Failed to insert/replace row into " + uri);
- } else {
- count++;
+ break;
+ case ACCEL_DATA:
+ for (ContentValues v : values) {
+ long id;
+ try {
+ id = database.insertOrThrow(DATABASE_TABLES[1], Accelerometer_Data.DEVICE_ID, v);
+ } catch (SQLException e) {
+ id = database.replace(DATABASE_TABLES[1], Accelerometer_Data.DEVICE_ID, v);
+ }
+ if (id <= 0) {
+ Log.w(Accelerometer.TAG, "Failed to insert/replace row into " + uri);
+ } else {
+ count++;
+ }
}
- }
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
- }
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
- database.setTransactionSuccessful();
- database.endTransaction();
+ transaction.commit();
- getContext().getContentResolver().notifyChange(uri, null, false);
+ getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
+ return count;
+ }
}
/**
@@ -320,7 +315,6 @@ public boolean onCreate() {
accelDataMap.put(Accelerometer_Data.VALUES_1, Accelerometer_Data.VALUES_1);
accelDataMap.put(Accelerometer_Data.VALUES_2, Accelerometer_Data.VALUES_2);
accelDataMap.put(Accelerometer_Data.ACCURACY, Accelerometer_Data.ACCURACY);
- accelDataMap.put(Accelerometer_Data.LABEL, Accelerometer_Data.LABEL);
return true;
}
@@ -353,7 +347,7 @@ public Cursor query(Uri uri, String[] projection, String selection, String[] sel
return c;
} catch (IllegalStateException e) {
if (Aware.DEBUG) Log.e(Aware.TAG, e.getMessage());
- return null;
+ throw e;
}
}
@@ -365,26 +359,25 @@ public synchronized int update(Uri uri, ContentValues values, String selection,
initialiseDatabase();
- database.beginTransaction();
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
- int count;
- switch (sUriMatcher.match(uri)) {
- case ACCEL_DEV:
- count = database.update(DATABASE_TABLES[0], values, selection, selectionArgs);
- break;
- case ACCEL_DATA:
- count = database.update(DATABASE_TABLES[1], values, selection, selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
- }
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case ACCEL_DEV:
+ count = database.update(DATABASE_TABLES[0], values, selection, selectionArgs);
+ break;
+ case ACCEL_DATA:
+ count = database.update(DATABASE_TABLES[1], values, selection, selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
- database.setTransactionSuccessful();
- database.endTransaction();
+ transaction.commit();
- getContext().getContentResolver().notifyChange(uri, null, false);
+ getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
+ return count;
+ }
}
}
diff --git a/aware-core/src/main/java/com/aware/providers/Applications_Provider.java b/aware-core/src/main/java/com/aware/providers/Applications_Provider.java
index ecb5fe47..ea606972 100644
--- a/aware-core/src/main/java/com/aware/providers/Applications_Provider.java
+++ b/aware-core/src/main/java/com/aware/providers/Applications_Provider.java
@@ -16,6 +16,7 @@
import com.aware.Aware;
import com.aware.utils.DatabaseHelper;
+import com.aware.utils.DatabaseTransaction;
import java.util.HashMap;
@@ -213,33 +214,32 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs
initialiseDatabase();
//lock database for transaction
- database.beginTransaction();
-
- int count;
- switch (sUriMatcher.match(uri)) {
- case FOREGROUND:
- count = database.delete(DATABASE_TABLES[0], selection, selectionArgs);
- break;
- case APPLICATIONS:
- count = database.delete(DATABASE_TABLES[1], selection, selectionArgs);
- break;
- case NOTIFICATIONS:
- count = database.delete(DATABASE_TABLES[2], selection, selectionArgs);
- break;
- case ERROR:
- count = database.delete(DATABASE_TABLES[3], selection, selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case FOREGROUND:
+ count = database.delete(DATABASE_TABLES[0], selection, selectionArgs);
+ break;
+ case APPLICATIONS:
+ count = database.delete(DATABASE_TABLES[1], selection, selectionArgs);
+ break;
+ case NOTIFICATIONS:
+ count = database.delete(DATABASE_TABLES[2], selection, selectionArgs);
+ break;
+ case ERROR:
+ count = database.delete(DATABASE_TABLES[3], selection, selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
-
- return count;
}
@Override
@@ -276,54 +276,48 @@ public synchronized Uri insert(Uri uri, ContentValues initialValues) {
ContentValues values = (initialValues != null) ? new ContentValues(initialValues) : new ContentValues();
- database.beginTransaction();
-
- switch (sUriMatcher.match(uri)) {
- case FOREGROUND:
- long foreground_id = database.insertWithOnConflict(DATABASE_TABLES[0], Applications_Foreground.APPLICATION_NAME, values, SQLiteDatabase.CONFLICT_IGNORE);
- if (foreground_id > 0) {
- Uri foregroundUri = ContentUris.withAppendedId(Applications_Foreground.CONTENT_URI, foreground_id);
- getContext().getContentResolver().notifyChange(foregroundUri, null, false);
- database.setTransactionSuccessful();
- database.endTransaction();
- return foregroundUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- case APPLICATIONS:
- long applications_id = database.insertWithOnConflict(DATABASE_TABLES[1], Applications_History.PACKAGE_NAME, values, SQLiteDatabase.CONFLICT_IGNORE);
- if (applications_id > 0) {
- Uri applicationsUri = ContentUris.withAppendedId(Applications_History.CONTENT_URI, applications_id);
- getContext().getContentResolver().notifyChange(applicationsUri, null, false);
- database.setTransactionSuccessful();
- database.endTransaction();
- return applicationsUri;
- }
- throw new SQLException("Failed to insert row into " + uri);
- case NOTIFICATIONS:
- long notifications_id = database.insertWithOnConflict(DATABASE_TABLES[2], Applications_Notifications.PACKAGE_NAME, values, SQLiteDatabase.CONFLICT_IGNORE);
- if (notifications_id > 0) {
- Uri notificationsUri = ContentUris.withAppendedId(Applications_Notifications.CONTENT_URI, notifications_id);
- getContext().getContentResolver().notifyChange(notificationsUri, null, false);
- database.setTransactionSuccessful();
- database.endTransaction();
- return notificationsUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- case ERROR:
- long error_id = database.insertWithOnConflict(DATABASE_TABLES[3], Applications_Crashes.PACKAGE_NAME, values, SQLiteDatabase.CONFLICT_IGNORE);
- if (error_id > 0) {
- Uri errorsUri = ContentUris.withAppendedId(Applications_Crashes.CONTENT_URI, error_id);
- getContext().getContentResolver().notifyChange(errorsUri, null, false);
- database.setTransactionSuccessful();
- database.endTransaction();
- return errorsUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- default:
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ switch (sUriMatcher.match(uri)) {
+ case FOREGROUND:
+ long foreground_id = database.insertWithOnConflict(DATABASE_TABLES[0], Applications_Foreground.APPLICATION_NAME, values, SQLiteDatabase.CONFLICT_IGNORE);
+ if (foreground_id > 0) {
+ Uri foregroundUri = ContentUris.withAppendedId(Applications_Foreground.CONTENT_URI, foreground_id);
+ transaction.commit();
+ getContext().getContentResolver().notifyChange(foregroundUri, null, false);
+ return foregroundUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ case APPLICATIONS:
+ long applications_id = database.insertWithOnConflict(DATABASE_TABLES[1], Applications_History.PACKAGE_NAME, values, SQLiteDatabase.CONFLICT_IGNORE);
+ if (applications_id > 0) {
+ Uri applicationsUri = ContentUris.withAppendedId(Applications_History.CONTENT_URI, applications_id);
+ transaction.commit();
+ getContext().getContentResolver().notifyChange(applicationsUri, null, false);
+ return applicationsUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ case NOTIFICATIONS:
+ long notifications_id = database.insertWithOnConflict(DATABASE_TABLES[2], Applications_Notifications.PACKAGE_NAME, values, SQLiteDatabase.CONFLICT_IGNORE);
+ if (notifications_id > 0) {
+ Uri notificationsUri = ContentUris.withAppendedId(Applications_Notifications.CONTENT_URI, notifications_id);
+ transaction.commit();
+ getContext().getContentResolver().notifyChange(notificationsUri, null, false);
+ return notificationsUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ case ERROR:
+ long error_id = database.insertWithOnConflict(DATABASE_TABLES[3], Applications_Crashes.PACKAGE_NAME, values, SQLiteDatabase.CONFLICT_IGNORE);
+ if (error_id > 0) {
+ Uri errorsUri = ContentUris.withAppendedId(Applications_Crashes.CONTENT_URI, error_id);
+ transaction.commit();
+ getContext().getContentResolver().notifyChange(errorsUri, null, false);
+ return errorsUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
}
}
@@ -475,7 +469,7 @@ public Cursor query(Uri uri, String[] projection, String selection,
} catch (IllegalStateException e) {
if (Aware.DEBUG)
Log.e(Aware.TAG, e.getMessage());
- return null;
+ throw e;
}
}
@@ -487,30 +481,30 @@ public synchronized int update(Uri uri, ContentValues values, String selection,
initialiseDatabase();
- database.beginTransaction();
-
- int count;
- switch (sUriMatcher.match(uri)) {
- case FOREGROUND:
- count = database.update(DATABASE_TABLES[0], values, selection, selectionArgs);
- break;
- case APPLICATIONS:
- count = database.update(DATABASE_TABLES[1], values, selection, selectionArgs);
- break;
- case NOTIFICATIONS:
- count = database.update(DATABASE_TABLES[2], values, selection, selectionArgs);
- break;
- case ERROR:
- count = database.update(DATABASE_TABLES[3], values, selection, selectionArgs);
- break;
- default:
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case FOREGROUND:
+ count = database.update(DATABASE_TABLES[0], values, selection, selectionArgs);
+ break;
+ case APPLICATIONS:
+ count = database.update(DATABASE_TABLES[1], values, selection, selectionArgs);
+ break;
+ case NOTIFICATIONS:
+ count = database.update(DATABASE_TABLES[2], values, selection, selectionArgs);
+ break;
+ case ERROR:
+ count = database.update(DATABASE_TABLES[3], values, selection, selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
}
diff --git a/aware-core/src/main/java/com/aware/providers/Aware_Provider.java b/aware-core/src/main/java/com/aware/providers/Aware_Provider.java
index db46cb85..448627fa 100644
--- a/aware-core/src/main/java/com/aware/providers/Aware_Provider.java
+++ b/aware-core/src/main/java/com/aware/providers/Aware_Provider.java
@@ -17,12 +17,9 @@
import com.aware.Aware;
import com.aware.utils.DatabaseHelper;
+import com.aware.utils.DatabaseTransaction;
import java.util.HashMap;
-import java.net.HttpURLConnection;
-import java.net.URL;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
/**
* AWARE framework content provider - Device information - Framework settings -
@@ -32,7 +29,7 @@
*/
public class Aware_Provider extends ContentProvider {
- public static final int DATABASE_VERSION = 18;
+ public static final int DATABASE_VERSION = 21;
/**
* AWARE framework content authority
@@ -50,6 +47,8 @@ public class Aware_Provider extends ContentProvider {
private final int STUDY_ID = 8;
private final int LOG = 9;
private final int LOG_ID = 10;
+ private final int SYNC_MARKER = 11;
+ private final int SYNC_MARKER_ID = 12;
/**
* Information about the device in which the framework is installed.
@@ -64,20 +63,16 @@ private Aware_Device() {
public static final String CONTENT_TYPE = "vnd.android.cursor.dir/vnd.aware.device";
public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/vnd.aware.device";
- public static final String _ID = "_id";
public static final String TIMESTAMP = "timestamp";
public static final String DEVICE_ID = "device_id";
public static final String BOARD = "board";
- public static final String BRAND = "brand";
public static final String DEVICE = "device";
public static final String BUILD_ID = "build_id";
public static final String HARDWARE = "hardware";
public static final String MANUFACTURER = "manufacturer";
public static final String MODEL = "model";
public static final String PRODUCT = "product";
- public static final String SERIAL = "serial";
public static final String RELEASE = "release";
- public static final String RELEASE_TYPE = "release_type";
public static final String SDK = "sdk";
public static final String LABEL = "label";
}
@@ -143,7 +138,8 @@ private Aware_Studies() {
public static final String STUDY_TITLE = "study_title";
public static final String STUDY_DESCRIPTION = "study_description";
public static final String STUDY_JOINED = "double_join";
- public static final String STUDY_UPDATED = "double_updated"; // TODO RIO: Use this date for all relevant study updates
+ /** When the study configuration on this device was last replaced by a server version. */
+ public static final String STUDY_UPDATED = "double_updated";
public static final String STUDY_EXIT = "double_exit";
public static final String STUDY_COMPLIANCE = "study_compliance";
}
@@ -160,26 +156,58 @@ private Aware_Log() {
public static final String LOG_TIMESTAMP = "timestamp";
public static final String LOG_DEVICE_ID = "device_id";
public static final String LOG_MESSAGE = "log_message";
+
+ /**
+ * What kind of record a row is, so the log can be filtered and counted by kind rather than
+ * by matching the text of {@link #LOG_MESSAGE}. See {@link com.aware.Aware.LogType} for the
+ * vocabulary.
+ */
+ public static final String LOG_TYPE = "log_type";
+ }
+
+ /**
+ * How far each table has been uploaded, one row per table.
+ *
+ * Kept apart from {@link Aware_Log} because the two have opposite lifetimes: a log entry is
+ * uploaded and then cleared, while a marker has to outlive every sync that reads it. Holding
+ * them in one table meant the log's own cleanup removed the markers, and every table whose rows
+ * are retained locally was then uploaded again from the beginning on the following sync.
+ *
+ * Local only — this is the phone's bookkeeping, and the server keeps its own copy of the data
+ * these markers describe.
+ */
+ public static final class Aware_Sync_Markers implements BaseColumns {
+ private Aware_Sync_Markers() {
+ }
+
+ public static final Uri CONTENT_URI = Uri.parse("content://" + Aware_Provider.AUTHORITY + "/aware_sync_markers");
+ public static final String CONTENT_TYPE = "vnd.android.cursor.dir/vnd.aware.sync_markers";
+ public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/vnd.aware.sync_markers";
+
+ public static final String MARKER_ID = "_id";
+ /** Name of the table this marker describes; one row per table. */
+ public static final String MARKER_TABLE = "table_name";
+ /** Timestamp of the last row the server acknowledged for that table. */
+ public static final String MARKER_LAST_SYNCED = "last_sync_timestamp";
+ /** Row id of the last row the server acknowledged for that table. */
+ public static final String MARKER_LAST_ID = "last_sync_id";
}
public static String DATABASE_NAME = "aware.db";
- public static final String[] DATABASE_TABLES = {"aware_device", "aware_settings", "aware_plugins", "aware_studies", "aware_log"};
+ public static final String[] DATABASE_TABLES = {"aware_device", "aware_settings", "aware_plugins", "aware_studies", "aware_log", "aware_sync_markers"};
public static final String[] TABLES_FIELDS = {
// Device information
Aware_Device._ID + " integer primary key autoincrement,"
+ Aware_Device.TIMESTAMP + " real default 0,"
+ Aware_Device.DEVICE_ID + " text default '',"
+ Aware_Device.BOARD + " text default '',"
- + Aware_Device.BRAND + " text default '',"
+ Aware_Device.DEVICE + " text default '',"
+ Aware_Device.BUILD_ID + " text default '',"
+ Aware_Device.HARDWARE + " text default '',"
+ Aware_Device.MANUFACTURER + " text default '',"
+ Aware_Device.MODEL + " text default '',"
+ Aware_Device.PRODUCT + " text default '',"
- + Aware_Device.SERIAL + " text default '',"
+ Aware_Device.RELEASE + " text default '',"
- + Aware_Device.RELEASE_TYPE + " text default '',"
+ Aware_Device.SDK + " text default '',"
+ Aware_Device.LABEL + " text default '',"
+ "UNIQUE(" + Aware_Device.DEVICE_ID + ")",
@@ -212,13 +240,22 @@ private Aware_Log() {
Aware_Studies.STUDY_TITLE + " text default ''," +
Aware_Studies.STUDY_DESCRIPTION + " text default ''," +
Aware_Studies.STUDY_JOINED + " real default 0," +
+ Aware_Studies.STUDY_UPDATED + " real default 0," +
Aware_Studies.STUDY_EXIT + " real default 0," +
Aware_Studies.STUDY_COMPLIANCE + " text default ''",
Aware_Log.LOG_ID + " integer primary key autoincrement," +
Aware_Log.LOG_TIMESTAMP + " real default 0," +
Aware_Log.LOG_DEVICE_ID + " text default ''," +
- Aware_Log.LOG_MESSAGE + " text default ''"
+ Aware_Log.LOG_TYPE + " text default ''," +
+ Aware_Log.LOG_MESSAGE + " text default ''",
+
+ // Sync markers
+ Aware_Sync_Markers.MARKER_ID + " integer primary key autoincrement," +
+ Aware_Sync_Markers.MARKER_TABLE + " text default ''," +
+ Aware_Sync_Markers.MARKER_LAST_SYNCED + " real default 0," +
+ Aware_Sync_Markers.MARKER_LAST_ID + " integer default 0," +
+ "UNIQUE(" + Aware_Sync_Markers.MARKER_TABLE + ")"
};
private UriMatcher sUriMatcher;
@@ -227,6 +264,7 @@ private Aware_Log() {
private HashMap pluginsMap;
private HashMap studiesMap;
private HashMap logMap;
+ private HashMap syncMarkersMap;
private DatabaseHelper dbHelper;
private static SQLiteDatabase database;
@@ -245,38 +283,39 @@ private void initialiseDatabase() {
public synchronized int delete(Uri uri, String selection, String[] selectionArgs) {
initialiseDatabase();
- if (database == null) return 0;
- database.beginTransaction();
-
- int count;
- switch (sUriMatcher.match(uri)) {
- case DEVICE_INFO:
- count = database.delete(DATABASE_TABLES[0], selection, selectionArgs);
- break;
- case SETTING:
- count = database.delete(DATABASE_TABLES[1], selection, selectionArgs);
- break;
- case PLUGIN:
- count = database.delete(DATABASE_TABLES[2], selection, selectionArgs);
- break;
- case STUDY:
- count = database.delete(DATABASE_TABLES[3], selection, selectionArgs);
- break;
- case LOG:
- count = database.delete(DATABASE_TABLES[4], selection, selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case DEVICE_INFO:
+ count = database.delete(DATABASE_TABLES[0], selection, selectionArgs);
+ break;
+ case SETTING:
+ count = database.delete(DATABASE_TABLES[1], selection, selectionArgs);
+ break;
+ case PLUGIN:
+ count = database.delete(DATABASE_TABLES[2], selection, selectionArgs);
+ break;
+ case STUDY:
+ count = database.delete(DATABASE_TABLES[3], selection, selectionArgs);
+ break;
+ case LOG:
+ count = database.delete(DATABASE_TABLES[4], selection, selectionArgs);
+ break;
+ case SYNC_MARKER:
+ count = database.delete(DATABASE_TABLES[5], selection, selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
-
- return count;
}
@Override
@@ -302,6 +341,10 @@ public String getType(Uri uri) {
return Aware_Log.CONTENT_TYPE;
case LOG_ID:
return Aware_Log.CONTENT_ITEM_TYPE;
+ case SYNC_MARKER:
+ return Aware_Sync_Markers.CONTENT_TYPE;
+ case SYNC_MARKER_ID:
+ return Aware_Sync_Markers.CONTENT_ITEM_TYPE;
default:
throw new IllegalArgumentException("Unknown URI " + uri);
}
@@ -314,97 +357,73 @@ public String getType(Uri uri) {
public synchronized Uri insert(Uri uri, ContentValues initialValues) {
initialiseDatabase();
- if (database == null) throw new SQLException("Failed to read database: " + uri);
ContentValues values = (initialValues != null) ? new ContentValues(initialValues) : new ContentValues();
- database.beginTransaction();
-
- switch (sUriMatcher.match(uri)) {
- case DEVICE_INFO:
- long dev_id = database.insertWithOnConflict(DATABASE_TABLES[0], Aware_Device.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- try{
-
- ExecutorService executorService = Executors.newSingleThreadExecutor();
- executorService.execute(new Runnable() {
- @Override
- public void run() {
- try{
- URL url = new URL("https://awareframework.com/aware_installation_counter.php?data=" + values.toString());
- HttpURLConnection connection = (HttpURLConnection) url.openConnection();
- connection.setRequestMethod("GET");
- int responseCode = connection.getResponseCode();
- connection.disconnect();}
- catch (Exception e){
- Log.e("Aware", Log.getStackTraceString(e));
- }
- }
- });
- } catch (Exception e){
- Log.e("Aware", e.toString());
- }
- if (dev_id > 0) {
- Uri devUri = ContentUris.withAppendedId(
- Aware_Device.CONTENT_URI, dev_id);
- getContext().getContentResolver().notifyChange(devUri, null, false);
- database.setTransactionSuccessful();
- database.endTransaction();
-
-
-
-
- return devUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- case SETTING:
- long sett_id = database.insertWithOnConflict(DATABASE_TABLES[1], Aware_Settings.SETTING_KEY, values, SQLiteDatabase.CONFLICT_IGNORE);
- if (sett_id > 0) {
- Uri settUri = ContentUris.withAppendedId(
- Aware_Settings.CONTENT_URI, sett_id);
- getContext().getContentResolver().notifyChange(settUri, null, false);
- database.setTransactionSuccessful();
- database.endTransaction();
- return settUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- case PLUGIN:
- long plug_id = database.insertWithOnConflict(DATABASE_TABLES[2], Aware_Plugins.PLUGIN_NAME, values, SQLiteDatabase.CONFLICT_IGNORE);
- if (plug_id > 0) {
- Uri settUri = ContentUris.withAppendedId(Aware_Plugins.CONTENT_URI, plug_id);
- getContext().getContentResolver().notifyChange(settUri, null, false);
- database.setTransactionSuccessful();
- database.endTransaction();
- return settUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- case STUDY:
- long study_id = database.insertWithOnConflict(DATABASE_TABLES[3], Aware_Studies.STUDY_DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- if (study_id > 0) {
- Uri settUri = ContentUris.withAppendedId(Aware_Studies.CONTENT_URI, study_id);
- getContext().getContentResolver().notifyChange(settUri, null, false);
- database.setTransactionSuccessful();
- database.endTransaction();
- return settUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- case LOG:
- long log_id = database.insertWithOnConflict(DATABASE_TABLES[4], Aware_Log.LOG_DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- if (log_id > 0) {
- Uri settUri = ContentUris.withAppendedId(Aware_Log.CONTENT_URI, log_id);
- getContext().getContentResolver().notifyChange(settUri, null, false);
- database.setTransactionSuccessful();
- database.endTransaction();
- return settUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ switch (sUriMatcher.match(uri)) {
+ case DEVICE_INFO:
+ long dev_id = database.insertWithOnConflict(DATABASE_TABLES[0], Aware_Device.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ if (dev_id > 0) {
+ Uri devUri = ContentUris.withAppendedId(
+ Aware_Device.CONTENT_URI, dev_id);
+ transaction.commit();
+ getContext().getContentResolver().notifyChange(devUri, null, false);
+ return devUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ case SETTING:
+ long sett_id = database.insertWithOnConflict(DATABASE_TABLES[1], Aware_Settings.SETTING_KEY, values, SQLiteDatabase.CONFLICT_IGNORE);
+ if (sett_id > 0) {
+ Uri settUri = ContentUris.withAppendedId(
+ Aware_Settings.CONTENT_URI, sett_id);
+ transaction.commit();
+ getContext().getContentResolver().notifyChange(settUri, null, false);
+ return settUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ case PLUGIN:
+ long plug_id = database.insertWithOnConflict(DATABASE_TABLES[2], Aware_Plugins.PLUGIN_NAME, values, SQLiteDatabase.CONFLICT_IGNORE);
+ if (plug_id > 0) {
+ Uri settUri = ContentUris.withAppendedId(Aware_Plugins.CONTENT_URI, plug_id);
+ transaction.commit();
+ getContext().getContentResolver().notifyChange(settUri, null, false);
+ return settUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ case STUDY:
+ long study_id = database.insertWithOnConflict(DATABASE_TABLES[3], Aware_Studies.STUDY_DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ if (study_id > 0) {
+ Uri settUri = ContentUris.withAppendedId(Aware_Studies.CONTENT_URI, study_id);
+ transaction.commit();
+ getContext().getContentResolver().notifyChange(settUri, null, false);
+ return settUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ case LOG:
+ long log_id = database.insertWithOnConflict(DATABASE_TABLES[4], Aware_Log.LOG_DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ if (log_id > 0) {
+ Uri settUri = ContentUris.withAppendedId(Aware_Log.CONTENT_URI, log_id);
+ transaction.commit();
+ getContext().getContentResolver().notifyChange(settUri, null, false);
+ return settUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ case SYNC_MARKER:
+ // CONFLICT_REPLACE, so writing a table's marker supersedes its previous one and the
+ // table holds one row per synced table.
+ long marker_id = database.insertWithOnConflict(DATABASE_TABLES[5], Aware_Sync_Markers.MARKER_TABLE, values, SQLiteDatabase.CONFLICT_REPLACE);
+ if (marker_id > 0) {
+ Uri markerUri = ContentUris.withAppendedId(Aware_Sync_Markers.CONTENT_URI, marker_id);
+ transaction.commit();
+ getContext().getContentResolver().notifyChange(markerUri, null, false);
+ return markerUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
}
}
@@ -432,22 +451,21 @@ public boolean onCreate() {
sUriMatcher.addURI(Aware_Provider.AUTHORITY, DATABASE_TABLES[3] + "/#", STUDY_ID);
sUriMatcher.addURI(Aware_Provider.AUTHORITY, DATABASE_TABLES[4], LOG);
sUriMatcher.addURI(Aware_Provider.AUTHORITY, DATABASE_TABLES[4] + "/#", LOG_ID);
+ sUriMatcher.addURI(Aware_Provider.AUTHORITY, DATABASE_TABLES[5], SYNC_MARKER);
+ sUriMatcher.addURI(Aware_Provider.AUTHORITY, DATABASE_TABLES[5] + "/#", SYNC_MARKER_ID);
deviceMap = new HashMap<>();
deviceMap.put(Aware_Device._ID, Aware_Device._ID);
deviceMap.put(Aware_Device.TIMESTAMP, Aware_Device.TIMESTAMP);
deviceMap.put(Aware_Device.DEVICE_ID, Aware_Device.DEVICE_ID);
deviceMap.put(Aware_Device.BOARD, Aware_Device.BOARD);
- deviceMap.put(Aware_Device.BRAND, Aware_Device.BRAND);
deviceMap.put(Aware_Device.DEVICE, Aware_Device.DEVICE);
deviceMap.put(Aware_Device.BUILD_ID, Aware_Device.BUILD_ID);
deviceMap.put(Aware_Device.HARDWARE, Aware_Device.HARDWARE);
deviceMap.put(Aware_Device.MANUFACTURER, Aware_Device.MANUFACTURER);
deviceMap.put(Aware_Device.MODEL, Aware_Device.MODEL);
deviceMap.put(Aware_Device.PRODUCT, Aware_Device.PRODUCT);
- deviceMap.put(Aware_Device.SERIAL, Aware_Device.SERIAL);
deviceMap.put(Aware_Device.RELEASE, Aware_Device.RELEASE);
- deviceMap.put(Aware_Device.RELEASE_TYPE, Aware_Device.RELEASE_TYPE);
deviceMap.put(Aware_Device.SDK, Aware_Device.SDK);
deviceMap.put(Aware_Device.LABEL, Aware_Device.LABEL);
@@ -479,6 +497,7 @@ public boolean onCreate() {
studiesMap.put(Aware_Studies.STUDY_TITLE, Aware_Studies.STUDY_TITLE);
studiesMap.put(Aware_Studies.STUDY_DESCRIPTION, Aware_Studies.STUDY_DESCRIPTION);
studiesMap.put(Aware_Studies.STUDY_JOINED, Aware_Studies.STUDY_JOINED);
+ studiesMap.put(Aware_Studies.STUDY_UPDATED, Aware_Studies.STUDY_UPDATED);
studiesMap.put(Aware_Studies.STUDY_EXIT, Aware_Studies.STUDY_EXIT);
studiesMap.put(Aware_Studies.STUDY_COMPLIANCE, Aware_Studies.STUDY_COMPLIANCE);
@@ -486,8 +505,15 @@ public boolean onCreate() {
logMap.put(Aware_Log.LOG_ID, Aware_Log.LOG_ID);
logMap.put(Aware_Log.LOG_TIMESTAMP, Aware_Log.LOG_TIMESTAMP);
logMap.put(Aware_Log.LOG_DEVICE_ID, Aware_Log.LOG_DEVICE_ID);
+ logMap.put(Aware_Log.LOG_TYPE, Aware_Log.LOG_TYPE);
logMap.put(Aware_Log.LOG_MESSAGE, Aware_Log.LOG_MESSAGE);
+ syncMarkersMap = new HashMap<>();
+ syncMarkersMap.put(Aware_Sync_Markers.MARKER_ID, Aware_Sync_Markers.MARKER_ID);
+ syncMarkersMap.put(Aware_Sync_Markers.MARKER_TABLE, Aware_Sync_Markers.MARKER_TABLE);
+ syncMarkersMap.put(Aware_Sync_Markers.MARKER_LAST_SYNCED, Aware_Sync_Markers.MARKER_LAST_SYNCED);
+ syncMarkersMap.put(Aware_Sync_Markers.MARKER_LAST_ID, Aware_Sync_Markers.MARKER_LAST_ID);
+
return true;
}
@@ -499,7 +525,6 @@ public boolean onCreate() {
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
initialiseDatabase();
- if (database == null) return null;
SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
qb.setStrict(true);
@@ -524,6 +549,10 @@ public Cursor query(Uri uri, String[] projection, String selection, String[] sel
qb.setTables(DATABASE_TABLES[4]);
qb.setProjectionMap(logMap);
break;
+ case SYNC_MARKER:
+ qb.setTables(DATABASE_TABLES[5]);
+ qb.setProjectionMap(syncMarkersMap);
+ break;
default:
throw new IllegalArgumentException("Unknown URI " + uri);
}
@@ -533,7 +562,7 @@ public Cursor query(Uri uri, String[] projection, String selection, String[] sel
return c;
} catch (IllegalStateException e) {
if (Aware.DEBUG) Log.e(Aware.TAG, e.getMessage());
- return null;
+ throw e;
}
}
@@ -544,37 +573,38 @@ public Cursor query(Uri uri, String[] projection, String selection, String[] sel
public synchronized int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
initialiseDatabase();
- if (database == null) return 0;
-
- database.beginTransaction();
- int count;
- switch (sUriMatcher.match(uri)) {
- case DEVICE_INFO:
- count = database.update(DATABASE_TABLES[0], values, selection, selectionArgs);
- break;
- case SETTING:
- count = database.update(DATABASE_TABLES[1], values, selection, selectionArgs);
- break;
- case PLUGIN:
- count = database.update(DATABASE_TABLES[2], values, selection, selectionArgs);
- break;
- case STUDY:
- count = database.update(DATABASE_TABLES[3], values, selection, selectionArgs);
- break;
- case LOG:
- count = database.update(DATABASE_TABLES[4], values, selection, selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case DEVICE_INFO:
+ count = database.update(DATABASE_TABLES[0], values, selection, selectionArgs);
+ break;
+ case SETTING:
+ count = database.update(DATABASE_TABLES[1], values, selection, selectionArgs);
+ break;
+ case PLUGIN:
+ count = database.update(DATABASE_TABLES[2], values, selection, selectionArgs);
+ break;
+ case STUDY:
+ count = database.update(DATABASE_TABLES[3], values, selection, selectionArgs);
+ break;
+ case LOG:
+ count = database.update(DATABASE_TABLES[4], values, selection, selectionArgs);
+ break;
+ case SYNC_MARKER:
+ count = database.update(DATABASE_TABLES[5], values, selection, selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
-
- return count;
}
}
diff --git a/aware-core/src/main/java/com/aware/providers/Barometer_Provider.java b/aware-core/src/main/java/com/aware/providers/Barometer_Provider.java
index 9d15fee4..448e5c12 100644
--- a/aware-core/src/main/java/com/aware/providers/Barometer_Provider.java
+++ b/aware-core/src/main/java/com/aware/providers/Barometer_Provider.java
@@ -17,6 +17,7 @@
import com.aware.Aware;
import com.aware.Barometer;
import com.aware.utils.DatabaseHelper;
+import com.aware.utils.DatabaseTransaction;
import java.util.HashMap;
@@ -28,7 +29,7 @@
*/
public class Barometer_Provider extends ContentProvider {
- public static final int DATABASE_VERSION = 2;
+ public static final int DATABASE_VERSION = 3;
/**
* Authority of content provider
@@ -87,7 +88,6 @@ private Barometer_Data() {
public static final String DEVICE_ID = "device_id";
public static final String AMBIENT_PRESSURE = "double_values_0";
public static final String ACCURACY = "accuracy";
- public static final String LABEL = "label";
}
public static String DATABASE_NAME = "barometer.db";
@@ -112,8 +112,7 @@ private Barometer_Data() {
+ Barometer_Data.TIMESTAMP + " real default 0,"
+ Barometer_Data.DEVICE_ID + " text default '',"
+ Barometer_Data.AMBIENT_PRESSURE + " real default 0,"
- + Barometer_Data.ACCURACY + " integer default 0,"
- + Barometer_Data.LABEL + " text default ''"};
+ + Barometer_Data.ACCURACY + " integer default 0"};
private UriMatcher sUriMatcher = null;
private HashMap sensorMap = null;
@@ -123,8 +122,10 @@ private Barometer_Data() {
private static SQLiteDatabase database;
private void initialiseDatabase() {
- if (dbHelper == null)
+ if (dbHelper == null) {
dbHelper = new DatabaseHelper(getContext(), DATABASE_NAME, null, DATABASE_VERSION, DATABASE_TABLES, TABLES_FIELDS);
+ dbHelper.setMetadataOnlyTrailingColumnDrops("label");
+ }
if (database == null)
database = dbHelper.getWritableDatabase();
}
@@ -137,27 +138,26 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs
initialiseDatabase();
- database.beginTransaction();
-
- int count;
- switch (sUriMatcher.match(uri)) {
- case SENSOR_DEV:
- count = database.delete(DATABASE_TABLES[0], selection, selectionArgs);
- break;
- case SENSOR_DATA:
- count = database.delete(DATABASE_TABLES[1], selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case SENSOR_DEV:
+ count = database.delete(DATABASE_TABLES[0], selection, selectionArgs);
+ break;
+ case SENSOR_DATA:
+ count = database.delete(DATABASE_TABLES[1], selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
@Override
@@ -186,38 +186,35 @@ public synchronized Uri insert(Uri uri, ContentValues initialValues) {
ContentValues values = (initialValues != null) ? new ContentValues(initialValues) : new ContentValues();
- database.beginTransaction();
-
- switch (sUriMatcher.match(uri)) {
- case SENSOR_DEV:
- long accel_id = database.insertWithOnConflict(DATABASE_TABLES[0],
- Barometer_Sensor.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- if (accel_id > 0) {
- Uri accelUri = ContentUris.withAppendedId(
- Barometer_Sensor.CONTENT_URI, accel_id);
- getContext().getContentResolver().notifyChange(accelUri, null, false);
- database.setTransactionSuccessful();
- database.endTransaction();
- return accelUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- case SENSOR_DATA:
- long accelData_id = database.insertWithOnConflict(DATABASE_TABLES[1],
- Barometer_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- if (accelData_id > 0) {
- Uri accelDataUri = ContentUris.withAppendedId(
- Barometer_Data.CONTENT_URI, accelData_id);
- getContext().getContentResolver().notifyChange(accelDataUri,null, false);
- database.setTransactionSuccessful();
- database.endTransaction();
- return accelDataUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- default:
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ switch (sUriMatcher.match(uri)) {
+ case SENSOR_DEV:
+ long accel_id = database.insertWithOnConflict(DATABASE_TABLES[0],
+ Barometer_Sensor.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ if (accel_id > 0) {
+ Uri accelUri = ContentUris.withAppendedId(
+ Barometer_Sensor.CONTENT_URI, accel_id);
+ transaction.commit();
+ getContext().getContentResolver().notifyChange(accelUri, null, false);
+ return accelUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ case SENSOR_DATA:
+ long accelData_id = database.insertWithOnConflict(DATABASE_TABLES[1],
+ Barometer_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ if (accelData_id > 0) {
+ Uri accelDataUri = ContentUris.withAppendedId(
+ Barometer_Data.CONTENT_URI, accelData_id);
+ transaction.commit();
+ getContext().getContentResolver().notifyChange(accelDataUri,null, false);
+ return accelDataUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ default:
- throw new IllegalArgumentException("Unknown URI " + uri);
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
}
}
@@ -233,51 +230,50 @@ public synchronized int bulkInsert(Uri uri, ContentValues[] values) {
initialiseDatabase();
- database.beginTransaction();
-
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case SENSOR_DEV:
- for (ContentValues v : values) {
- long id;
- try {
- id = database.insertOrThrow(DATABASE_TABLES[0], Barometer_Sensor.DEVICE_ID, v);
- } catch (SQLException e) {
- id = database.replace(DATABASE_TABLES[0], Barometer_Sensor.DEVICE_ID, v);
- }
- if (id <= 0) {
- Log.w(Barometer.TAG, "Failed to insert/replace row into " + uri);
- } else {
- count++;
- }
- }
- break;
- case SENSOR_DATA:
- for (ContentValues v : values) {
- long id;
- try {
- id = database.insertOrThrow(DATABASE_TABLES[1], Barometer_Data.DEVICE_ID, v);
- } catch (SQLException e) {
- id = database.replace(DATABASE_TABLES[1], Barometer_Data.DEVICE_ID, v);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case SENSOR_DEV:
+ for (ContentValues v : values) {
+ long id;
+ try {
+ id = database.insertOrThrow(DATABASE_TABLES[0], Barometer_Sensor.DEVICE_ID, v);
+ } catch (SQLException e) {
+ id = database.replace(DATABASE_TABLES[0], Barometer_Sensor.DEVICE_ID, v);
+ }
+ if (id <= 0) {
+ Log.w(Barometer.TAG, "Failed to insert/replace row into " + uri);
+ } else {
+ count++;
+ }
}
- if (id <= 0) {
- Log.w(Barometer.TAG, "Failed to insert/replace row into " + uri);
- } else {
- count++;
+ break;
+ case SENSOR_DATA:
+ for (ContentValues v : values) {
+ long id;
+ try {
+ id = database.insertOrThrow(DATABASE_TABLES[1], Barometer_Data.DEVICE_ID, v);
+ } catch (SQLException e) {
+ id = database.replace(DATABASE_TABLES[1], Barometer_Data.DEVICE_ID, v);
+ }
+ if (id <= 0) {
+ Log.w(Barometer.TAG, "Failed to insert/replace row into " + uri);
+ } else {
+ count++;
+ }
}
- }
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
- }
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
- database.setTransactionSuccessful();
- database.endTransaction();
+ transaction.commit();
- getContext().getContentResolver().notifyChange(uri, null, false);
+ getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
+ return count;
+ }
}
/**
@@ -325,7 +321,6 @@ public boolean onCreate() {
sensorDataMap.put(Barometer_Data.AMBIENT_PRESSURE,
Barometer_Data.AMBIENT_PRESSURE);
sensorDataMap.put(Barometer_Data.ACCURACY, Barometer_Data.ACCURACY);
- sensorDataMap.put(Barometer_Data.LABEL, Barometer_Data.LABEL);
return true;
}
@@ -363,7 +358,7 @@ public Cursor query(Uri uri, String[] projection, String selection,
if (Aware.DEBUG)
Log.e(Aware.TAG, e.getMessage());
- return null;
+ throw e;
}
}
@@ -376,27 +371,26 @@ public synchronized int update(Uri uri, ContentValues values, String selection,
initialiseDatabase();
- database.beginTransaction();
-
- int count;
- switch (sUriMatcher.match(uri)) {
- case SENSOR_DEV:
- count = database.update(DATABASE_TABLES[0], values, selection,
- selectionArgs);
- break;
- case SENSOR_DATA:
- count = database.update(DATABASE_TABLES[1], values, selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case SENSOR_DEV:
+ count = database.update(DATABASE_TABLES[0], values, selection,
+ selectionArgs);
+ break;
+ case SENSOR_DATA:
+ count = database.update(DATABASE_TABLES[1], values, selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
-}
\ No newline at end of file
+}
diff --git a/aware-core/src/main/java/com/aware/providers/Battery_Provider.java b/aware-core/src/main/java/com/aware/providers/Battery_Provider.java
index 53b3405e..ed65d94f 100644
--- a/aware-core/src/main/java/com/aware/providers/Battery_Provider.java
+++ b/aware-core/src/main/java/com/aware/providers/Battery_Provider.java
@@ -16,6 +16,7 @@
import com.aware.Aware;
import com.aware.utils.DatabaseHelper;
+import com.aware.utils.DatabaseTransaction;
import java.util.HashMap;
@@ -158,32 +159,31 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs
initialiseDatabase();
//lock database for transaction
- database.beginTransaction();
-
- int count;
- switch (sUriMatcher.match(uri)) {
- case BATTERY:
- count = database.delete(DATABASE_TABLES[0], selection,
- selectionArgs);
- break;
- case BATTERY_DISCHARGE:
- count = database.delete(DATABASE_TABLES[1], selection,
- selectionArgs);
- break;
- case BATTERY_CHARGE:
- count = database.delete(DATABASE_TABLES[2], selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case BATTERY:
+ count = database.delete(DATABASE_TABLES[0], selection,
+ selectionArgs);
+ break;
+ case BATTERY_DISCHARGE:
+ count = database.delete(DATABASE_TABLES[1], selection,
+ selectionArgs);
+ break;
+ case BATTERY_CHARGE:
+ count = database.delete(DATABASE_TABLES[2], selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
@Override
@@ -216,48 +216,42 @@ public synchronized Uri insert(Uri uri, ContentValues initialValues) {
ContentValues values = (initialValues != null) ? new ContentValues(initialValues) : new ContentValues();
- database.beginTransaction();
-
- switch (sUriMatcher.match(uri)) {
- case BATTERY:
- long battery_id = database.insertWithOnConflict(DATABASE_TABLES[0], Battery_Data.TECHNOLOGY, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (battery_id > 0) {
- Uri batteryUri = ContentUris.withAppendedId(Battery_Data.CONTENT_URI, battery_id);
- getContext().getContentResolver().notifyChange(batteryUri, null, false);
- return batteryUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- case BATTERY_DISCHARGE:
- long battery_d_id = database.insertWithOnConflict(DATABASE_TABLES[1], Battery_Discharges.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (battery_d_id > 0) {
- Uri batteryUri = ContentUris.withAppendedId(
- Battery_Discharges.CONTENT_URI, battery_d_id);
- getContext().getContentResolver().notifyChange(batteryUri, null, false);
- return batteryUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- case BATTERY_CHARGE:
- long battery_c_id = database.insertWithOnConflict(DATABASE_TABLES[2],
- Battery_Charges.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (battery_c_id > 0) {
- Uri batteryUri = ContentUris.withAppendedId(
- Battery_Charges.CONTENT_URI, battery_c_id);
- getContext().getContentResolver().notifyChange(batteryUri, null, false);
- return batteryUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ switch (sUriMatcher.match(uri)) {
+ case BATTERY:
+ long battery_id = database.insertWithOnConflict(DATABASE_TABLES[0], Battery_Data.TECHNOLOGY, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (battery_id > 0) {
+ Uri batteryUri = ContentUris.withAppendedId(Battery_Data.CONTENT_URI, battery_id);
+ getContext().getContentResolver().notifyChange(batteryUri, null, false);
+ return batteryUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ case BATTERY_DISCHARGE:
+ long battery_d_id = database.insertWithOnConflict(DATABASE_TABLES[1], Battery_Discharges.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (battery_d_id > 0) {
+ Uri batteryUri = ContentUris.withAppendedId(
+ Battery_Discharges.CONTENT_URI, battery_d_id);
+ getContext().getContentResolver().notifyChange(batteryUri, null, false);
+ return batteryUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ case BATTERY_CHARGE:
+ long battery_c_id = database.insertWithOnConflict(DATABASE_TABLES[2],
+ Battery_Charges.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (battery_c_id > 0) {
+ Uri batteryUri = ContentUris.withAppendedId(
+ Battery_Charges.CONTENT_URI, battery_c_id);
+ getContext().getContentResolver().notifyChange(batteryUri, null, false);
+ return batteryUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
}
}
@@ -372,7 +366,7 @@ public Cursor query(Uri uri, String[] projection, String selection,
} catch (IllegalStateException e) {
if (Aware.DEBUG)
Log.e(Aware.TAG, e.getMessage());
- return null;
+ throw e;
}
}
@@ -385,31 +379,30 @@ public synchronized int update(Uri uri, ContentValues values, String selection,
initialiseDatabase();
- database.beginTransaction();
-
- int count;
- switch (sUriMatcher.match(uri)) {
- case BATTERY:
- count = database.update(DATABASE_TABLES[0], values, selection,
- selectionArgs);
- break;
- case BATTERY_DISCHARGE:
- count = database.update(DATABASE_TABLES[1], values, selection,
- selectionArgs);
- break;
- case BATTERY_CHARGE:
- count = database.update(DATABASE_TABLES[2], values, selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case BATTERY:
+ count = database.update(DATABASE_TABLES[0], values, selection,
+ selectionArgs);
+ break;
+ case BATTERY_DISCHARGE:
+ count = database.update(DATABASE_TABLES[1], values, selection,
+ selectionArgs);
+ break;
+ case BATTERY_CHARGE:
+ count = database.update(DATABASE_TABLES[2], values, selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
}
diff --git a/aware-core/src/main/java/com/aware/providers/Bluetooth_Provider.java b/aware-core/src/main/java/com/aware/providers/Bluetooth_Provider.java
index 6e40ee56..401e43ae 100644
--- a/aware-core/src/main/java/com/aware/providers/Bluetooth_Provider.java
+++ b/aware-core/src/main/java/com/aware/providers/Bluetooth_Provider.java
@@ -16,6 +16,7 @@
import com.aware.Aware;
import com.aware.utils.DatabaseHelper;
+import com.aware.utils.DatabaseTransaction;
import java.util.HashMap;
@@ -131,28 +132,27 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs
initialiseDatabase();
//lock database for transaction
- database.beginTransaction();
-
- int count;
- switch (sUriMatcher.match(uri)) {
- case BT_DEV:
- count = database.delete(DATABASE_TABLES[0], selection,
- selectionArgs);
- break;
- case BT_DATA:
- count = database.delete(DATABASE_TABLES[1], selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case BT_DEV:
+ count = database.delete(DATABASE_TABLES[0], selection,
+ selectionArgs);
+ break;
+ case BT_DATA:
+ count = database.delete(DATABASE_TABLES[1], selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
@Override
@@ -181,38 +181,34 @@ public synchronized Uri insert(Uri uri, ContentValues initialValues) {
ContentValues values = (initialValues != null) ? new ContentValues(initialValues) : new ContentValues();
- database.beginTransaction();
-
- switch (sUriMatcher.match(uri)) {
- case BT_DEV:
- long rowId = database.insertWithOnConflict(DATABASE_TABLES[0],
- Bluetooth_Sensor.BT_NAME, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (rowId > 0) {
- Uri bluetoothUri = ContentUris.withAppendedId(
- Bluetooth_Sensor.CONTENT_URI, rowId);
- getContext().getContentResolver().notifyChange(bluetoothUri,null,false);
- return bluetoothUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- case BT_DATA:
- long btId = database.insertWithOnConflict(DATABASE_TABLES[1],
- Bluetooth_Data.BT_NAME, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (btId > 0) {
- Uri bluetoothUri = ContentUris.withAppendedId(
- Bluetooth_Data.CONTENT_URI, btId);
- getContext().getContentResolver().notifyChange(bluetoothUri,null,false);
- return bluetoothUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ switch (sUriMatcher.match(uri)) {
+ case BT_DEV:
+ long rowId = database.insertWithOnConflict(DATABASE_TABLES[0],
+ Bluetooth_Sensor.BT_NAME, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (rowId > 0) {
+ Uri bluetoothUri = ContentUris.withAppendedId(
+ Bluetooth_Sensor.CONTENT_URI, rowId);
+ getContext().getContentResolver().notifyChange(bluetoothUri,null,false);
+ return bluetoothUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ case BT_DATA:
+ long btId = database.insertWithOnConflict(DATABASE_TABLES[1],
+ Bluetooth_Data.BT_NAME, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (btId > 0) {
+ Uri bluetoothUri = ContentUris.withAppendedId(
+ Bluetooth_Data.CONTENT_URI, btId);
+ getContext().getContentResolver().notifyChange(bluetoothUri,null,false);
+ return bluetoothUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
}
}
@@ -299,7 +295,7 @@ public Cursor query(Uri uri, String[] projection, String selection,
if (Aware.DEBUG)
Log.e(Aware.TAG, e.getMessage());
- return null;
+ throw e;
}
}
@@ -312,27 +308,26 @@ public synchronized int update(Uri uri, ContentValues values, String selection,
initialiseDatabase();
- database.beginTransaction();
-
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case BT_DEV:
- count = database.update(DATABASE_TABLES[0], values, selection,
- selectionArgs);
- break;
- case BT_DATA:
- count = database.update(DATABASE_TABLES[1], values, selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case BT_DEV:
+ count = database.update(DATABASE_TABLES[0], values, selection,
+ selectionArgs);
+ break;
+ case BT_DATA:
+ count = database.update(DATABASE_TABLES[1], values, selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
}
\ No newline at end of file
diff --git a/aware-core/src/main/java/com/aware/providers/Communication_Provider.java b/aware-core/src/main/java/com/aware/providers/Communication_Provider.java
index 80a44d47..7b643362 100644
--- a/aware-core/src/main/java/com/aware/providers/Communication_Provider.java
+++ b/aware-core/src/main/java/com/aware/providers/Communication_Provider.java
@@ -16,6 +16,7 @@
import com.aware.Aware;
import com.aware.utils.DatabaseHelper;
+import com.aware.utils.DatabaseTransaction;
import java.util.HashMap;
@@ -126,28 +127,27 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs
initialiseDatabase();
//lock database for transaction
- database.beginTransaction();
-
- int count;
- switch (sUriMatcher.match(uri)) {
- case CALLS:
- count = database.delete(DATABASE_TABLES[0], selection,
- selectionArgs);
- break;
- case MESSAGES:
- count = database.delete(DATABASE_TABLES[1], selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case CALLS:
+ count = database.delete(DATABASE_TABLES[0], selection,
+ selectionArgs);
+ break;
+ case MESSAGES:
+ count = database.delete(DATABASE_TABLES[1], selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
@Override
@@ -176,38 +176,34 @@ public synchronized Uri insert(Uri uri, ContentValues initialValues) {
ContentValues values = (initialValues != null) ? new ContentValues(initialValues) : new ContentValues();
- database.beginTransaction();
-
- switch (sUriMatcher.match(uri)) {
- case CALLS:
- long call_id = database.insertWithOnConflict(DATABASE_TABLES[0],
- Calls_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (call_id > 0) {
- Uri callsUri = ContentUris.withAppendedId(
- Calls_Data.CONTENT_URI, call_id);
- getContext().getContentResolver().notifyChange(callsUri, null, false);
- return callsUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- case MESSAGES:
- long message_id = database.insertWithOnConflict(DATABASE_TABLES[1],
- Messages_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (message_id > 0) {
- Uri messagesUri = ContentUris.withAppendedId(
- Messages_Data.CONTENT_URI, message_id);
- getContext().getContentResolver().notifyChange(messagesUri, null, false);
- return messagesUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ switch (sUriMatcher.match(uri)) {
+ case CALLS:
+ long call_id = database.insertWithOnConflict(DATABASE_TABLES[0],
+ Calls_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (call_id > 0) {
+ Uri callsUri = ContentUris.withAppendedId(
+ Calls_Data.CONTENT_URI, call_id);
+ getContext().getContentResolver().notifyChange(callsUri, null, false);
+ return callsUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ case MESSAGES:
+ long message_id = database.insertWithOnConflict(DATABASE_TABLES[1],
+ Messages_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (message_id > 0) {
+ Uri messagesUri = ContentUris.withAppendedId(
+ Messages_Data.CONTENT_URI, message_id);
+ getContext().getContentResolver().notifyChange(messagesUri, null, false);
+ return messagesUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
}
}
@@ -287,7 +283,7 @@ public Cursor query(Uri uri, String[] projection, String selection,
if (Aware.DEBUG)
Log.e(Aware.TAG, e.getMessage());
- return null;
+ throw e;
}
}
@@ -300,27 +296,26 @@ public synchronized int update(Uri uri, ContentValues values, String selection,
initialiseDatabase();
- database.beginTransaction();
-
- int count;
- switch (sUriMatcher.match(uri)) {
- case CALLS:
- count = database.update(DATABASE_TABLES[0], values, selection,
- selectionArgs);
- break;
- case MESSAGES:
- count = database.update(DATABASE_TABLES[1], values, selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case CALLS:
+ count = database.update(DATABASE_TABLES[0], values, selection,
+ selectionArgs);
+ break;
+ case MESSAGES:
+ count = database.update(DATABASE_TABLES[1], values, selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
}
diff --git a/aware-core/src/main/java/com/aware/providers/ESM_Provider.java b/aware-core/src/main/java/com/aware/providers/ESM_Provider.java
index 8f7120f5..b7d5b2c7 100644
--- a/aware-core/src/main/java/com/aware/providers/ESM_Provider.java
+++ b/aware-core/src/main/java/com/aware/providers/ESM_Provider.java
@@ -16,6 +16,7 @@
import com.aware.Aware;
import com.aware.utils.DatabaseHelper;
+import com.aware.utils.DatabaseTransaction;
import java.util.HashMap;
@@ -100,24 +101,23 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs
initialiseDatabase();
//lock database for transaction
- database.beginTransaction();
-
- int count;
- switch (sUriMatcher.match(uri)) {
- case ESMS_QUEUE:
- count = database.delete(DATABASE_TABLES[0], selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case ESMS_QUEUE:
+ count = database.delete(DATABASE_TABLES[0], selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
@Override
@@ -142,25 +142,23 @@ public synchronized Uri insert(Uri uri, ContentValues initialValues) {
ContentValues values = (initialValues != null) ? new ContentValues(initialValues) : new ContentValues();
- database.beginTransaction();
-
- switch (sUriMatcher.match(uri)) {
- case ESMS_QUEUE:
- long quest_id = database.insertWithOnConflict(DATABASE_TABLES[0],
- ESM_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (quest_id > 0) {
- Uri questUri = ContentUris.withAppendedId(ESM_Data.CONTENT_URI,
- quest_id);
- getContext().getContentResolver().notifyChange(questUri, null, false);
- return questUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ switch (sUriMatcher.match(uri)) {
+ case ESMS_QUEUE:
+ long quest_id = database.insertWithOnConflict(DATABASE_TABLES[0],
+ ESM_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (quest_id > 0) {
+ Uri questUri = ContentUris.withAppendedId(ESM_Data.CONTENT_URI,
+ quest_id);
+ getContext().getContentResolver().notifyChange(questUri, null, false);
+ return questUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
}
}
@@ -225,7 +223,7 @@ public Cursor query(Uri uri, String[] projection, String selection,
if (Aware.DEBUG)
Log.e(Aware.TAG, e.getMessage());
- return null;
+ throw e;
}
}
@@ -238,23 +236,22 @@ public synchronized int update(Uri uri, ContentValues values, String selection,
initialiseDatabase();
- database.beginTransaction();
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
- int count;
- switch (sUriMatcher.match(uri)) {
- case ESMS_QUEUE:
- count = database.update(DATABASE_TABLES[0], values, selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
- }
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case ESMS_QUEUE:
+ count = database.update(DATABASE_TABLES[0], values, selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
- database.setTransactionSuccessful();
- database.endTransaction();
+ transaction.commit();
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
+ }
}
}
\ No newline at end of file
diff --git a/aware-core/src/main/java/com/aware/providers/Gravity_Provider.java b/aware-core/src/main/java/com/aware/providers/Gravity_Provider.java
index 4aa8a763..ef8f5008 100644
--- a/aware-core/src/main/java/com/aware/providers/Gravity_Provider.java
+++ b/aware-core/src/main/java/com/aware/providers/Gravity_Provider.java
@@ -17,6 +17,7 @@
import com.aware.Aware;
import com.aware.Barometer;
import com.aware.utils.DatabaseHelper;
+import com.aware.utils.DatabaseTransaction;
import java.util.HashMap;
@@ -28,7 +29,7 @@
*/
public class Gravity_Provider extends ContentProvider {
- private static final int DATABASE_VERSION = 3;
+ private static final int DATABASE_VERSION = 4;
/**
* Authority of content provider
@@ -89,7 +90,6 @@ private Gravity_Data() {
public static final String VALUES_1 = "double_values_1";
public static final String VALUES_2 = "double_values_2";
public static final String ACCURACY = "accuracy";
- public static final String LABEL = "label";
}
public static String DATABASE_NAME = "gravity.db";
@@ -117,8 +117,7 @@ private Gravity_Data() {
+ Gravity_Data.VALUES_0 + " real default 0,"
+ Gravity_Data.VALUES_1 + " real default 0,"
+ Gravity_Data.VALUES_2 + " real default 0,"
- + Gravity_Data.ACCURACY + " integer default 0,"
- + Gravity_Data.LABEL + " text default ''"};
+ + Gravity_Data.ACCURACY + " integer default 0"};
private UriMatcher sUriMatcher = null;
private HashMap sensorDeviceMap = null;
@@ -128,8 +127,10 @@ private Gravity_Data() {
private static SQLiteDatabase database;
private void initialiseDatabase() {
- if (dbHelper == null)
+ if (dbHelper == null) {
dbHelper = new DatabaseHelper(getContext(), DATABASE_NAME, null, DATABASE_VERSION, DATABASE_TABLES, TABLES_FIELDS);
+ dbHelper.setMetadataOnlyTrailingColumnDrops("label");
+ }
if (database == null)
database = dbHelper.getWritableDatabase();
}
@@ -142,28 +143,27 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs
initialiseDatabase();
//lock database for transaction
- database.beginTransaction();
-
- int count;
- switch (sUriMatcher.match(uri)) {
- case SENSOR_DEV:
- count = database.delete(DATABASE_TABLES[0], selection,
- selectionArgs);
- break;
- case SENSOR_DATA:
- count = database.delete(DATABASE_TABLES[1], selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case SENSOR_DEV:
+ count = database.delete(DATABASE_TABLES[0], selection,
+ selectionArgs);
+ break;
+ case SENSOR_DATA:
+ count = database.delete(DATABASE_TABLES[1], selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
@Override
@@ -191,38 +191,34 @@ public synchronized Uri insert(Uri uri, ContentValues initialValues) {
ContentValues values = (initialValues != null) ? new ContentValues(initialValues) : new ContentValues();
- database.beginTransaction();
-
- switch (sUriMatcher.match(uri)) {
- case SENSOR_DEV:
- long accel_id = database.insertWithOnConflict(DATABASE_TABLES[0],
- Gravity_Sensor.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (accel_id > 0) {
- Uri accelUri = ContentUris.withAppendedId(
- Gravity_Sensor.CONTENT_URI, accel_id);
- getContext().getContentResolver().notifyChange(accelUri, null, false);
- return accelUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- case SENSOR_DATA:
- long accelData_id = database.insertWithOnConflict(DATABASE_TABLES[1],
- Gravity_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (accelData_id > 0) {
- Uri accelDataUri = ContentUris.withAppendedId(
- Gravity_Data.CONTENT_URI, accelData_id);
- getContext().getContentResolver().notifyChange(accelDataUri, null, false);
- return accelDataUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ switch (sUriMatcher.match(uri)) {
+ case SENSOR_DEV:
+ long accel_id = database.insertWithOnConflict(DATABASE_TABLES[0],
+ Gravity_Sensor.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (accel_id > 0) {
+ Uri accelUri = ContentUris.withAppendedId(
+ Gravity_Sensor.CONTENT_URI, accel_id);
+ getContext().getContentResolver().notifyChange(accelUri, null, false);
+ return accelUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ case SENSOR_DATA:
+ long accelData_id = database.insertWithOnConflict(DATABASE_TABLES[1],
+ Gravity_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (accelData_id > 0) {
+ Uri accelDataUri = ContentUris.withAppendedId(
+ Gravity_Data.CONTENT_URI, accelData_id);
+ getContext().getContentResolver().notifyChange(accelDataUri, null, false);
+ return accelDataUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
}
}
@@ -237,51 +233,50 @@ public synchronized Uri insert(Uri uri, ContentValues initialValues) {
public synchronized int bulkInsert(Uri uri, ContentValues[] values) {
initialiseDatabase();
- database.beginTransaction();
-
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case SENSOR_DEV:
- for (ContentValues v : values) {
- long id;
- try {
- id = database.insertOrThrow(DATABASE_TABLES[0], Gravity_Sensor.DEVICE_ID, v);
- } catch (SQLException e) {
- id = database.replace(DATABASE_TABLES[0], Gravity_Sensor.DEVICE_ID, v);
- }
- if (id <= 0) {
- Log.w(Barometer.TAG, "Failed to insert/replace row into " + uri);
- } else {
- count++;
- }
- }
- break;
- case SENSOR_DATA:
- for (ContentValues v : values) {
- long id;
- try {
- id = database.insertOrThrow(DATABASE_TABLES[1], Gravity_Data.DEVICE_ID, v);
- } catch (SQLException e) {
- id = database.replace(DATABASE_TABLES[1], Gravity_Data.DEVICE_ID, v);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case SENSOR_DEV:
+ for (ContentValues v : values) {
+ long id;
+ try {
+ id = database.insertOrThrow(DATABASE_TABLES[0], Gravity_Sensor.DEVICE_ID, v);
+ } catch (SQLException e) {
+ id = database.replace(DATABASE_TABLES[0], Gravity_Sensor.DEVICE_ID, v);
+ }
+ if (id <= 0) {
+ Log.w(Barometer.TAG, "Failed to insert/replace row into " + uri);
+ } else {
+ count++;
+ }
}
- if (id <= 0) {
- Log.w(Barometer.TAG, "Failed to insert/replace row into " + uri);
- } else {
- count++;
+ break;
+ case SENSOR_DATA:
+ for (ContentValues v : values) {
+ long id;
+ try {
+ id = database.insertOrThrow(DATABASE_TABLES[1], Gravity_Data.DEVICE_ID, v);
+ } catch (SQLException e) {
+ id = database.replace(DATABASE_TABLES[1], Gravity_Data.DEVICE_ID, v);
+ }
+ if (id <= 0) {
+ Log.w(Barometer.TAG, "Failed to insert/replace row into " + uri);
+ } else {
+ count++;
+ }
}
- }
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
- }
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
- database.setTransactionSuccessful();
- database.endTransaction();
+ transaction.commit();
- getContext().getContentResolver().notifyChange(uri, null, false);
+ getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
+ return count;
+ }
}
/**
@@ -331,7 +326,6 @@ public boolean onCreate() {
sensorDataMap.put(Gravity_Data.VALUES_1, Gravity_Data.VALUES_1);
sensorDataMap.put(Gravity_Data.VALUES_2, Gravity_Data.VALUES_2);
sensorDataMap.put(Gravity_Data.ACCURACY, Gravity_Data.ACCURACY);
- sensorDataMap.put(Gravity_Data.LABEL, Gravity_Data.LABEL);
return true;
}
@@ -368,7 +362,7 @@ public Cursor query(Uri uri, String[] projection, String selection,
if (Aware.DEBUG)
Log.e(Aware.TAG, e.getMessage());
- return null;
+ throw e;
}
}
@@ -381,27 +375,26 @@ public synchronized int update(Uri uri, ContentValues values, String selection,
initialiseDatabase();
- database.beginTransaction();
-
- int count;
- switch (sUriMatcher.match(uri)) {
- case SENSOR_DEV:
- count = database.update(DATABASE_TABLES[0], values, selection,
- selectionArgs);
- break;
- case SENSOR_DATA:
- count = database.update(DATABASE_TABLES[1], values, selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case SENSOR_DEV:
+ count = database.update(DATABASE_TABLES[0], values, selection,
+ selectionArgs);
+ break;
+ case SENSOR_DATA:
+ count = database.update(DATABASE_TABLES[1], values, selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
-}
\ No newline at end of file
+}
diff --git a/aware-core/src/main/java/com/aware/providers/Gyroscope_Provider.java b/aware-core/src/main/java/com/aware/providers/Gyroscope_Provider.java
index f9bfc11b..1b9513c1 100644
--- a/aware-core/src/main/java/com/aware/providers/Gyroscope_Provider.java
+++ b/aware-core/src/main/java/com/aware/providers/Gyroscope_Provider.java
@@ -17,6 +17,7 @@
import com.aware.Aware;
import com.aware.Barometer;
import com.aware.utils.DatabaseHelper;
+import com.aware.utils.DatabaseTransaction;
import java.util.HashMap;
@@ -29,7 +30,7 @@
*/
public class Gyroscope_Provider extends ContentProvider {
- public static final int DATABASE_VERSION = 4;
+ public static final int DATABASE_VERSION = 5;
/**
* Authority of Gyroscope content provider
@@ -85,7 +86,6 @@ private Gyroscope_Data() {
public static final String VALUES_1 = "double_values_1";
public static final String VALUES_2 = "double_values_2";
public static final String ACCURACY = "accuracy";
- public static final String LABEL = "label";
}
public static String DATABASE_NAME = "gyroscope.db";
@@ -113,8 +113,7 @@ private Gyroscope_Data() {
+ Gyroscope_Data.VALUES_0 + " real default 0,"
+ Gyroscope_Data.VALUES_1 + " real default 0,"
+ Gyroscope_Data.VALUES_2 + " real default 0,"
- + Gyroscope_Data.ACCURACY + " integer default 0,"
- + Gyroscope_Data.LABEL + " text default ''"};
+ + Gyroscope_Data.ACCURACY + " integer default 0"};
private static UriMatcher sUriMatcher = null;
private static HashMap gyroDeviceMap = null;
@@ -124,8 +123,10 @@ private Gyroscope_Data() {
private static SQLiteDatabase database;
private void initialiseDatabase() {
- if (dbHelper == null)
+ if (dbHelper == null) {
dbHelper = new DatabaseHelper(getContext(), DATABASE_NAME, null, DATABASE_VERSION, DATABASE_TABLES, TABLES_FIELDS);
+ dbHelper.setMetadataOnlyTrailingColumnDrops("label");
+ }
if (database == null)
database = dbHelper.getWritableDatabase();
}
@@ -139,29 +140,28 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs
initialiseDatabase();
//lock database for transaction
- database.beginTransaction();
-
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case GYRO_DEV:
- count = database.delete(DATABASE_TABLES[0], selection,
- selectionArgs);
- break;
- case GYRO_DATA:
- count = database.delete(DATABASE_TABLES[1], selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case GYRO_DEV:
+ count = database.delete(DATABASE_TABLES[0], selection,
+ selectionArgs);
+ break;
+ case GYRO_DATA:
+ count = database.delete(DATABASE_TABLES[1], selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
-
- return count;
}
@Override
@@ -190,38 +190,34 @@ public synchronized Uri insert(Uri uri, ContentValues initialValues) {
ContentValues values = (initialValues != null) ? new ContentValues(initialValues) : new ContentValues();
- database.beginTransaction();
-
- switch (sUriMatcher.match(uri)) {
- case GYRO_DEV:
- long gyro_id = database.insertWithOnConflict(DATABASE_TABLES[0],
- Gyroscope_Sensor.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (gyro_id > 0) {
- Uri gyroUri = ContentUris.withAppendedId(
- Gyroscope_Sensor.CONTENT_URI, gyro_id);
- getContext().getContentResolver().notifyChange(gyroUri, null, false);
- return gyroUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- case GYRO_DATA:
- long gyroData_id = database.insertWithOnConflict(DATABASE_TABLES[1],
- Gyroscope_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (gyroData_id > 0) {
- Uri gyroDataUri = ContentUris.withAppendedId(
- Gyroscope_Data.CONTENT_URI, gyroData_id);
- getContext().getContentResolver().notifyChange(gyroDataUri,null, false);
- return gyroDataUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ switch (sUriMatcher.match(uri)) {
+ case GYRO_DEV:
+ long gyro_id = database.insertWithOnConflict(DATABASE_TABLES[0],
+ Gyroscope_Sensor.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (gyro_id > 0) {
+ Uri gyroUri = ContentUris.withAppendedId(
+ Gyroscope_Sensor.CONTENT_URI, gyro_id);
+ getContext().getContentResolver().notifyChange(gyroUri, null, false);
+ return gyroUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ case GYRO_DATA:
+ long gyroData_id = database.insertWithOnConflict(DATABASE_TABLES[1],
+ Gyroscope_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (gyroData_id > 0) {
+ Uri gyroDataUri = ContentUris.withAppendedId(
+ Gyroscope_Data.CONTENT_URI, gyroData_id);
+ getContext().getContentResolver().notifyChange(gyroDataUri,null, false);
+ return gyroDataUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
}
}
@@ -237,51 +233,50 @@ public synchronized int bulkInsert(Uri uri, ContentValues[] values) {
initialiseDatabase();
- database.beginTransaction();
-
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case GYRO_DEV:
- for (ContentValues v : values) {
- long id;
- try {
- id = database.insertOrThrow(DATABASE_TABLES[0], Gyroscope_Sensor.DEVICE_ID, v);
- } catch (SQLException e) {
- id = database.replace(DATABASE_TABLES[0], Gyroscope_Sensor.DEVICE_ID, v);
- }
- if (id <= 0) {
- Log.w(Barometer.TAG, "Failed to insert/replace row into " + uri);
- } else {
- count++;
- }
- }
- break;
- case GYRO_DATA:
- for (ContentValues v : values) {
- long id;
- try {
- id = database.insertOrThrow(DATABASE_TABLES[1], Gyroscope_Data.DEVICE_ID, v);
- } catch (SQLException e) {
- id = database.replace(DATABASE_TABLES[1], Gyroscope_Data.DEVICE_ID, v);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case GYRO_DEV:
+ for (ContentValues v : values) {
+ long id;
+ try {
+ id = database.insertOrThrow(DATABASE_TABLES[0], Gyroscope_Sensor.DEVICE_ID, v);
+ } catch (SQLException e) {
+ id = database.replace(DATABASE_TABLES[0], Gyroscope_Sensor.DEVICE_ID, v);
+ }
+ if (id <= 0) {
+ Log.w(Barometer.TAG, "Failed to insert/replace row into " + uri);
+ } else {
+ count++;
+ }
}
- if (id <= 0) {
- Log.w(Barometer.TAG, "Failed to insert/replace row into " + uri);
- } else {
- count++;
+ break;
+ case GYRO_DATA:
+ for (ContentValues v : values) {
+ long id;
+ try {
+ id = database.insertOrThrow(DATABASE_TABLES[1], Gyroscope_Data.DEVICE_ID, v);
+ } catch (SQLException e) {
+ id = database.replace(DATABASE_TABLES[1], Gyroscope_Data.DEVICE_ID, v);
+ }
+ if (id <= 0) {
+ Log.w(Barometer.TAG, "Failed to insert/replace row into " + uri);
+ } else {
+ count++;
+ }
}
- }
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
- }
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
- database.setTransactionSuccessful();
- database.endTransaction();
+ transaction.commit();
- getContext().getContentResolver().notifyChange(uri, null, false);
+ getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
+ return count;
+ }
}
/**
@@ -333,7 +328,6 @@ public boolean onCreate() {
gyroDataMap.put(Gyroscope_Data.VALUES_1, Gyroscope_Data.VALUES_1);
gyroDataMap.put(Gyroscope_Data.VALUES_2, Gyroscope_Data.VALUES_2);
gyroDataMap.put(Gyroscope_Data.ACCURACY, Gyroscope_Data.ACCURACY);
- gyroDataMap.put(Gyroscope_Data.LABEL, Gyroscope_Data.LABEL);
return true;
}
@@ -371,7 +365,7 @@ public Cursor query(Uri uri, String[] projection, String selection,
if (Aware.DEBUG)
Log.e(Aware.TAG, e.getMessage());
- return null;
+ throw e;
}
}
@@ -384,27 +378,26 @@ public synchronized int update(Uri uri, ContentValues values, String selection,
initialiseDatabase();
- database.beginTransaction();
-
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case GYRO_DEV:
- count = database.update(DATABASE_TABLES[0], values, selection,
- selectionArgs);
- break;
- case GYRO_DATA:
- count = database.update(DATABASE_TABLES[1], values, selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case GYRO_DEV:
+ count = database.update(DATABASE_TABLES[0], values, selection,
+ selectionArgs);
+ break;
+ case GYRO_DATA:
+ count = database.update(DATABASE_TABLES[1], values, selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
-}
\ No newline at end of file
+}
diff --git a/aware-core/src/main/java/com/aware/providers/Installations_Provider.java b/aware-core/src/main/java/com/aware/providers/Installations_Provider.java
index 9e662f73..60de2945 100644
--- a/aware-core/src/main/java/com/aware/providers/Installations_Provider.java
+++ b/aware-core/src/main/java/com/aware/providers/Installations_Provider.java
@@ -16,6 +16,7 @@
import com.aware.Aware;
import com.aware.utils.DatabaseHelper;
+import com.aware.utils.DatabaseTransaction;
import java.util.HashMap;
@@ -101,24 +102,23 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs
initialiseDatabase();
//lock database for transaction
- database.beginTransaction();
-
- int count;
- switch (sUriMatcher.match(uri)) {
- case INSTALLATIONS:
- count = database.delete(DATABASE_TABLES[0], selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case INSTALLATIONS:
+ count = database.delete(DATABASE_TABLES[0], selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
@Override
@@ -143,26 +143,24 @@ public synchronized Uri insert(Uri uri, ContentValues initialValues) {
ContentValues values = (initialValues != null) ? new ContentValues(initialValues) : new ContentValues();
- database.beginTransaction();
-
-
- switch (sUriMatcher.match(uri)) {
- case INSTALLATIONS:
- long installations_id = database.insertWithOnConflict(DATABASE_TABLES[0],
- Installations_Data.PACKAGE_NAME, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (installations_id > 0) {
- Uri installationsUri = ContentUris.withAppendedId(
- Installations_Data.CONTENT_URI, installations_id);
- getContext().getContentResolver().notifyChange(installationsUri, null, false);
- return installationsUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+
+ switch (sUriMatcher.match(uri)) {
+ case INSTALLATIONS:
+ long installations_id = database.insertWithOnConflict(DATABASE_TABLES[0],
+ Installations_Data.PACKAGE_NAME, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (installations_id > 0) {
+ Uri installationsUri = ContentUris.withAppendedId(
+ Installations_Data.CONTENT_URI, installations_id);
+ getContext().getContentResolver().notifyChange(installationsUri, null, false);
+ return installationsUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
}
}
@@ -225,7 +223,7 @@ public Cursor query(Uri uri, String[] projection, String selection,
} catch (IllegalStateException e) {
if (Aware.DEBUG)
Log.e(Aware.TAG, e.getMessage());
- return null;
+ throw e;
}
}
@@ -238,23 +236,22 @@ public synchronized int update(Uri uri, ContentValues values, String selection,
initialiseDatabase();
- database.beginTransaction();
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
- int count;
- switch (sUriMatcher.match(uri)) {
- case INSTALLATIONS:
- count = database.update(DATABASE_TABLES[0], values, selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
- }
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case INSTALLATIONS:
+ count = database.update(DATABASE_TABLES[0], values, selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
- database.setTransactionSuccessful();
- database.endTransaction();
+ transaction.commit();
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
+ }
}
}
\ No newline at end of file
diff --git a/aware-core/src/main/java/com/aware/providers/Keyboard_Provider.java b/aware-core/src/main/java/com/aware/providers/Keyboard_Provider.java
index d2a01318..a256125d 100644
--- a/aware-core/src/main/java/com/aware/providers/Keyboard_Provider.java
+++ b/aware-core/src/main/java/com/aware/providers/Keyboard_Provider.java
@@ -15,6 +15,7 @@
import com.aware.Aware;
import com.aware.utils.DatabaseHelper;
+import com.aware.utils.DatabaseTransaction;
import java.util.HashMap;
@@ -90,25 +91,24 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs
initialiseDatabase();
//lock database for transaction
- database.beginTransaction();
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
- int count;
- switch (sUriMatcher.match(uri)) {
- case KEYBOARD:
- count = database.delete(DATABASE_TABLES[0], selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
- }
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case KEYBOARD:
+ count = database.delete(DATABASE_TABLES[0], selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
- database.setTransactionSuccessful();
- database.endTransaction();
+ transaction.commit();
- getContext().getContentResolver().notifyChange(uri, null, false);
+ getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
+ return count;
+ }
}
@Override
@@ -133,25 +133,23 @@ public synchronized Uri insert(Uri uri, ContentValues initialValues) {
ContentValues values = (initialValues != null) ? new ContentValues(initialValues) : new ContentValues();
- database.beginTransaction();
-
- switch (sUriMatcher.match(uri)) {
- case KEYBOARD:
- long keyboard_id = database.insertWithOnConflict(DATABASE_TABLES[0],
- Keyboard_Data.PACKAGE_NAME, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (keyboard_id > 0) {
- Uri installationsUri = ContentUris.withAppendedId(
- Keyboard_Data.CONTENT_URI, keyboard_id);
- getContext().getContentResolver().notifyChange(installationsUri, null, false);
- return installationsUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ switch (sUriMatcher.match(uri)) {
+ case KEYBOARD:
+ long keyboard_id = database.insertWithOnConflict(DATABASE_TABLES[0],
+ Keyboard_Data.PACKAGE_NAME, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (keyboard_id > 0) {
+ Uri installationsUri = ContentUris.withAppendedId(
+ Keyboard_Data.CONTENT_URI, keyboard_id);
+ getContext().getContentResolver().notifyChange(installationsUri, null, false);
+ return installationsUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
}
}
@@ -219,7 +217,7 @@ public Cursor query(Uri uri, String[] projection, String selection,
} catch (IllegalStateException e) {
if (Aware.DEBUG)
Log.e(Aware.TAG, e.getMessage());
- return null;
+ throw e;
}
}
@@ -232,22 +230,21 @@ public synchronized int update(Uri uri, ContentValues values, String selection,
initialiseDatabase();
- database.beginTransaction();
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
- int count;
- switch (sUriMatcher.match(uri)) {
- case KEYBOARD:
- count = database.update(DATABASE_TABLES[0], values, selection, selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
- }
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case KEYBOARD:
+ count = database.update(DATABASE_TABLES[0], values, selection, selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
- database.setTransactionSuccessful();
- database.endTransaction();
+ transaction.commit();
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
+ }
}
}
diff --git a/aware-core/src/main/java/com/aware/providers/Light_Provider.java b/aware-core/src/main/java/com/aware/providers/Light_Provider.java
index a94c236a..48b4714f 100644
--- a/aware-core/src/main/java/com/aware/providers/Light_Provider.java
+++ b/aware-core/src/main/java/com/aware/providers/Light_Provider.java
@@ -17,6 +17,7 @@
import com.aware.Aware;
import com.aware.Barometer;
import com.aware.utils.DatabaseHelper;
+import com.aware.utils.DatabaseTransaction;
import java.util.HashMap;
@@ -28,7 +29,7 @@
*/
public class Light_Provider extends ContentProvider {
- public static final int DATABASE_VERSION = 3;
+ public static final int DATABASE_VERSION = 4;
/**
* Authority of content provider
@@ -87,7 +88,6 @@ private Light_Data() {
public static final String DEVICE_ID = "device_id";
public static final String LIGHT_LUX = "double_light_lux";
public static final String ACCURACY = "accuracy";
- public static final String LABEL = "label";
}
public static String DATABASE_NAME = "light.db";
@@ -111,8 +111,7 @@ private Light_Data() {
+ Light_Data.TIMESTAMP + " real default 0,"
+ Light_Data.DEVICE_ID + " text default '',"
+ Light_Data.LIGHT_LUX + " real default 0,"
- + Light_Data.ACCURACY + " integer default 0,"
- + Light_Data.LABEL + " text default ''"};
+ + Light_Data.ACCURACY + " integer default 0"};
private UriMatcher sUriMatcher = null;
private HashMap sensorMap = null;
@@ -122,8 +121,10 @@ private Light_Data() {
private static SQLiteDatabase database;
private void initialiseDatabase() {
- if (dbHelper == null)
+ if (dbHelper == null) {
dbHelper = new DatabaseHelper(getContext(), DATABASE_NAME, null, DATABASE_VERSION, DATABASE_TABLES, TABLES_FIELDS);
+ dbHelper.setMetadataOnlyTrailingColumnDrops("label");
+ }
if (database == null)
database = dbHelper.getWritableDatabase();
}
@@ -137,28 +138,27 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs
initialiseDatabase();
//lock database for transaction
- database.beginTransaction();
-
- int count;
- switch (sUriMatcher.match(uri)) {
- case SENSOR_DEV:
- count = database.delete(DATABASE_TABLES[0], selection,
- selectionArgs);
- break;
- case SENSOR_DATA:
- count = database.delete(DATABASE_TABLES[1], selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case SENSOR_DEV:
+ count = database.delete(DATABASE_TABLES[0], selection,
+ selectionArgs);
+ break;
+ case SENSOR_DATA:
+ count = database.delete(DATABASE_TABLES[1], selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
@Override
@@ -187,38 +187,34 @@ public synchronized Uri insert(Uri uri, ContentValues initialValues) {
ContentValues values = (initialValues != null) ? new ContentValues(initialValues) : new ContentValues();
- database.beginTransaction();
-
- switch (sUriMatcher.match(uri)) {
- case SENSOR_DEV:
- long accel_id = database.insertWithOnConflict(DATABASE_TABLES[0],
- Light_Sensor.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (accel_id > 0) {
- Uri accelUri = ContentUris.withAppendedId(
- Light_Sensor.CONTENT_URI, accel_id);
- getContext().getContentResolver().notifyChange(accelUri, null, false);
- return accelUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- case SENSOR_DATA:
- long accelData_id = database.insertWithOnConflict(DATABASE_TABLES[1],
- Light_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (accelData_id > 0) {
- Uri accelDataUri = ContentUris.withAppendedId(
- Light_Data.CONTENT_URI, accelData_id);
- getContext().getContentResolver().notifyChange(accelDataUri,null, false);
- return accelDataUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ switch (sUriMatcher.match(uri)) {
+ case SENSOR_DEV:
+ long accel_id = database.insertWithOnConflict(DATABASE_TABLES[0],
+ Light_Sensor.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (accel_id > 0) {
+ Uri accelUri = ContentUris.withAppendedId(
+ Light_Sensor.CONTENT_URI, accel_id);
+ getContext().getContentResolver().notifyChange(accelUri, null, false);
+ return accelUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ case SENSOR_DATA:
+ long accelData_id = database.insertWithOnConflict(DATABASE_TABLES[1],
+ Light_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (accelData_id > 0) {
+ Uri accelDataUri = ContentUris.withAppendedId(
+ Light_Data.CONTENT_URI, accelData_id);
+ getContext().getContentResolver().notifyChange(accelDataUri,null, false);
+ return accelDataUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
}
}
@@ -234,51 +230,50 @@ public synchronized int bulkInsert(Uri uri, ContentValues[] values) {
initialiseDatabase();
- database.beginTransaction();
-
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case SENSOR_DEV:
- for (ContentValues v : values) {
- long id;
- try {
- id = database.insertOrThrow(DATABASE_TABLES[0], Light_Sensor.DEVICE_ID, v);
- } catch (SQLException e) {
- id = database.replace(DATABASE_TABLES[0], Light_Sensor.DEVICE_ID, v);
- }
- if (id <= 0) {
- Log.w(Barometer.TAG, "Failed to insert/replace row into " + uri);
- } else {
- count++;
- }
- }
- break;
- case SENSOR_DATA:
- for (ContentValues v : values) {
- long id;
- try {
- id = database.insertOrThrow(DATABASE_TABLES[1], Light_Data.DEVICE_ID, v);
- } catch (SQLException e) {
- id = database.replace(DATABASE_TABLES[1], Light_Data.DEVICE_ID, v);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case SENSOR_DEV:
+ for (ContentValues v : values) {
+ long id;
+ try {
+ id = database.insertOrThrow(DATABASE_TABLES[0], Light_Sensor.DEVICE_ID, v);
+ } catch (SQLException e) {
+ id = database.replace(DATABASE_TABLES[0], Light_Sensor.DEVICE_ID, v);
+ }
+ if (id <= 0) {
+ Log.w(Barometer.TAG, "Failed to insert/replace row into " + uri);
+ } else {
+ count++;
+ }
}
- if (id <= 0) {
- Log.w(Barometer.TAG, "Failed to insert/replace row into " + uri);
- } else {
- count++;
+ break;
+ case SENSOR_DATA:
+ for (ContentValues v : values) {
+ long id;
+ try {
+ id = database.insertOrThrow(DATABASE_TABLES[1], Light_Data.DEVICE_ID, v);
+ } catch (SQLException e) {
+ id = database.replace(DATABASE_TABLES[1], Light_Data.DEVICE_ID, v);
+ }
+ if (id <= 0) {
+ Log.w(Barometer.TAG, "Failed to insert/replace row into " + uri);
+ } else {
+ count++;
+ }
}
- }
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
- }
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
- database.setTransactionSuccessful();
- database.endTransaction();
+ transaction.commit();
- getContext().getContentResolver().notifyChange(uri, null, false);
+ getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
+ return count;
+ }
}
/**
@@ -323,7 +318,6 @@ public boolean onCreate() {
sensorDataMap.put(Light_Data.DEVICE_ID, Light_Data.DEVICE_ID);
sensorDataMap.put(Light_Data.LIGHT_LUX, Light_Data.LIGHT_LUX);
sensorDataMap.put(Light_Data.ACCURACY, Light_Data.ACCURACY);
- sensorDataMap.put(Light_Data.LABEL, Light_Data.LABEL);
return true;
}
@@ -361,7 +355,7 @@ public Cursor query(Uri uri, String[] projection, String selection,
if (Aware.DEBUG)
Log.e(Aware.TAG, e.getMessage());
- return null;
+ throw e;
}
}
@@ -374,27 +368,26 @@ public synchronized int update(Uri uri, ContentValues values, String selection,
initialiseDatabase();
- database.beginTransaction();
-
- int count;
- switch (sUriMatcher.match(uri)) {
- case SENSOR_DEV:
- count = database.update(DATABASE_TABLES[0], values, selection,
- selectionArgs);
- break;
- case SENSOR_DATA:
- count = database.update(DATABASE_TABLES[1], values, selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case SENSOR_DEV:
+ count = database.update(DATABASE_TABLES[0], values, selection,
+ selectionArgs);
+ break;
+ case SENSOR_DATA:
+ count = database.update(DATABASE_TABLES[1], values, selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
-}
\ No newline at end of file
+}
diff --git a/aware-core/src/main/java/com/aware/providers/Linear_Accelerometer_Provider.java b/aware-core/src/main/java/com/aware/providers/Linear_Accelerometer_Provider.java
index 6fcb4843..60043874 100644
--- a/aware-core/src/main/java/com/aware/providers/Linear_Accelerometer_Provider.java
+++ b/aware-core/src/main/java/com/aware/providers/Linear_Accelerometer_Provider.java
@@ -17,6 +17,7 @@
import com.aware.Accelerometer;
import com.aware.Aware;
import com.aware.utils.DatabaseHelper;
+import com.aware.utils.DatabaseTransaction;
import java.util.HashMap;
@@ -28,7 +29,7 @@
*/
public class Linear_Accelerometer_Provider extends ContentProvider {
- public static final int DATABASE_VERSION = 3;
+ public static final int DATABASE_VERSION = 4;
/**
* Authority of content provider
@@ -92,7 +93,6 @@ private Linear_Accelerometer_Data() {
public static final String VALUES_1 = "double_values_1";
public static final String VALUES_2 = "double_values_2";
public static final String ACCURACY = "accuracy";
- public static final String LABEL = "label";
}
public static String DATABASE_NAME = "linear_accelerometer.db";
@@ -121,8 +121,7 @@ private Linear_Accelerometer_Data() {
+ Linear_Accelerometer_Data.VALUES_0 + " real default 0,"
+ Linear_Accelerometer_Data.VALUES_1 + " real default 0,"
+ Linear_Accelerometer_Data.VALUES_2 + " real default 0,"
- + Linear_Accelerometer_Data.ACCURACY + " integer default 0,"
- + Linear_Accelerometer_Data.LABEL + " text default ''"};
+ + Linear_Accelerometer_Data.ACCURACY + " integer default 0"};
private UriMatcher sUriMatcher = null;
private HashMap accelDeviceMap = null;
@@ -132,8 +131,10 @@ private Linear_Accelerometer_Data() {
private static SQLiteDatabase database;
private void initialiseDatabase() {
- if (dbHelper == null)
+ if (dbHelper == null) {
dbHelper = new DatabaseHelper(getContext(), DATABASE_NAME, null, DATABASE_VERSION, DATABASE_TABLES, TABLES_FIELDS);
+ dbHelper.setMetadataOnlyTrailingColumnDrops("label");
+ }
if (database == null)
database = dbHelper.getWritableDatabase();
}
@@ -147,28 +148,27 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs
initialiseDatabase();
//lock database for transaction
- database.beginTransaction();
-
- int count;
- switch (sUriMatcher.match(uri)) {
- case ACCEL_DEV:
- count = database.delete(DATABASE_TABLES[0], selection,
- selectionArgs);
- break;
- case ACCEL_DATA:
- count = database.delete(DATABASE_TABLES[1], selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case ACCEL_DEV:
+ count = database.delete(DATABASE_TABLES[0], selection,
+ selectionArgs);
+ break;
+ case ACCEL_DATA:
+ count = database.delete(DATABASE_TABLES[1], selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
@Override
@@ -197,37 +197,33 @@ public synchronized Uri insert(Uri uri, ContentValues initialValues) {
ContentValues values = (initialValues != null) ? new ContentValues(initialValues) : new ContentValues();
- database.beginTransaction();
-
- switch (sUriMatcher.match(uri)) {
- case ACCEL_DEV:
- long accel_id = database.insertWithOnConflict(DATABASE_TABLES[0], Linear_Accelerometer_Sensor.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (accel_id > 0) {
- Uri accelUri = ContentUris.withAppendedId(
- Linear_Accelerometer_Sensor.CONTENT_URI, accel_id);
- getContext().getContentResolver().notifyChange(accelUri, null, false);
- return accelUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- case ACCEL_DATA:
- long accelData_id = database.insertWithOnConflict(DATABASE_TABLES[1],
- Linear_Accelerometer_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (accelData_id > 0) {
- Uri accelDataUri = ContentUris.withAppendedId(
- Linear_Accelerometer_Data.CONTENT_URI, accelData_id);
- getContext().getContentResolver().notifyChange(accelDataUri, null, false);
- return accelDataUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ switch (sUriMatcher.match(uri)) {
+ case ACCEL_DEV:
+ long accel_id = database.insertWithOnConflict(DATABASE_TABLES[0], Linear_Accelerometer_Sensor.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (accel_id > 0) {
+ Uri accelUri = ContentUris.withAppendedId(
+ Linear_Accelerometer_Sensor.CONTENT_URI, accel_id);
+ getContext().getContentResolver().notifyChange(accelUri, null, false);
+ return accelUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ case ACCEL_DATA:
+ long accelData_id = database.insertWithOnConflict(DATABASE_TABLES[1],
+ Linear_Accelerometer_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (accelData_id > 0) {
+ Uri accelDataUri = ContentUris.withAppendedId(
+ Linear_Accelerometer_Data.CONTENT_URI, accelData_id);
+ getContext().getContentResolver().notifyChange(accelDataUri, null, false);
+ return accelDataUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
}
}
@@ -243,51 +239,50 @@ public synchronized int bulkInsert(Uri uri, ContentValues[] values) {
initialiseDatabase();
- database.beginTransaction();
-
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case ACCEL_DEV:
- for (ContentValues v : values) {
- long id;
- try {
- id = database.insertOrThrow(DATABASE_TABLES[0], Linear_Accelerometer_Sensor.DEVICE_ID, v);
- } catch (SQLException e) {
- id = database.replace(DATABASE_TABLES[0], Linear_Accelerometer_Sensor.DEVICE_ID, v);
- }
- if (id <= 0) {
- Log.w(Accelerometer.TAG, "Failed to insert/replace row into " + uri);
- } else {
- count++;
- }
- }
- break;
- case ACCEL_DATA:
- for (ContentValues v : values) {
- long id;
- try {
- id = database.insertOrThrow(DATABASE_TABLES[1], Linear_Accelerometer_Data.DEVICE_ID, v);
- } catch (SQLException e) {
- id = database.replace(DATABASE_TABLES[1], Linear_Accelerometer_Data.DEVICE_ID, v);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case ACCEL_DEV:
+ for (ContentValues v : values) {
+ long id;
+ try {
+ id = database.insertOrThrow(DATABASE_TABLES[0], Linear_Accelerometer_Sensor.DEVICE_ID, v);
+ } catch (SQLException e) {
+ id = database.replace(DATABASE_TABLES[0], Linear_Accelerometer_Sensor.DEVICE_ID, v);
+ }
+ if (id <= 0) {
+ Log.w(Accelerometer.TAG, "Failed to insert/replace row into " + uri);
+ } else {
+ count++;
+ }
}
- if (id <= 0) {
- Log.w(Accelerometer.TAG, "Failed to insert/replace row into " + uri);
- } else {
- count++;
+ break;
+ case ACCEL_DATA:
+ for (ContentValues v : values) {
+ long id;
+ try {
+ id = database.insertOrThrow(DATABASE_TABLES[1], Linear_Accelerometer_Data.DEVICE_ID, v);
+ } catch (SQLException e) {
+ id = database.replace(DATABASE_TABLES[1], Linear_Accelerometer_Data.DEVICE_ID, v);
+ }
+ if (id <= 0) {
+ Log.w(Accelerometer.TAG, "Failed to insert/replace row into " + uri);
+ } else {
+ count++;
+ }
}
- }
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
- }
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
- database.setTransactionSuccessful();
- database.endTransaction();
+ transaction.commit();
- getContext().getContentResolver().notifyChange(uri, null, false);
+ getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
+ return count;
+ }
}
/**
@@ -352,8 +347,6 @@ public boolean onCreate() {
Linear_Accelerometer_Data.VALUES_2);
accelDataMap.put(Linear_Accelerometer_Data.ACCURACY,
Linear_Accelerometer_Data.ACCURACY);
- accelDataMap.put(Linear_Accelerometer_Data.LABEL,
- Linear_Accelerometer_Data.LABEL);
return true;
}
@@ -391,7 +384,7 @@ public Cursor query(Uri uri, String[] projection, String selection,
if (Aware.DEBUG)
Log.e(Aware.TAG, e.getMessage());
- return null;
+ throw e;
}
}
@@ -404,27 +397,26 @@ public synchronized int update(Uri uri, ContentValues values, String selection,
initialiseDatabase();
- database.beginTransaction();
-
- int count;
- switch (sUriMatcher.match(uri)) {
- case ACCEL_DEV:
- count = database.update(DATABASE_TABLES[0], values, selection,
- selectionArgs);
- break;
- case ACCEL_DATA:
- count = database.update(DATABASE_TABLES[1], values, selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case ACCEL_DEV:
+ count = database.update(DATABASE_TABLES[0], values, selection,
+ selectionArgs);
+ break;
+ case ACCEL_DATA:
+ count = database.update(DATABASE_TABLES[1], values, selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
}
diff --git a/aware-core/src/main/java/com/aware/providers/Locations_Provider.java b/aware-core/src/main/java/com/aware/providers/Locations_Provider.java
index cc152bd0..a99da712 100644
--- a/aware-core/src/main/java/com/aware/providers/Locations_Provider.java
+++ b/aware-core/src/main/java/com/aware/providers/Locations_Provider.java
@@ -16,6 +16,7 @@
import com.aware.Aware;
import com.aware.utils.DatabaseHelper;
+import com.aware.utils.DatabaseTransaction;
import java.util.HashMap;
@@ -105,24 +106,23 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs
initialiseDatabase();
//lock database for transaction
- database.beginTransaction();
-
- int count;
- switch (sUriMatcher.match(uri)) {
- case LOCATIONS:
- count = database.delete(DATABASE_TABLES[0], selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case LOCATIONS:
+ count = database.delete(DATABASE_TABLES[0], selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
@Override
@@ -147,25 +147,23 @@ public synchronized Uri insert(Uri uri, ContentValues initialValues) {
ContentValues values = (initialValues != null) ? new ContentValues(initialValues) : new ContentValues();
- database.beginTransaction();
-
- switch (sUriMatcher.match(uri)) {
- case LOCATIONS:
- long location_id = database.insertWithOnConflict(DATABASE_TABLES[0],
- Locations_Data.PROVIDER, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (location_id > 0) {
- Uri locationUri = ContentUris.withAppendedId(
- Locations_Data.CONTENT_URI, location_id);
- getContext().getContentResolver().notifyChange(locationUri, null, false);
- return locationUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ switch (sUriMatcher.match(uri)) {
+ case LOCATIONS:
+ long location_id = database.insertWithOnConflict(DATABASE_TABLES[0],
+ Locations_Data.PROVIDER, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (location_id > 0) {
+ Uri locationUri = ContentUris.withAppendedId(
+ Locations_Data.CONTENT_URI, location_id);
+ getContext().getContentResolver().notifyChange(locationUri, null, false);
+ return locationUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
}
}
@@ -239,7 +237,7 @@ public Cursor query(Uri uri, String[] projection, String selection,
if (Aware.DEBUG)
Log.e(Aware.TAG, e.getMessage());
- return null;
+ throw e;
}
}
@@ -252,23 +250,22 @@ public synchronized int update(Uri uri, ContentValues values, String selection,
initialiseDatabase();
- database.beginTransaction();
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
- int count;
- switch (sUriMatcher.match(uri)) {
- case LOCATIONS:
- count = database.update(DATABASE_TABLES[0], values, selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
- }
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case LOCATIONS:
+ count = database.update(DATABASE_TABLES[0], values, selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
- database.setTransactionSuccessful();
- database.endTransaction();
+ transaction.commit();
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
+ }
}
}
\ No newline at end of file
diff --git a/aware-core/src/main/java/com/aware/providers/Magnetometer_Provider.java b/aware-core/src/main/java/com/aware/providers/Magnetometer_Provider.java
index 5dea279a..be34b07b 100644
--- a/aware-core/src/main/java/com/aware/providers/Magnetometer_Provider.java
+++ b/aware-core/src/main/java/com/aware/providers/Magnetometer_Provider.java
@@ -17,6 +17,7 @@
import com.aware.Accelerometer;
import com.aware.Aware;
import com.aware.utils.DatabaseHelper;
+import com.aware.utils.DatabaseTransaction;
import java.util.HashMap;
@@ -29,7 +30,7 @@
*/
public class Magnetometer_Provider extends ContentProvider {
- public static final int DATABASE_VERSION = 3;
+ public static final int DATABASE_VERSION = 4;
/**
* Authority of content provider
@@ -90,7 +91,6 @@ private Magnetometer_Data() {
public static final String VALUES_1 = "double_values_1";
public static final String VALUES_2 = "double_values_2";
public static final String ACCURACY = "accuracy";
- public static final String LABEL = "label";
}
public static String DATABASE_NAME = "magnetometer.db";
@@ -118,8 +118,7 @@ private Magnetometer_Data() {
+ Magnetometer_Data.VALUES_0 + " real default 0,"
+ Magnetometer_Data.VALUES_1 + " real default 0,"
+ Magnetometer_Data.VALUES_2 + " real default 0,"
- + Magnetometer_Data.ACCURACY + " integer default 0,"
- + Magnetometer_Data.LABEL + " text default ''"};
+ + Magnetometer_Data.ACCURACY + " integer default 0"};
private UriMatcher sUriMatcher = null;
private HashMap sensorDeviceMap = null;
@@ -129,8 +128,10 @@ private Magnetometer_Data() {
private static SQLiteDatabase database;
private void initialiseDatabase() {
- if (dbHelper == null)
+ if (dbHelper == null) {
dbHelper = new DatabaseHelper(getContext(), DATABASE_NAME, null, DATABASE_VERSION, DATABASE_TABLES, TABLES_FIELDS);
+ dbHelper.setMetadataOnlyTrailingColumnDrops("label");
+ }
if (database == null)
database = dbHelper.getWritableDatabase();
}
@@ -144,28 +145,27 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs
initialiseDatabase();
//lock database for transaction
- database.beginTransaction();
-
- int count;
- switch (sUriMatcher.match(uri)) {
- case SENSOR_DEV:
- count = database.delete(DATABASE_TABLES[0], selection,
- selectionArgs);
- break;
- case SENSOR_DATA:
- count = database.delete(DATABASE_TABLES[1], selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case SENSOR_DEV:
+ count = database.delete(DATABASE_TABLES[0], selection,
+ selectionArgs);
+ break;
+ case SENSOR_DATA:
+ count = database.delete(DATABASE_TABLES[1], selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
@Override
@@ -194,38 +194,34 @@ public synchronized Uri insert(Uri uri, ContentValues initialValues) {
ContentValues values = (initialValues != null) ? new ContentValues(initialValues) : new ContentValues();
- database.beginTransaction();
-
- switch (sUriMatcher.match(uri)) {
- case SENSOR_DEV:
- long accel_id = database.insertWithOnConflict(DATABASE_TABLES[0],
- Magnetometer_Sensor.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (accel_id > 0) {
- Uri accelUri = ContentUris.withAppendedId(
- Magnetometer_Sensor.CONTENT_URI, accel_id);
- getContext().getContentResolver().notifyChange(accelUri, null, false);
- return accelUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- case SENSOR_DATA:
- long accelData_id = database.insertWithOnConflict(DATABASE_TABLES[1],
- Magnetometer_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (accelData_id > 0) {
- Uri accelDataUri = ContentUris.withAppendedId(
- Magnetometer_Data.CONTENT_URI, accelData_id);
- getContext().getContentResolver().notifyChange(accelDataUri,null, false);
- return accelDataUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ switch (sUriMatcher.match(uri)) {
+ case SENSOR_DEV:
+ long accel_id = database.insertWithOnConflict(DATABASE_TABLES[0],
+ Magnetometer_Sensor.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (accel_id > 0) {
+ Uri accelUri = ContentUris.withAppendedId(
+ Magnetometer_Sensor.CONTENT_URI, accel_id);
+ getContext().getContentResolver().notifyChange(accelUri, null, false);
+ return accelUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ case SENSOR_DATA:
+ long accelData_id = database.insertWithOnConflict(DATABASE_TABLES[1],
+ Magnetometer_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (accelData_id > 0) {
+ Uri accelDataUri = ContentUris.withAppendedId(
+ Magnetometer_Data.CONTENT_URI, accelData_id);
+ getContext().getContentResolver().notifyChange(accelDataUri,null, false);
+ return accelDataUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
}
}
@@ -241,51 +237,50 @@ public synchronized int bulkInsert(Uri uri, ContentValues[] values) {
initialiseDatabase();
- database.beginTransaction();
-
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case SENSOR_DEV:
- for (ContentValues v : values) {
- long id;
- try {
- id = database.insertOrThrow(DATABASE_TABLES[0], Magnetometer_Sensor.DEVICE_ID, v);
- } catch (SQLException e) {
- id = database.replace(DATABASE_TABLES[0], Magnetometer_Sensor.DEVICE_ID, v);
- }
- if (id <= 0) {
- Log.w(Accelerometer.TAG, "Failed to insert/replace row into " + uri);
- } else {
- count++;
- }
- }
- break;
- case SENSOR_DATA:
- for (ContentValues v : values) {
- long id;
- try {
- id = database.insertOrThrow(DATABASE_TABLES[1], Magnetometer_Data.DEVICE_ID, v);
- } catch (SQLException e) {
- id = database.replace(DATABASE_TABLES[1], Magnetometer_Data.DEVICE_ID, v);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case SENSOR_DEV:
+ for (ContentValues v : values) {
+ long id;
+ try {
+ id = database.insertOrThrow(DATABASE_TABLES[0], Magnetometer_Sensor.DEVICE_ID, v);
+ } catch (SQLException e) {
+ id = database.replace(DATABASE_TABLES[0], Magnetometer_Sensor.DEVICE_ID, v);
+ }
+ if (id <= 0) {
+ Log.w(Accelerometer.TAG, "Failed to insert/replace row into " + uri);
+ } else {
+ count++;
+ }
}
- if (id <= 0) {
- Log.w(Accelerometer.TAG, "Failed to insert/replace row into " + uri);
- } else {
- count++;
+ break;
+ case SENSOR_DATA:
+ for (ContentValues v : values) {
+ long id;
+ try {
+ id = database.insertOrThrow(DATABASE_TABLES[1], Magnetometer_Data.DEVICE_ID, v);
+ } catch (SQLException e) {
+ id = database.replace(DATABASE_TABLES[1], Magnetometer_Data.DEVICE_ID, v);
+ }
+ if (id <= 0) {
+ Log.w(Accelerometer.TAG, "Failed to insert/replace row into " + uri);
+ } else {
+ count++;
+ }
}
- }
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
- }
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
- database.setTransactionSuccessful();
- database.endTransaction();
+ transaction.commit();
- getContext().getContentResolver().notifyChange(uri, null, false);
+ getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
+ return count;
+ }
}
/**
@@ -346,7 +341,6 @@ public boolean onCreate() {
Magnetometer_Data.VALUES_2);
sensorDataMap.put(Magnetometer_Data.ACCURACY,
Magnetometer_Data.ACCURACY);
- sensorDataMap.put(Magnetometer_Data.LABEL, Magnetometer_Data.LABEL);
return true;
}
@@ -383,7 +377,7 @@ public Cursor query(Uri uri, String[] projection, String selection,
if (Aware.DEBUG)
Log.e(Aware.TAG, e.getMessage());
- return null;
+ throw e;
}
}
@@ -396,27 +390,26 @@ public synchronized int update(Uri uri, ContentValues values, String selection,
initialiseDatabase();
- database.beginTransaction();
-
- int count;
- switch (sUriMatcher.match(uri)) {
- case SENSOR_DEV:
- count = database.update(DATABASE_TABLES[0], values, selection,
- selectionArgs);
- break;
- case SENSOR_DATA:
- count = database.update(DATABASE_TABLES[1], values, selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case SENSOR_DEV:
+ count = database.update(DATABASE_TABLES[0], values, selection,
+ selectionArgs);
+ break;
+ case SENSOR_DATA:
+ count = database.update(DATABASE_TABLES[1], values, selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
-}
\ No newline at end of file
+}
diff --git a/aware-core/src/main/java/com/aware/providers/Mqtt_Provider.java b/aware-core/src/main/java/com/aware/providers/Mqtt_Provider.java
index 958fe1bf..e6312690 100644
--- a/aware-core/src/main/java/com/aware/providers/Mqtt_Provider.java
+++ b/aware-core/src/main/java/com/aware/providers/Mqtt_Provider.java
@@ -16,6 +16,7 @@
import com.aware.Aware;
import com.aware.utils.DatabaseHelper;
+import com.aware.utils.DatabaseTransaction;
import java.util.HashMap;
@@ -115,28 +116,27 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs
initialiseDatabase();
//lock database for transaction
- database.beginTransaction();
-
- int count;
- switch (sUriMatcher.match(uri)) {
- case MQTT:
- count = database.delete(DATABASE_TABLES[0], selection,
- selectionArgs);
- break;
- case MQTT_SUBSCRIPTION:
- count = database.delete(DATABASE_TABLES[1], selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case MQTT:
+ count = database.delete(DATABASE_TABLES[0], selection,
+ selectionArgs);
+ break;
+ case MQTT_SUBSCRIPTION:
+ count = database.delete(DATABASE_TABLES[1], selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
@Override
@@ -165,39 +165,35 @@ public synchronized Uri insert(Uri uri, ContentValues initialValues) {
ContentValues values = (initialValues != null) ? new ContentValues(initialValues) : new ContentValues();
- database.beginTransaction();
-
- switch (sUriMatcher.match(uri)) {
- case MQTT:
- long mqtt_id = database.insertWithOnConflict(DATABASE_TABLES[0],
- Mqtt_Messages.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (mqtt_id > 0) {
- Uri mqttUri = ContentUris.withAppendedId(
- Mqtt_Messages.CONTENT_URI, mqtt_id);
- getContext().getContentResolver().notifyChange(mqttUri, null, false);
- return mqttUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- case MQTT_SUBSCRIPTION:
- long mqtt_sub_id = database.insertWithOnConflict(DATABASE_TABLES[1],
- Mqtt_Subscriptions.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (mqtt_sub_id > 0) {
- Uri mqttSubUri = ContentUris.withAppendedId(
- Mqtt_Subscriptions.CONTENT_URI, mqtt_sub_id);
- getContext().getContentResolver().notifyChange(mqttSubUri, null, false);
- return mqttSubUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
- }
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ switch (sUriMatcher.match(uri)) {
+ case MQTT:
+ long mqtt_id = database.insertWithOnConflict(DATABASE_TABLES[0],
+ Mqtt_Messages.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (mqtt_id > 0) {
+ Uri mqttUri = ContentUris.withAppendedId(
+ Mqtt_Messages.CONTENT_URI, mqtt_id);
+ getContext().getContentResolver().notifyChange(mqttUri, null, false);
+ return mqttUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ case MQTT_SUBSCRIPTION:
+ long mqtt_sub_id = database.insertWithOnConflict(DATABASE_TABLES[1],
+ Mqtt_Subscriptions.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (mqtt_sub_id > 0) {
+ Uri mqttSubUri = ContentUris.withAppendedId(
+ Mqtt_Subscriptions.CONTENT_URI, mqtt_sub_id);
+ getContext().getContentResolver().notifyChange(mqttSubUri, null, false);
+ return mqttSubUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+ }
}
/**
@@ -274,7 +270,7 @@ public Cursor query(Uri uri, String[] projection, String selection,
if (Aware.DEBUG)
Log.e(Aware.TAG, e.getMessage());
- return null;
+ throw e;
}
}
@@ -287,27 +283,26 @@ public synchronized int update(Uri uri, ContentValues values, String selection,
initialiseDatabase();
- database.beginTransaction();
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case MQTT:
- count = database.update(DATABASE_TABLES[0], values, selection,
- selectionArgs);
- break;
- case MQTT_SUBSCRIPTION:
- count = database.update(DATABASE_TABLES[1], values, selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
- }
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case MQTT:
+ count = database.update(DATABASE_TABLES[0], values, selection,
+ selectionArgs);
+ break;
+ case MQTT_SUBSCRIPTION:
+ count = database.update(DATABASE_TABLES[1], values, selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
+ }
}
}
\ No newline at end of file
diff --git a/aware-core/src/main/java/com/aware/providers/Network_Provider.java b/aware-core/src/main/java/com/aware/providers/Network_Provider.java
index a2fd8fba..3493392d 100644
--- a/aware-core/src/main/java/com/aware/providers/Network_Provider.java
+++ b/aware-core/src/main/java/com/aware/providers/Network_Provider.java
@@ -16,6 +16,7 @@
import com.aware.Aware;
import com.aware.utils.DatabaseHelper;
+import com.aware.utils.DatabaseTransaction;
import java.util.HashMap;
@@ -95,24 +96,23 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs
initialiseDatabase();
//lock database for transaction
- database.beginTransaction();
-
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case NETWORK:
- count = database.delete(DATABASE_TABLES[0], selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case NETWORK:
+ count = database.delete(DATABASE_TABLES[0], selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
@Override
@@ -137,25 +137,23 @@ public synchronized Uri insert(Uri uri, ContentValues initialValues) {
ContentValues values = (initialValues != null) ? new ContentValues(initialValues) : new ContentValues();
- database.beginTransaction();
-
- switch (sUriMatcher.match(uri)) {
- case NETWORK:
- long network_id = database.insertWithOnConflict(DATABASE_TABLES[0],
- Network_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (network_id > 0) {
- Uri networkUri = ContentUris.withAppendedId(
- Network_Data.CONTENT_URI, network_id);
- getContext().getContentResolver().notifyChange(networkUri, null, false);
- return networkUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ switch (sUriMatcher.match(uri)) {
+ case NETWORK:
+ long network_id = database.insertWithOnConflict(DATABASE_TABLES[0],
+ Network_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (network_id > 0) {
+ Uri networkUri = ContentUris.withAppendedId(
+ Network_Data.CONTENT_URI, network_id);
+ getContext().getContentResolver().notifyChange(networkUri, null, false);
+ return networkUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
}
}
@@ -220,7 +218,7 @@ public Cursor query(Uri uri, String[] projection, String selection,
if (Aware.DEBUG)
Log.e(Aware.TAG, e.getMessage());
- return null;
+ throw e;
}
}
@@ -233,23 +231,22 @@ public synchronized int update(Uri uri, ContentValues values, String selection,
initialiseDatabase();
- database.beginTransaction();
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case NETWORK:
- count = database.update(DATABASE_TABLES[0], values, selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
- }
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case NETWORK:
+ count = database.update(DATABASE_TABLES[0], values, selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
- database.setTransactionSuccessful();
- database.endTransaction();
+ transaction.commit();
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
+ }
}
}
\ No newline at end of file
diff --git a/aware-core/src/main/java/com/aware/providers/Notes_Provider.java b/aware-core/src/main/java/com/aware/providers/Notes_Provider.java
index d2e03d12..b1556f74 100644
--- a/aware-core/src/main/java/com/aware/providers/Notes_Provider.java
+++ b/aware-core/src/main/java/com/aware/providers/Notes_Provider.java
@@ -15,6 +15,7 @@
import com.aware.Aware;
import com.aware.utils.DatabaseHelper;
+import com.aware.utils.DatabaseTransaction;
import java.util.HashMap;
@@ -75,24 +76,23 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs
initialiseDatabase();
//lock database for transaction
- database.beginTransaction();
-
- int count;
- switch (sUriMatcher.match(uri)) {
- case NOTES:
- count = database.delete(DATABASE_TABLES[0], selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case NOTES:
+ count = database.delete(DATABASE_TABLES[0], selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
@Override
@@ -114,26 +114,24 @@ public synchronized Uri insert(Uri uri, ContentValues initialValues) {
ContentValues values = (initialValues != null) ? new ContentValues(initialValues) : new ContentValues();
- database.beginTransaction();
-
-
- switch (sUriMatcher.match(uri)) {
- case NOTES:
- long notes_id = database.insertWithOnConflict(DATABASE_TABLES[0],
- null, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (notes_id > 0) {
- Uri notesUri = ContentUris.withAppendedId(
- Notes_Data.CONTENT_URI, notes_id);
- getContext().getContentResolver().notifyChange(notesUri, null, false);
- return notesUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+
+ switch (sUriMatcher.match(uri)) {
+ case NOTES:
+ long notes_id = database.insertWithOnConflict(DATABASE_TABLES[0],
+ null, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (notes_id > 0) {
+ Uri notesUri = ContentUris.withAppendedId(
+ Notes_Data.CONTENT_URI, notes_id);
+ getContext().getContentResolver().notifyChange(notesUri, null, false);
+ return notesUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
}
}
@@ -193,7 +191,7 @@ public Cursor query(Uri uri, String[] projection, String selection,
} catch (IllegalStateException e) {
if (Aware.DEBUG)
Log.e(Aware.TAG, e.getMessage());
- return null;
+ throw e;
}
}
@@ -206,23 +204,22 @@ public synchronized int update(Uri uri, ContentValues values, String selection,
initialiseDatabase();
- database.beginTransaction();
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
- int count;
- switch (sUriMatcher.match(uri)) {
- case NOTES:
- count = database.update(DATABASE_TABLES[0], values, selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
- }
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case NOTES:
+ count = database.update(DATABASE_TABLES[0], values, selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
- database.setTransactionSuccessful();
- database.endTransaction();
+ transaction.commit();
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
+ }
}
}
diff --git a/aware-core/src/main/java/com/aware/providers/Processor_Provider.java b/aware-core/src/main/java/com/aware/providers/Processor_Provider.java
index 484ea949..61665dc3 100644
--- a/aware-core/src/main/java/com/aware/providers/Processor_Provider.java
+++ b/aware-core/src/main/java/com/aware/providers/Processor_Provider.java
@@ -16,6 +16,7 @@
import com.aware.Aware;
import com.aware.utils.DatabaseHelper;
+import com.aware.utils.DatabaseTransaction;
import java.util.HashMap;
@@ -102,24 +103,23 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs
initialiseDatabase();
//lock database for transaction
- database.beginTransaction();
-
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case PROCESSOR:
- count = database.delete(DATABASE_TABLES[0], selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case PROCESSOR:
+ count = database.delete(DATABASE_TABLES[0], selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
@Override
@@ -144,25 +144,23 @@ public synchronized Uri insert(Uri uri, ContentValues initialValues) {
ContentValues values = (initialValues != null) ? new ContentValues(initialValues) : new ContentValues();
- database.beginTransaction();
-
- switch (sUriMatcher.match(uri)) {
- case PROCESSOR:
- long processor_id = database.insertWithOnConflict(DATABASE_TABLES[0],
- Processor_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (processor_id > 0) {
- Uri processorUri = ContentUris.withAppendedId(
- Processor_Data.CONTENT_URI, processor_id);
- getContext().getContentResolver().notifyChange(processorUri,null, false);
- return processorUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ switch (sUriMatcher.match(uri)) {
+ case PROCESSOR:
+ long processor_id = database.insertWithOnConflict(DATABASE_TABLES[0],
+ Processor_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (processor_id > 0) {
+ Uri processorUri = ContentUris.withAppendedId(
+ Processor_Data.CONTENT_URI, processor_id);
+ getContext().getContentResolver().notifyChange(processorUri,null, false);
+ return processorUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
}
}
@@ -236,7 +234,7 @@ public Cursor query(Uri uri, String[] projection, String selection,
if (Aware.DEBUG)
Log.e(Aware.TAG, e.getMessage());
- return null;
+ throw e;
}
}
@@ -249,23 +247,22 @@ public synchronized int update(Uri uri, ContentValues values, String selection,
initialiseDatabase();
- database.beginTransaction();
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case PROCESSOR:
- count = database.update(DATABASE_TABLES[0], values, selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
- }
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case PROCESSOR:
+ count = database.update(DATABASE_TABLES[0], values, selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
- database.setTransactionSuccessful();
- database.endTransaction();
+ transaction.commit();
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
+ }
}
}
diff --git a/aware-core/src/main/java/com/aware/providers/Proximity_Provider.java b/aware-core/src/main/java/com/aware/providers/Proximity_Provider.java
index f1069b6f..b3ac9e95 100644
--- a/aware-core/src/main/java/com/aware/providers/Proximity_Provider.java
+++ b/aware-core/src/main/java/com/aware/providers/Proximity_Provider.java
@@ -17,6 +17,7 @@
import com.aware.Accelerometer;
import com.aware.Aware;
import com.aware.utils.DatabaseHelper;
+import com.aware.utils.DatabaseTransaction;
import java.util.HashMap;
@@ -28,7 +29,7 @@
*/
public class Proximity_Provider extends ContentProvider {
- public static final int DATABASE_VERSION = 3;
+ public static final int DATABASE_VERSION = 4;
/**
* Authority of content provider
@@ -87,7 +88,6 @@ private Proximity_Data() {
public static final String DEVICE_ID = "device_id";
public static final String PROXIMITY = "double_proximity";
public static final String ACCURACY = "accuracy";
- public static final String LABEL = "label";
}
public static String DATABASE_NAME = "proximity.db";
@@ -113,8 +113,7 @@ private Proximity_Data() {
+ Proximity_Data.TIMESTAMP + " real default 0,"
+ Proximity_Data.DEVICE_ID + " text default '',"
+ Proximity_Data.PROXIMITY + " real default 0,"
- + Proximity_Data.ACCURACY + " integer default 0,"
- + Proximity_Data.LABEL + " text default ''"};
+ + Proximity_Data.ACCURACY + " integer default 0"};
private UriMatcher sUriMatcher = null;
private HashMap sensorMap = null;
@@ -124,8 +123,10 @@ private Proximity_Data() {
private static SQLiteDatabase database;
private void initialiseDatabase() {
- if (dbHelper == null)
+ if (dbHelper == null) {
dbHelper = new DatabaseHelper(getContext(), DATABASE_NAME, null, DATABASE_VERSION, DATABASE_TABLES, TABLES_FIELDS);
+ dbHelper.setMetadataOnlyTrailingColumnDrops("label");
+ }
if (database == null)
database = dbHelper.getWritableDatabase();
}
@@ -139,28 +140,27 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs
initialiseDatabase();
//lock database for transaction
- database.beginTransaction();
-
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case SENSOR_DEV:
- count = database.delete(DATABASE_TABLES[0], selection,
- selectionArgs);
- break;
- case SENSOR_DATA:
- count = database.delete(DATABASE_TABLES[1], selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case SENSOR_DEV:
+ count = database.delete(DATABASE_TABLES[0], selection,
+ selectionArgs);
+ break;
+ case SENSOR_DATA:
+ count = database.delete(DATABASE_TABLES[1], selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
@Override
@@ -189,38 +189,34 @@ public synchronized Uri insert(Uri uri, ContentValues initialValues) {
ContentValues values = (initialValues != null) ? new ContentValues(initialValues) : new ContentValues();
- database.beginTransaction();
-
- switch (sUriMatcher.match(uri)) {
- case SENSOR_DEV:
- long accel_id = database.insertWithOnConflict(DATABASE_TABLES[0],
- Proximity_Sensor.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (accel_id > 0) {
- Uri accelUri = ContentUris.withAppendedId(
- Proximity_Sensor.CONTENT_URI, accel_id);
- getContext().getContentResolver().notifyChange(accelUri, null, false);
- return accelUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- case SENSOR_DATA:
- long accelData_id = database.insertWithOnConflict(DATABASE_TABLES[1],
- Proximity_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (accelData_id > 0) {
- Uri accelDataUri = ContentUris.withAppendedId(
- Proximity_Data.CONTENT_URI, accelData_id);
- getContext().getContentResolver().notifyChange(accelDataUri, null, false);
- return accelDataUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ switch (sUriMatcher.match(uri)) {
+ case SENSOR_DEV:
+ long accel_id = database.insertWithOnConflict(DATABASE_TABLES[0],
+ Proximity_Sensor.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (accel_id > 0) {
+ Uri accelUri = ContentUris.withAppendedId(
+ Proximity_Sensor.CONTENT_URI, accel_id);
+ getContext().getContentResolver().notifyChange(accelUri, null, false);
+ return accelUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ case SENSOR_DATA:
+ long accelData_id = database.insertWithOnConflict(DATABASE_TABLES[1],
+ Proximity_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (accelData_id > 0) {
+ Uri accelDataUri = ContentUris.withAppendedId(
+ Proximity_Data.CONTENT_URI, accelData_id);
+ getContext().getContentResolver().notifyChange(accelDataUri, null, false);
+ return accelDataUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
}
}
@@ -236,51 +232,50 @@ public synchronized int bulkInsert(Uri uri, ContentValues[] values) {
initialiseDatabase();
- database.beginTransaction();
-
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case SENSOR_DEV:
- for (ContentValues v : values) {
- long id;
- try {
- id = database.insertOrThrow(DATABASE_TABLES[0], Proximity_Sensor.DEVICE_ID, v);
- } catch (SQLException e) {
- id = database.replace(DATABASE_TABLES[0], Proximity_Sensor.DEVICE_ID, v);
- }
- if (id <= 0) {
- Log.w(Accelerometer.TAG, "Failed to insert/replace row into " + uri);
- } else {
- count++;
- }
- }
- break;
- case SENSOR_DATA:
- for (ContentValues v : values) {
- long id;
- try {
- id = database.insertOrThrow(DATABASE_TABLES[1], Proximity_Data.DEVICE_ID, v);
- } catch (SQLException e) {
- id = database.replace(DATABASE_TABLES[1], Proximity_Data.DEVICE_ID, v);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case SENSOR_DEV:
+ for (ContentValues v : values) {
+ long id;
+ try {
+ id = database.insertOrThrow(DATABASE_TABLES[0], Proximity_Sensor.DEVICE_ID, v);
+ } catch (SQLException e) {
+ id = database.replace(DATABASE_TABLES[0], Proximity_Sensor.DEVICE_ID, v);
+ }
+ if (id <= 0) {
+ Log.w(Accelerometer.TAG, "Failed to insert/replace row into " + uri);
+ } else {
+ count++;
+ }
}
- if (id <= 0) {
- Log.w(Accelerometer.TAG, "Failed to insert/replace row into " + uri);
- } else {
- count++;
+ break;
+ case SENSOR_DATA:
+ for (ContentValues v : values) {
+ long id;
+ try {
+ id = database.insertOrThrow(DATABASE_TABLES[1], Proximity_Data.DEVICE_ID, v);
+ } catch (SQLException e) {
+ id = database.replace(DATABASE_TABLES[1], Proximity_Data.DEVICE_ID, v);
+ }
+ if (id <= 0) {
+ Log.w(Accelerometer.TAG, "Failed to insert/replace row into " + uri);
+ } else {
+ count++;
+ }
}
- }
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
- }
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
- database.setTransactionSuccessful();
- database.endTransaction();
+ transaction.commit();
- getContext().getContentResolver().notifyChange(uri, null, false);
+ getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
+ return count;
+ }
}
/**
@@ -327,7 +322,6 @@ public boolean onCreate() {
sensorDataMap.put(Proximity_Data.DEVICE_ID, Proximity_Data.DEVICE_ID);
sensorDataMap.put(Proximity_Data.PROXIMITY, Proximity_Data.PROXIMITY);
sensorDataMap.put(Proximity_Data.ACCURACY, Proximity_Data.ACCURACY);
- sensorDataMap.put(Proximity_Data.LABEL, Proximity_Data.LABEL);
return true;
}
@@ -365,7 +359,7 @@ public Cursor query(Uri uri, String[] projection, String selection,
if (Aware.DEBUG)
Log.e(Aware.TAG, e.getMessage());
- return null;
+ throw e;
}
}
@@ -378,27 +372,26 @@ public synchronized int update(Uri uri, ContentValues values, String selection,
initialiseDatabase();
- database.beginTransaction();
-
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case SENSOR_DEV:
- count = database.update(DATABASE_TABLES[0], values, selection,
- selectionArgs);
- break;
- case SENSOR_DATA:
- count = database.update(DATABASE_TABLES[1], values, selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case SENSOR_DEV:
+ count = database.update(DATABASE_TABLES[0], values, selection,
+ selectionArgs);
+ break;
+ case SENSOR_DATA:
+ count = database.update(DATABASE_TABLES[1], values, selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
-}
\ No newline at end of file
+}
diff --git a/aware-core/src/main/java/com/aware/providers/Rotation_Provider.java b/aware-core/src/main/java/com/aware/providers/Rotation_Provider.java
index ce0c1acc..5772cb38 100644
--- a/aware-core/src/main/java/com/aware/providers/Rotation_Provider.java
+++ b/aware-core/src/main/java/com/aware/providers/Rotation_Provider.java
@@ -17,6 +17,7 @@
import com.aware.Accelerometer;
import com.aware.Aware;
import com.aware.utils.DatabaseHelper;
+import com.aware.utils.DatabaseTransaction;
import java.util.HashMap;
@@ -29,7 +30,7 @@
*/
public class Rotation_Provider extends ContentProvider {
- public static final int DATABASE_VERSION = 4;
+ public static final int DATABASE_VERSION = 5;
/**
* Authority of content provider
@@ -91,7 +92,6 @@ private Rotation_Data() {
public static final String VALUES_2 = "double_values_2";
public static final String VALUES_3 = "double_values_3";
public static final String ACCURACY = "accuracy";
- public static final String LABEL = "label";
}
public static String DATABASE_NAME = "rotation.db";
@@ -119,8 +119,7 @@ private Rotation_Data() {
+ Rotation_Data.VALUES_1 + " real default 0,"
+ Rotation_Data.VALUES_2 + " real default 0,"
+ Rotation_Data.VALUES_3 + " real default 0,"
- + Rotation_Data.ACCURACY + " integer default 0,"
- + Rotation_Data.LABEL + " text default ''"};
+ + Rotation_Data.ACCURACY + " integer default 0"};
private UriMatcher sUriMatcher = null;
private HashMap sensorMap = null;
@@ -130,8 +129,10 @@ private Rotation_Data() {
private static SQLiteDatabase database;
private void initialiseDatabase() {
- if (dbHelper == null)
+ if (dbHelper == null) {
dbHelper = new DatabaseHelper(getContext(), DATABASE_NAME, null, DATABASE_VERSION, DATABASE_TABLES, TABLES_FIELDS);
+ dbHelper.setMetadataOnlyTrailingColumnDrops("label");
+ }
if (database == null)
database = dbHelper.getWritableDatabase();
}
@@ -145,28 +146,27 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs
initialiseDatabase();
//lock database for transaction
- database.beginTransaction();
-
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case SENSOR_DEV:
- count = database.delete(DATABASE_TABLES[0], selection,
- selectionArgs);
- break;
- case SENSOR_DATA:
- count = database.delete(DATABASE_TABLES[1], selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case SENSOR_DEV:
+ count = database.delete(DATABASE_TABLES[0], selection,
+ selectionArgs);
+ break;
+ case SENSOR_DATA:
+ count = database.delete(DATABASE_TABLES[1], selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
@Override
@@ -195,38 +195,34 @@ public synchronized Uri insert(Uri uri, ContentValues initialValues) {
ContentValues values = (initialValues != null) ? new ContentValues(initialValues) : new ContentValues();
- database.beginTransaction();
-
- switch (sUriMatcher.match(uri)) {
- case SENSOR_DEV:
- long accel_id = database.insertWithOnConflict(DATABASE_TABLES[0],
- Rotation_Sensor.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (accel_id > 0) {
- Uri accelUri = ContentUris.withAppendedId(
- Rotation_Sensor.CONTENT_URI, accel_id);
- getContext().getContentResolver().notifyChange(accelUri, null, false);
- return accelUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- case SENSOR_DATA:
- long accelData_id = database.insertWithOnConflict(DATABASE_TABLES[1],
- Rotation_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (accelData_id > 0) {
- Uri accelDataUri = ContentUris.withAppendedId(
- Rotation_Data.CONTENT_URI, accelData_id);
- getContext().getContentResolver().notifyChange(accelDataUri,null, false);
- return accelDataUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ switch (sUriMatcher.match(uri)) {
+ case SENSOR_DEV:
+ long accel_id = database.insertWithOnConflict(DATABASE_TABLES[0],
+ Rotation_Sensor.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (accel_id > 0) {
+ Uri accelUri = ContentUris.withAppendedId(
+ Rotation_Sensor.CONTENT_URI, accel_id);
+ getContext().getContentResolver().notifyChange(accelUri, null, false);
+ return accelUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ case SENSOR_DATA:
+ long accelData_id = database.insertWithOnConflict(DATABASE_TABLES[1],
+ Rotation_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (accelData_id > 0) {
+ Uri accelDataUri = ContentUris.withAppendedId(
+ Rotation_Data.CONTENT_URI, accelData_id);
+ getContext().getContentResolver().notifyChange(accelDataUri,null, false);
+ return accelDataUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
}
}
@@ -242,51 +238,50 @@ public synchronized int bulkInsert(Uri uri, ContentValues[] values) {
initialiseDatabase();
- database.beginTransaction();
-
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case SENSOR_DEV:
- for (ContentValues v : values) {
- long id;
- try {
- id = database.insertOrThrow(DATABASE_TABLES[0], Rotation_Sensor.DEVICE_ID, v);
- } catch (SQLException e) {
- id = database.replace(DATABASE_TABLES[0], Rotation_Sensor.DEVICE_ID, v);
- }
- if (id <= 0) {
- Log.w(Accelerometer.TAG, "Failed to insert/replace row into " + uri);
- } else {
- count++;
- }
- }
- break;
- case SENSOR_DATA:
- for (ContentValues v : values) {
- long id;
- try {
- id = database.insertOrThrow(DATABASE_TABLES[1], Rotation_Data.DEVICE_ID, v);
- } catch (SQLException e) {
- id = database.replace(DATABASE_TABLES[1], Rotation_Data.DEVICE_ID, v);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case SENSOR_DEV:
+ for (ContentValues v : values) {
+ long id;
+ try {
+ id = database.insertOrThrow(DATABASE_TABLES[0], Rotation_Sensor.DEVICE_ID, v);
+ } catch (SQLException e) {
+ id = database.replace(DATABASE_TABLES[0], Rotation_Sensor.DEVICE_ID, v);
+ }
+ if (id <= 0) {
+ Log.w(Accelerometer.TAG, "Failed to insert/replace row into " + uri);
+ } else {
+ count++;
+ }
}
- if (id <= 0) {
- Log.w(Accelerometer.TAG, "Failed to insert/replace row into " + uri);
- } else {
- count++;
+ break;
+ case SENSOR_DATA:
+ for (ContentValues v : values) {
+ long id;
+ try {
+ id = database.insertOrThrow(DATABASE_TABLES[1], Rotation_Data.DEVICE_ID, v);
+ } catch (SQLException e) {
+ id = database.replace(DATABASE_TABLES[1], Rotation_Data.DEVICE_ID, v);
+ }
+ if (id <= 0) {
+ Log.w(Accelerometer.TAG, "Failed to insert/replace row into " + uri);
+ } else {
+ count++;
+ }
}
- }
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
- }
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
- database.setTransactionSuccessful();
- database.endTransaction();
+ transaction.commit();
- getContext().getContentResolver().notifyChange(uri, null,false);
+ getContext().getContentResolver().notifyChange(uri, null,false);
- return count;
+ return count;
+ }
}
/**
@@ -336,7 +331,6 @@ public boolean onCreate() {
sensorDataMap.put(Rotation_Data.VALUES_2, Rotation_Data.VALUES_2);
sensorDataMap.put(Rotation_Data.VALUES_3, Rotation_Data.VALUES_3);
sensorDataMap.put(Rotation_Data.ACCURACY, Rotation_Data.ACCURACY);
- sensorDataMap.put(Rotation_Data.LABEL, Rotation_Data.LABEL);
return true;
}
@@ -374,7 +368,7 @@ public Cursor query(Uri uri, String[] projection, String selection,
if (Aware.DEBUG)
Log.e(Aware.TAG, e.getMessage());
- return null;
+ throw e;
}
}
@@ -387,26 +381,25 @@ public synchronized int update(Uri uri, ContentValues values, String selection,
initialiseDatabase();
- database.beginTransaction();
-
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case SENSOR_DEV:
- count = database.update(DATABASE_TABLES[0], values, selection,
- selectionArgs);
- break;
- case SENSOR_DATA:
- count = database.update(DATABASE_TABLES[1], values, selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case SENSOR_DEV:
+ count = database.update(DATABASE_TABLES[0], values, selection,
+ selectionArgs);
+ break;
+ case SENSOR_DATA:
+ count = database.update(DATABASE_TABLES[1], values, selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
}
diff --git a/aware-core/src/main/java/com/aware/providers/Scheduler_Provider.java b/aware-core/src/main/java/com/aware/providers/Scheduler_Provider.java
index 7ff3f743..bf2ccdfc 100644
--- a/aware-core/src/main/java/com/aware/providers/Scheduler_Provider.java
+++ b/aware-core/src/main/java/com/aware/providers/Scheduler_Provider.java
@@ -16,6 +16,7 @@
import com.aware.Aware;
import com.aware.utils.DatabaseHelper;
+import com.aware.utils.DatabaseTransaction;
import java.util.HashMap;
@@ -85,24 +86,23 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs
initialiseDatabase();
- database.beginTransaction();
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
- int count;
- switch (sUriMatcher.match(uri)) {
- case SCHEDULER:
- count = database.delete(DATABASE_TABLES[0], selection, selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
- }
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case SCHEDULER:
+ count = database.delete(DATABASE_TABLES[0], selection, selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
- database.setTransactionSuccessful();
- database.endTransaction();
+ transaction.commit();
- getContext().getContentResolver().notifyChange(uri, null, false);
+ getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
+ return count;
+ }
}
@Override
@@ -127,23 +127,21 @@ public synchronized Uri insert(Uri uri, ContentValues initialValues) {
ContentValues values = (initialValues != null) ? new ContentValues(initialValues) : new ContentValues();
- database.beginTransaction();
-
- switch (sUriMatcher.match(uri)) {
- case SCHEDULER:
- long screen_id = database.insertWithOnConflict(DATABASE_TABLES[0], Scheduler_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- if (screen_id > 0) {
- Uri screenUri = ContentUris.withAppendedId(Scheduler_Data.CONTENT_URI, screen_id);
- getContext().getContentResolver().notifyChange(screenUri, null, false);
- database.setTransactionSuccessful();
- database.endTransaction();
- return screenUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ switch (sUriMatcher.match(uri)) {
+ case SCHEDULER:
+ long screen_id = database.insertWithOnConflict(DATABASE_TABLES[0], Scheduler_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ if (screen_id > 0) {
+ Uri screenUri = ContentUris.withAppendedId(Scheduler_Data.CONTENT_URI, screen_id);
+ transaction.commit();
+ getContext().getContentResolver().notifyChange(screenUri, null, false);
+ return screenUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
}
}
@@ -200,7 +198,7 @@ public Cursor query(Uri uri, String[] projection, String selection, String[] sel
return c;
} catch (IllegalStateException e) {
if (Aware.DEBUG) Log.e(Aware.TAG, e.getMessage());
- return null;
+ throw e;
}
}
@@ -212,23 +210,22 @@ public synchronized int update(Uri uri, ContentValues values, String selection,
initialiseDatabase();
- database.beginTransaction();
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
- int count;
- switch (sUriMatcher.match(uri)) {
- case SCHEDULER:
- count = database.update(DATABASE_TABLES[0], values, selection, selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
- }
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case SCHEDULER:
+ count = database.update(DATABASE_TABLES[0], values, selection, selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
- database.setTransactionSuccessful();
- database.endTransaction();
+ transaction.commit();
- getContext().getContentResolver().notifyChange(uri, null, false);
+ getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
+ return count;
+ }
}
}
\ No newline at end of file
diff --git a/aware-core/src/main/java/com/aware/providers/ScreenShot_Provider.java b/aware-core/src/main/java/com/aware/providers/ScreenShot_Provider.java
index 2e2db70b..0d145521 100644
--- a/aware-core/src/main/java/com/aware/providers/ScreenShot_Provider.java
+++ b/aware-core/src/main/java/com/aware/providers/ScreenShot_Provider.java
@@ -20,6 +20,7 @@
import com.aware.Aware;
import com.aware.ScreenShot;
import com.aware.utils.DatabaseHelper;
+import com.aware.utils.DatabaseTransaction;
import java.util.HashMap;
import java.util.Objects;
@@ -106,25 +107,23 @@ public Uri insert(Uri uri, ContentValues initialValues) {
byte[] imageData = values.getAsByteArray(ScreenshotData.IMAGE_DATA);
int imageSize = (imageData != null) ? imageData.length : 0;
- database.beginTransaction();
- switch (sUriMatcher.match(uri)){
- case SCREENSHOT:
- long screen_shot_id = database.insertWithOnConflict(DATABASE_TABLES[0],
- ScreenShot_Provider.ScreenshotData.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- if (screen_shot_id > 0) {
- Uri screenShotUri = ContentUris.withAppendedId(ScreenshotData.CONTENT_URI, screen_shot_id);
- Objects.requireNonNull(getContext()).getContentResolver().notifyChange(screenShotUri, null, false);
- database.setTransactionSuccessful();
- database.endTransaction();
- return screenShotUri;
- }
-
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
-
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI Insert " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+ switch (sUriMatcher.match(uri)){
+ case SCREENSHOT:
+ long screen_shot_id = database.insertWithOnConflict(DATABASE_TABLES[0],
+ ScreenShot_Provider.ScreenshotData.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ if (screen_shot_id > 0) {
+ Uri screenShotUri = ContentUris.withAppendedId(ScreenshotData.CONTENT_URI, screen_shot_id);
+ transaction.commit();
+ Objects.requireNonNull(getContext()).getContentResolver().notifyChange(screenShotUri, null, false);
+ return screenShotUri;
+ }
+
+ throw new SQLException("Failed to insert row into " + uri);
+
+ default:
+ throw new IllegalArgumentException("Unknown URI Insert " + uri);
+ }
}
}
@@ -163,7 +162,7 @@ public Cursor query(Uri uri, String[] projection, String selection, String[] sel
} catch (IllegalStateException e) {
if (Aware.DEBUG)
Log.e(Aware.TAG, e.getMessage());
- return null;
+ throw e;
}
}
@@ -172,47 +171,45 @@ public int delete(Uri uri, String selection, String[] selectionArgs) {
initialiseDatabase();
//lock database for transaction
- database.beginTransaction();
-
- int count;
- switch (sUriMatcher.match(uri)) {
- case SCREENSHOT:
- count = database.delete(DATABASE_TABLES[0], selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI delete " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case SCREENSHOT:
+ count = database.delete(DATABASE_TABLES[0], selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI delete " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
@Override
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
initialiseDatabase();
- database.beginTransaction();
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
- int count;
- switch (sUriMatcher.match(uri)) {
- case SCREENSHOT:
- count = database.update(DATABASE_TABLES[0], values, selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI update" + uri);
- }
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case SCREENSHOT:
+ count = database.update(DATABASE_TABLES[0], values, selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI update" + uri);
+ }
- database.setTransactionSuccessful();
- database.endTransaction();
+ transaction.commit();
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
+ }
}
}
diff --git a/aware-core/src/main/java/com/aware/providers/ScreenText_Provider.java b/aware-core/src/main/java/com/aware/providers/ScreenText_Provider.java
index aa14534a..7f98a272 100644
--- a/aware-core/src/main/java/com/aware/providers/ScreenText_Provider.java
+++ b/aware-core/src/main/java/com/aware/providers/ScreenText_Provider.java
@@ -15,6 +15,7 @@
import com.aware.Aware;
import com.aware.utils.DatabaseHelper;
+import com.aware.utils.DatabaseTransaction;
import java.util.HashMap;
@@ -145,7 +146,7 @@ public Cursor query(Uri uri, String[] projection, String selection, String[] sel
} catch (IllegalStateException e) {
if (Aware.DEBUG)
Log.e(Aware.TAG, e.getMessage());
- return null;
+ throw e;
}
}
@@ -167,25 +168,23 @@ public Uri insert(Uri uri, ContentValues initialValues) {
ContentValues values = (initialValues != null) ? new ContentValues(initialValues) : new ContentValues();
- database.beginTransaction();
-
- switch (sUriMatcher.match(uri)) {
- case SCREEN_TEXT:
- long screen_text_id = database.insertWithOnConflict(DATABASE_TABLES[0],
- ScreenTextData.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- if (screen_text_id > 0) {
- Uri screenTextUri = ContentUris.withAppendedId(
- ScreenTextData.CONTENT_URI, screen_text_id);
- getContext().getContentResolver().notifyChange(screenTextUri, null, false);
- database.setTransactionSuccessful();
- database.endTransaction();
- return screenTextUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ switch (sUriMatcher.match(uri)) {
+ case SCREEN_TEXT:
+ long screen_text_id = database.insertWithOnConflict(DATABASE_TABLES[0],
+ ScreenTextData.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ if (screen_text_id > 0) {
+ Uri screenTextUri = ContentUris.withAppendedId(
+ ScreenTextData.CONTENT_URI, screen_text_id);
+ transaction.commit();
+ getContext().getContentResolver().notifyChange(screenTextUri, null, false);
+ return screenTextUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
}
}
@@ -194,47 +193,45 @@ public int delete(Uri uri, String selection, String[] selectionArgs) {
initialiseDatabase();
//lock database for transaction
- database.beginTransaction();
-
- int count;
- switch (sUriMatcher.match(uri)) {
- case SCREEN_TEXT:
- count = database.delete(DATABASE_TABLES[0], selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case SCREEN_TEXT:
+ count = database.delete(DATABASE_TABLES[0], selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
@Override
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
initialiseDatabase();
- database.beginTransaction();
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
- int count;
- switch (sUriMatcher.match(uri)) {
- case SCREEN_TEXT:
- count = database.update(DATABASE_TABLES[0], values, selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
- }
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case SCREEN_TEXT:
+ count = database.update(DATABASE_TABLES[0], values, selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
- database.setTransactionSuccessful();
- database.endTransaction();
+ transaction.commit();
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
+ }
}
}
diff --git a/aware-core/src/main/java/com/aware/providers/Screen_Provider.java b/aware-core/src/main/java/com/aware/providers/Screen_Provider.java
index 18ecb896..bcb20410 100644
--- a/aware-core/src/main/java/com/aware/providers/Screen_Provider.java
+++ b/aware-core/src/main/java/com/aware/providers/Screen_Provider.java
@@ -16,6 +16,7 @@
import com.aware.Aware;
import com.aware.utils.DatabaseHelper;
+import com.aware.utils.DatabaseTransaction;
import java.util.HashMap;
@@ -123,26 +124,25 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs
initialiseDatabase();
//lock database for transaction
- database.beginTransaction();
-
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case SCREEN:
- count = database.delete(DATABASE_TABLES[0], selection,
- selectionArgs);
- break;
- case TOUCH:
- count = database.delete(DATABASE_TABLES[1], selection, selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case SCREEN:
+ count = database.delete(DATABASE_TABLES[0], selection,
+ selectionArgs);
+ break;
+ case TOUCH:
+ count = database.delete(DATABASE_TABLES[1], selection, selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
@Override
@@ -171,37 +171,33 @@ public synchronized Uri insert(Uri uri, ContentValues initialValues) {
ContentValues values = (initialValues != null) ? new ContentValues(initialValues) : new ContentValues();
- database.beginTransaction();
-
- switch (sUriMatcher.match(uri)) {
- case SCREEN:
- long screen_id = database.insertWithOnConflict(DATABASE_TABLES[0],
- Screen_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (screen_id > 0) {
- Uri screenUri = ContentUris.withAppendedId(
- Screen_Data.CONTENT_URI, screen_id);
- getContext().getContentResolver().notifyChange(screenUri, null, false);
- return screenUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- case TOUCH:
- long touch_id = database.insertWithOnConflict(DATABASE_TABLES[1], Screen_Touch.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (touch_id > 0) {
- Uri screenUri = ContentUris.withAppendedId(
- Screen_Touch.CONTENT_URI, touch_id);
- getContext().getContentResolver().notifyChange(screenUri, null, false);
- return screenUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ switch (sUriMatcher.match(uri)) {
+ case SCREEN:
+ long screen_id = database.insertWithOnConflict(DATABASE_TABLES[0],
+ Screen_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (screen_id > 0) {
+ Uri screenUri = ContentUris.withAppendedId(
+ Screen_Data.CONTENT_URI, screen_id);
+ getContext().getContentResolver().notifyChange(screenUri, null, false);
+ return screenUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ case TOUCH:
+ long touch_id = database.insertWithOnConflict(DATABASE_TABLES[1], Screen_Touch.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (touch_id > 0) {
+ Uri screenUri = ContentUris.withAppendedId(
+ Screen_Touch.CONTENT_URI, touch_id);
+ getContext().getContentResolver().notifyChange(screenUri, null, false);
+ return screenUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
}
}
@@ -277,7 +273,7 @@ public Cursor query(Uri uri, String[] projection, String selection,
if (Aware.DEBUG)
Log.e(Aware.TAG, e.getMessage());
- return null;
+ throw e;
}
}
@@ -290,26 +286,25 @@ public synchronized int update(Uri uri, ContentValues values, String selection,
initialiseDatabase();
- database.beginTransaction();
-
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case SCREEN:
- count = database.update(DATABASE_TABLES[0], values, selection,
- selectionArgs);
- break;
- case TOUCH:
- count = database.update(DATABASE_TABLES[1], values, selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case SCREEN:
+ count = database.update(DATABASE_TABLES[0], values, selection,
+ selectionArgs);
+ break;
+ case TOUCH:
+ count = database.update(DATABASE_TABLES[1], values, selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
}
\ No newline at end of file
diff --git a/aware-core/src/main/java/com/aware/providers/Significant_Provider.java b/aware-core/src/main/java/com/aware/providers/Significant_Provider.java
index d6822306..8a5194f3 100644
--- a/aware-core/src/main/java/com/aware/providers/Significant_Provider.java
+++ b/aware-core/src/main/java/com/aware/providers/Significant_Provider.java
@@ -17,6 +17,7 @@
import com.aware.Accelerometer;
import com.aware.Aware;
import com.aware.utils.DatabaseHelper;
+import com.aware.utils.DatabaseTransaction;
import java.util.HashMap;
@@ -91,23 +92,22 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs
initialiseDatabase();
- database.beginTransaction();
-
- int count;
- switch (sUriMatcher.match(uri)) {
- case SENSOR_DATA:
- count = database.delete(DATABASE_TABLES[0], selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count;
+ switch (sUriMatcher.match(uri)) {
+ case SENSOR_DATA:
+ count = database.delete(DATABASE_TABLES[0], selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
@Override
@@ -132,23 +132,21 @@ public synchronized Uri insert(Uri uri, ContentValues initialValues) {
ContentValues values = (initialValues != null) ? new ContentValues(initialValues) : new ContentValues();
- database.beginTransaction();
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
- switch (sUriMatcher.match(uri)) {
- case SENSOR_DATA:
- long accelData_id = database.insertWithOnConflict(DATABASE_TABLES[0], Significant_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (accelData_id > 0) {
- Uri accelDataUri = ContentUris.withAppendedId(Significant_Data.CONTENT_URI, accelData_id);
- getContext().getContentResolver().notifyChange(accelDataUri, null, false);
- return accelDataUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ switch (sUriMatcher.match(uri)) {
+ case SENSOR_DATA:
+ long accelData_id = database.insertWithOnConflict(DATABASE_TABLES[0], Significant_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (accelData_id > 0) {
+ Uri accelDataUri = ContentUris.withAppendedId(Significant_Data.CONTENT_URI, accelData_id);
+ getContext().getContentResolver().notifyChange(accelDataUri, null, false);
+ return accelDataUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
}
}
@@ -164,36 +162,35 @@ public synchronized int bulkInsert(Uri uri, ContentValues[] values) {
initialiseDatabase();
- database.beginTransaction();
-
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case SENSOR_DATA:
- for (ContentValues v : values) {
- long id;
- try {
- id = database.insertOrThrow(DATABASE_TABLES[0], Significant_Data.DEVICE_ID, v);
- } catch (SQLException e) {
- id = database.replace(DATABASE_TABLES[0], Significant_Data.DEVICE_ID, v);
- }
- if (id <= 0) {
- Log.w(Accelerometer.TAG, "Failed to insert/replace row into " + uri);
- } else {
- count++;
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case SENSOR_DATA:
+ for (ContentValues v : values) {
+ long id;
+ try {
+ id = database.insertOrThrow(DATABASE_TABLES[0], Significant_Data.DEVICE_ID, v);
+ } catch (SQLException e) {
+ id = database.replace(DATABASE_TABLES[0], Significant_Data.DEVICE_ID, v);
+ }
+ if (id <= 0) {
+ Log.w(Accelerometer.TAG, "Failed to insert/replace row into " + uri);
+ } else {
+ count++;
+ }
}
- }
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
- }
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
- database.setTransactionSuccessful();
- database.endTransaction();
+ transaction.commit();
- getContext().getContentResolver().notifyChange(uri, null, false);
+ getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
+ return count;
+ }
}
/**
@@ -254,7 +251,7 @@ public Cursor query(Uri uri, String[] projection, String selection,
if (Aware.DEBUG)
Log.e(Aware.TAG, e.getMessage());
- return null;
+ throw e;
}
}
@@ -267,23 +264,22 @@ public synchronized int update(Uri uri, ContentValues values, String selection,
initialiseDatabase();
- database.beginTransaction();
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case SENSOR_DATA:
- count = database.update(DATABASE_TABLES[0], values, selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
- }
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case SENSOR_DATA:
+ count = database.update(DATABASE_TABLES[0], values, selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
- database.setTransactionSuccessful();
- database.endTransaction();
+ transaction.commit();
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
+ }
}
}
diff --git a/aware-core/src/main/java/com/aware/providers/Telephony_Provider.java b/aware-core/src/main/java/com/aware/providers/Telephony_Provider.java
index 49e2def2..e5f00e84 100644
--- a/aware-core/src/main/java/com/aware/providers/Telephony_Provider.java
+++ b/aware-core/src/main/java/com/aware/providers/Telephony_Provider.java
@@ -16,6 +16,7 @@
import com.aware.Aware;
import com.aware.utils.DatabaseHelper;
+import com.aware.utils.DatabaseTransaction;
import java.util.HashMap;
@@ -236,36 +237,35 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs
initialiseDatabase();
- database.beginTransaction();
-
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case TELEPHONY:
- count = database.delete(DATABASE_TABLES[0], selection,
- selectionArgs);
- break;
- case GSM:
- count = database.delete(DATABASE_TABLES[1], selection,
- selectionArgs);
- break;
- case NEIGHBOR:
- count = database.delete(DATABASE_TABLES[2], selection,
- selectionArgs);
- break;
- case CDMA:
- count = database.delete(DATABASE_TABLES[3], selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case TELEPHONY:
+ count = database.delete(DATABASE_TABLES[0], selection,
+ selectionArgs);
+ break;
+ case GSM:
+ count = database.delete(DATABASE_TABLES[1], selection,
+ selectionArgs);
+ break;
+ case NEIGHBOR:
+ count = database.delete(DATABASE_TABLES[2], selection,
+ selectionArgs);
+ break;
+ case CDMA:
+ count = database.delete(DATABASE_TABLES[3], selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
@Override
@@ -302,64 +302,56 @@ public synchronized Uri insert(Uri uri, ContentValues initialValues) {
ContentValues values = (initialValues != null) ? new ContentValues(initialValues) : new ContentValues();
- database.beginTransaction();
-
- switch (sUriMatcher.match(uri)) {
- case TELEPHONY:
- long tele_id = database.insertWithOnConflict(DATABASE_TABLES[0],
- Telephony_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (tele_id > 0) {
- Uri tele_uri = ContentUris.withAppendedId(
- Telephony_Data.CONTENT_URI, tele_id);
- getContext().getContentResolver().notifyChange(tele_uri, null, false);
- return tele_uri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- case GSM:
- long gsm_id = database.insertWithOnConflict(DATABASE_TABLES[1],
- GSM_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (gsm_id > 0) {
- Uri gsm_uri = ContentUris.withAppendedId(GSM_Data.CONTENT_URI,
- gsm_id);
- getContext().getContentResolver().notifyChange(gsm_uri, null, false);
- return gsm_uri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- case NEIGHBOR:
- long neighbor_id = database.insertWithOnConflict(DATABASE_TABLES[2],
- GSM_Neighbors_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (neighbor_id > 0) {
- Uri neighbor_uri = ContentUris.withAppendedId(
- GSM_Neighbors_Data.CONTENT_URI, neighbor_id);
- getContext().getContentResolver().notifyChange(neighbor_uri,null, false);
- return neighbor_uri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- case CDMA:
- long cdma_id = database.insertWithOnConflict(DATABASE_TABLES[3],
- CDMA_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (cdma_id > 0) {
- Uri cdma_uri = ContentUris.withAppendedId(
- CDMA_Data.CONTENT_URI, cdma_id);
- getContext().getContentResolver().notifyChange(cdma_uri, null, false);
- return cdma_uri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ switch (sUriMatcher.match(uri)) {
+ case TELEPHONY:
+ long tele_id = database.insertWithOnConflict(DATABASE_TABLES[0],
+ Telephony_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (tele_id > 0) {
+ Uri tele_uri = ContentUris.withAppendedId(
+ Telephony_Data.CONTENT_URI, tele_id);
+ getContext().getContentResolver().notifyChange(tele_uri, null, false);
+ return tele_uri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ case GSM:
+ long gsm_id = database.insertWithOnConflict(DATABASE_TABLES[1],
+ GSM_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (gsm_id > 0) {
+ Uri gsm_uri = ContentUris.withAppendedId(GSM_Data.CONTENT_URI,
+ gsm_id);
+ getContext().getContentResolver().notifyChange(gsm_uri, null, false);
+ return gsm_uri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ case NEIGHBOR:
+ long neighbor_id = database.insertWithOnConflict(DATABASE_TABLES[2],
+ GSM_Neighbors_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (neighbor_id > 0) {
+ Uri neighbor_uri = ContentUris.withAppendedId(
+ GSM_Neighbors_Data.CONTENT_URI, neighbor_id);
+ getContext().getContentResolver().notifyChange(neighbor_uri,null, false);
+ return neighbor_uri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ case CDMA:
+ long cdma_id = database.insertWithOnConflict(DATABASE_TABLES[3],
+ CDMA_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (cdma_id > 0) {
+ Uri cdma_uri = ContentUris.withAppendedId(
+ CDMA_Data.CONTENT_URI, cdma_id);
+ getContext().getContentResolver().notifyChange(cdma_uri, null, false);
+ return cdma_uri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
}
}
@@ -510,7 +502,7 @@ public Cursor query(Uri uri, String[] projection, String selection,
if (Aware.DEBUG)
Log.e(Aware.TAG, e.getMessage());
- return null;
+ throw e;
}
}
@@ -523,35 +515,34 @@ public synchronized int update(Uri uri, ContentValues values, String selection,
initialiseDatabase();
- database.beginTransaction();
-
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case TELEPHONY:
- count = database.update(DATABASE_TABLES[0], values, selection,
- selectionArgs);
- break;
- case GSM:
- count = database.update(DATABASE_TABLES[1], values, selection,
- selectionArgs);
- break;
- case NEIGHBOR:
- count = database.update(DATABASE_TABLES[2], values, selection,
- selectionArgs);
- break;
- case CDMA:
- count = database.update(DATABASE_TABLES[3], values, selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case TELEPHONY:
+ count = database.update(DATABASE_TABLES[0], values, selection,
+ selectionArgs);
+ break;
+ case GSM:
+ count = database.update(DATABASE_TABLES[1], values, selection,
+ selectionArgs);
+ break;
+ case NEIGHBOR:
+ count = database.update(DATABASE_TABLES[2], values, selection,
+ selectionArgs);
+ break;
+ case CDMA:
+ count = database.update(DATABASE_TABLES[3], values, selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
}
\ No newline at end of file
diff --git a/aware-core/src/main/java/com/aware/providers/Temperature_Provider.java b/aware-core/src/main/java/com/aware/providers/Temperature_Provider.java
index 9fd40945..88194d59 100644
--- a/aware-core/src/main/java/com/aware/providers/Temperature_Provider.java
+++ b/aware-core/src/main/java/com/aware/providers/Temperature_Provider.java
@@ -17,6 +17,7 @@
import com.aware.Accelerometer;
import com.aware.Aware;
import com.aware.utils.DatabaseHelper;
+import com.aware.utils.DatabaseTransaction;
import java.util.HashMap;
@@ -28,7 +29,7 @@
*/
public class Temperature_Provider extends ContentProvider {
- public static final int DATABASE_VERSION = 3;
+ public static final int DATABASE_VERSION = 4;
/**
* Authority of content provider
@@ -87,7 +88,6 @@ private Temperature_Data() {
public static final String DEVICE_ID = "device_id";
public static final String TEMPERATURE_CELSIUS = "temperature_celsius";
public static final String ACCURACY = "accuracy";
- public static final String LABEL = "label";
}
public static String DATABASE_NAME = "temperature.db";
@@ -114,8 +114,7 @@ private Temperature_Data() {
+ Temperature_Data.TIMESTAMP + " real default 0,"
+ Temperature_Data.DEVICE_ID + " text default '',"
+ Temperature_Data.TEMPERATURE_CELSIUS + " real default 0,"
- + Temperature_Data.ACCURACY + " integer default 0,"
- + Temperature_Data.LABEL + " text default ''"};
+ + Temperature_Data.ACCURACY + " integer default 0"};
private UriMatcher sUriMatcher = null;
private HashMap sensorMap = null;
@@ -124,8 +123,10 @@ private Temperature_Data() {
private static SQLiteDatabase database;
private void initialiseDatabase() {
- if (dbHelper == null)
+ if (dbHelper == null) {
dbHelper = new DatabaseHelper(getContext(), DATABASE_NAME, null, DATABASE_VERSION, DATABASE_TABLES, TABLES_FIELDS);
+ dbHelper.setMetadataOnlyTrailingColumnDrops("label");
+ }
if (database == null)
database = dbHelper.getWritableDatabase();
}
@@ -138,27 +139,26 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs
initialiseDatabase();
//lock database for transaction
- database.beginTransaction();
-
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case SENSOR_DEV:
- count = database.delete(DATABASE_TABLES[0], selection,
- selectionArgs);
- break;
- case SENSOR_DATA:
- count = database.delete(DATABASE_TABLES[1], selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case SENSOR_DEV:
+ count = database.delete(DATABASE_TABLES[0], selection,
+ selectionArgs);
+ break;
+ case SENSOR_DATA:
+ count = database.delete(DATABASE_TABLES[1], selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
@Override
@@ -187,36 +187,34 @@ public synchronized Uri insert(Uri uri, ContentValues initialValues) {
ContentValues values = (initialValues != null) ? new ContentValues(initialValues) : new ContentValues();
- database.beginTransaction();
-
- switch (sUriMatcher.match(uri)) {
- case SENSOR_DEV:
- long accel_id = database.insertWithOnConflict(DATABASE_TABLES[0],
- Temperature_Sensor.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (accel_id > 0) {
- Uri accelUri = ContentUris.withAppendedId(
- Temperature_Sensor.CONTENT_URI, accel_id);
- getContext().getContentResolver().notifyChange(accelUri, null, false);
- return accelUri;
- }
- throw new SQLException("Failed to insert row into " + uri);
- case SENSOR_DATA:
- long accelData_id = database.insertWithOnConflict(DATABASE_TABLES[1],
- Temperature_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (accelData_id > 0) {
- Uri accelDataUri = ContentUris.withAppendedId(
- Temperature_Data.CONTENT_URI, accelData_id);
- getContext().getContentResolver().notifyChange(accelDataUri,null, false);
- return accelDataUri;
- }
- throw new SQLException("Failed to insert row into " + uri);
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ switch (sUriMatcher.match(uri)) {
+ case SENSOR_DEV:
+ long accel_id = database.insertWithOnConflict(DATABASE_TABLES[0],
+ Temperature_Sensor.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (accel_id > 0) {
+ Uri accelUri = ContentUris.withAppendedId(
+ Temperature_Sensor.CONTENT_URI, accel_id);
+ getContext().getContentResolver().notifyChange(accelUri, null, false);
+ return accelUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ case SENSOR_DATA:
+ long accelData_id = database.insertWithOnConflict(DATABASE_TABLES[1],
+ Temperature_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (accelData_id > 0) {
+ Uri accelDataUri = ContentUris.withAppendedId(
+ Temperature_Data.CONTENT_URI, accelData_id);
+ getContext().getContentResolver().notifyChange(accelDataUri,null, false);
+ return accelDataUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
}
}
@@ -232,51 +230,50 @@ public synchronized int bulkInsert(Uri uri, ContentValues[] values) {
initialiseDatabase();
- database.beginTransaction();
-
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case SENSOR_DEV:
- for (ContentValues v : values) {
- long id;
- try {
- id = database.insertOrThrow(DATABASE_TABLES[0], Temperature_Sensor.DEVICE_ID, v);
- } catch (SQLException e) {
- id = database.replace(DATABASE_TABLES[0], Temperature_Sensor.DEVICE_ID, v);
- }
- if (id <= 0) {
- Log.w(Accelerometer.TAG, "Failed to insert/replace row into " + uri);
- } else {
- count++;
- }
- }
- break;
- case SENSOR_DATA:
- for (ContentValues v : values) {
- long id;
- try {
- id = database.insertOrThrow(DATABASE_TABLES[1], Temperature_Data.DEVICE_ID, v);
- } catch (SQLException e) {
- id = database.replace(DATABASE_TABLES[1], Temperature_Data.DEVICE_ID, v);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case SENSOR_DEV:
+ for (ContentValues v : values) {
+ long id;
+ try {
+ id = database.insertOrThrow(DATABASE_TABLES[0], Temperature_Sensor.DEVICE_ID, v);
+ } catch (SQLException e) {
+ id = database.replace(DATABASE_TABLES[0], Temperature_Sensor.DEVICE_ID, v);
+ }
+ if (id <= 0) {
+ Log.w(Accelerometer.TAG, "Failed to insert/replace row into " + uri);
+ } else {
+ count++;
+ }
}
- if (id <= 0) {
- Log.w(Accelerometer.TAG, "Failed to insert/replace row into " + uri);
- } else {
- count++;
+ break;
+ case SENSOR_DATA:
+ for (ContentValues v : values) {
+ long id;
+ try {
+ id = database.insertOrThrow(DATABASE_TABLES[1], Temperature_Data.DEVICE_ID, v);
+ } catch (SQLException e) {
+ id = database.replace(DATABASE_TABLES[1], Temperature_Data.DEVICE_ID, v);
+ }
+ if (id <= 0) {
+ Log.w(Accelerometer.TAG, "Failed to insert/replace row into " + uri);
+ } else {
+ count++;
+ }
}
- }
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
- }
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
- database.setTransactionSuccessful();
- database.endTransaction();
+ transaction.commit();
- getContext().getContentResolver().notifyChange(uri, null, false);
+ getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
+ return count;
+ }
}
/**
@@ -329,7 +326,6 @@ public boolean onCreate() {
sensorDataMap.put(Temperature_Data.TEMPERATURE_CELSIUS,
Temperature_Data.TEMPERATURE_CELSIUS);
sensorDataMap.put(Temperature_Data.ACCURACY, Temperature_Data.ACCURACY);
- sensorDataMap.put(Temperature_Data.LABEL, Temperature_Data.LABEL);
return true;
}
@@ -367,7 +363,7 @@ public Cursor query(Uri uri, String[] projection, String selection,
if (Aware.DEBUG)
Log.e(Aware.TAG, e.getMessage());
- return null;
+ throw e;
}
}
@@ -380,27 +376,26 @@ public synchronized int update(Uri uri, ContentValues values, String selection,
initialiseDatabase();
- database.beginTransaction();
-
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case SENSOR_DEV:
- count = database.update(DATABASE_TABLES[0], values, selection,
- selectionArgs);
- break;
- case SENSOR_DATA:
- count = database.update(DATABASE_TABLES[1], values, selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case SENSOR_DEV:
+ count = database.update(DATABASE_TABLES[0], values, selection,
+ selectionArgs);
+ break;
+ case SENSOR_DATA:
+ count = database.update(DATABASE_TABLES[1], values, selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
-
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
}
diff --git a/aware-core/src/main/java/com/aware/providers/TimeZone_Provider.java b/aware-core/src/main/java/com/aware/providers/TimeZone_Provider.java
index 755c772f..c6c4829c 100644
--- a/aware-core/src/main/java/com/aware/providers/TimeZone_Provider.java
+++ b/aware-core/src/main/java/com/aware/providers/TimeZone_Provider.java
@@ -16,6 +16,7 @@
import com.aware.Aware;
import com.aware.utils.DatabaseHelper;
+import com.aware.utils.DatabaseTransaction;
import java.util.HashMap;
@@ -90,23 +91,22 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs
initialiseDatabase();
//lock database for transaction
- database.beginTransaction();
-
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case TIMEZONE:
- count = database.delete(DATABASE_TABLES[0], selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case TIMEZONE:
+ count = database.delete(DATABASE_TABLES[0], selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
@Override
@@ -131,25 +131,23 @@ public synchronized Uri insert(Uri uri, ContentValues initialValues) {
ContentValues values = (initialValues != null) ? new ContentValues(initialValues) : new ContentValues();
- database.beginTransaction();
-
- switch (sUriMatcher.match(uri)) {
- case TIMEZONE:
- long timezone_id = database.insertWithOnConflict(DATABASE_TABLES[0],
- TimeZone_Data.TIMEZONE, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (timezone_id > 0) {
- Uri tele_uri = ContentUris.withAppendedId(
- TimeZone_Data.CONTENT_URI, timezone_id);
- getContext().getContentResolver().notifyChange(tele_uri, null, false);
- return tele_uri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ switch (sUriMatcher.match(uri)) {
+ case TIMEZONE:
+ long timezone_id = database.insertWithOnConflict(DATABASE_TABLES[0],
+ TimeZone_Data.TIMEZONE, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (timezone_id > 0) {
+ Uri tele_uri = ContentUris.withAppendedId(
+ TimeZone_Data.CONTENT_URI, timezone_id);
+ getContext().getContentResolver().notifyChange(tele_uri, null, false);
+ return tele_uri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
}
}
@@ -209,7 +207,7 @@ public Cursor query(Uri uri, String[] projection, String selection,
if (Aware.DEBUG)
Log.e(Aware.TAG, e.getMessage());
- return null;
+ throw e;
}
}
@@ -222,22 +220,21 @@ public synchronized int update(Uri uri, ContentValues values, String selection,
initialiseDatabase();
- database.beginTransaction();
-
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case TIMEZONE:
- count = database.update(DATABASE_TABLES[0], values, selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case TIMEZONE:
+ count = database.update(DATABASE_TABLES[0], values, selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
}
\ No newline at end of file
diff --git a/aware-core/src/main/java/com/aware/providers/Traffic_Provider.java b/aware-core/src/main/java/com/aware/providers/Traffic_Provider.java
index dcfa2067..61319139 100644
--- a/aware-core/src/main/java/com/aware/providers/Traffic_Provider.java
+++ b/aware-core/src/main/java/com/aware/providers/Traffic_Provider.java
@@ -16,6 +16,7 @@
import com.aware.Aware;
import com.aware.utils.DatabaseHelper;
+import com.aware.utils.DatabaseTransaction;
import java.util.HashMap;
@@ -98,23 +99,22 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs
initialiseDatabase();
//lock database for transaction
- database.beginTransaction();
-
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case TRAFFIC:
- count = database.delete(DATABASE_TABLES[0], selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case TRAFFIC:
+ count = database.delete(DATABASE_TABLES[0], selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
@Override
@@ -139,25 +139,23 @@ public synchronized Uri insert(Uri uri, ContentValues initialValues) {
ContentValues values = (initialValues != null) ? new ContentValues(initialValues) : new ContentValues();
- database.beginTransaction();
-
- switch (sUriMatcher.match(uri)) {
- case TRAFFIC:
- long traffic_id = database.insertWithOnConflict(DATABASE_TABLES[0],
- Traffic_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (traffic_id > 0) {
- Uri trafficUri = ContentUris.withAppendedId(
- Traffic_Data.CONTENT_URI, traffic_id);
- getContext().getContentResolver().notifyChange(trafficUri, null, false);
- return trafficUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ switch (sUriMatcher.match(uri)) {
+ case TRAFFIC:
+ long traffic_id = database.insertWithOnConflict(DATABASE_TABLES[0],
+ Traffic_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (traffic_id > 0) {
+ Uri trafficUri = ContentUris.withAppendedId(
+ Traffic_Data.CONTENT_URI, traffic_id);
+ getContext().getContentResolver().notifyChange(trafficUri, null, false);
+ return trafficUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
}
}
@@ -233,7 +231,7 @@ public Cursor query(Uri uri, String[] projection, String selection,
if (Aware.DEBUG)
Log.e(Aware.TAG, e.getMessage());
- return null;
+ throw e;
}
}
@@ -246,22 +244,21 @@ public synchronized int update(Uri uri, ContentValues values, String selection,
initialiseDatabase();
- database.beginTransaction();
-
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case TRAFFIC:
- count = database.update(DATABASE_TABLES[0], values, selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case TRAFFIC:
+ count = database.update(DATABASE_TABLES[0], values, selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
}
diff --git a/aware-core/src/main/java/com/aware/providers/WiFi_Provider.java b/aware-core/src/main/java/com/aware/providers/WiFi_Provider.java
index 652792a4..7abafe02 100644
--- a/aware-core/src/main/java/com/aware/providers/WiFi_Provider.java
+++ b/aware-core/src/main/java/com/aware/providers/WiFi_Provider.java
@@ -16,6 +16,7 @@
import com.aware.Aware;
import com.aware.utils.DatabaseHelper;
+import com.aware.utils.DatabaseTransaction;
import java.util.HashMap;
@@ -130,27 +131,26 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs
initialiseDatabase();
//lock database for transaction
- database.beginTransaction();
-
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case WIFI_DATA:
- count = database.delete(DATABASE_TABLES[0], selection,
- selectionArgs);
- break;
- case WIFI_DEV:
- count = database.delete(DATABASE_TABLES[1], selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case WIFI_DATA:
+ count = database.delete(DATABASE_TABLES[0], selection,
+ selectionArgs);
+ break;
+ case WIFI_DEV:
+ count = database.delete(DATABASE_TABLES[1], selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
@Override
@@ -179,38 +179,34 @@ public synchronized Uri insert(Uri uri, ContentValues initialValues) {
ContentValues values = (initialValues != null) ? new ContentValues(initialValues) : new ContentValues();
- database.beginTransaction();
-
- switch (sUriMatcher.match(uri)) {
- case WIFI_DATA:
- long wifiID = database.insertWithOnConflict(DATABASE_TABLES[0],
- WiFi_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (wifiID > 0) {
- Uri wifiUri = ContentUris.withAppendedId(WiFi_Data.CONTENT_URI,
- wifiID);
- getContext().getContentResolver().notifyChange(wifiUri, null, false);
- return wifiUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- case WIFI_DEV:
- long wifiDevID = database.insertWithOnConflict(DATABASE_TABLES[1],
- WiFi_Sensor.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
- database.setTransactionSuccessful();
- database.endTransaction();
- if (wifiDevID > 0) {
- Uri wifiUri = ContentUris.withAppendedId(
- WiFi_Sensor.CONTENT_URI, wifiDevID);
- getContext().getContentResolver().notifyChange(wifiUri, null, false);
- return wifiUri;
- }
- database.endTransaction();
- throw new SQLException("Failed to insert row into " + uri);
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ switch (sUriMatcher.match(uri)) {
+ case WIFI_DATA:
+ long wifiID = database.insertWithOnConflict(DATABASE_TABLES[0],
+ WiFi_Data.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (wifiID > 0) {
+ Uri wifiUri = ContentUris.withAppendedId(WiFi_Data.CONTENT_URI,
+ wifiID);
+ getContext().getContentResolver().notifyChange(wifiUri, null, false);
+ return wifiUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ case WIFI_DEV:
+ long wifiDevID = database.insertWithOnConflict(DATABASE_TABLES[1],
+ WiFi_Sensor.DEVICE_ID, values, SQLiteDatabase.CONFLICT_IGNORE);
+ transaction.commit();
+ if (wifiDevID > 0) {
+ Uri wifiUri = ContentUris.withAppendedId(
+ WiFi_Sensor.CONTENT_URI, wifiDevID);
+ getContext().getContentResolver().notifyChange(wifiUri, null, false);
+ return wifiUri;
+ }
+ throw new SQLException("Failed to insert row into " + uri);
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
}
}
@@ -292,7 +288,7 @@ public Cursor query(Uri uri, String[] projection, String selection,
if (Aware.DEBUG)
Log.e(Aware.TAG, e.getMessage());
- return null;
+ throw e;
}
}
@@ -305,26 +301,25 @@ public synchronized int update(Uri uri, ContentValues values, String selection,
initialiseDatabase();
- database.beginTransaction();
-
- int count = 0;
- switch (sUriMatcher.match(uri)) {
- case WIFI_DATA:
- count = database.update(DATABASE_TABLES[0], values, selection,
- selectionArgs);
- break;
- case WIFI_DEV:
- count = database.update(DATABASE_TABLES[1], values, selection,
- selectionArgs);
- break;
- default:
- database.endTransaction();
- throw new IllegalArgumentException("Unknown URI " + uri);
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(database)) {
+
+ int count = 0;
+ switch (sUriMatcher.match(uri)) {
+ case WIFI_DATA:
+ count = database.update(DATABASE_TABLES[0], values, selection,
+ selectionArgs);
+ break;
+ case WIFI_DEV:
+ count = database.update(DATABASE_TABLES[1], values, selection,
+ selectionArgs);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown URI " + uri);
+ }
+
+ transaction.commit();
+ getContext().getContentResolver().notifyChange(uri, null, false);
+ return count;
}
-
- database.setTransactionSuccessful();
- database.endTransaction();
- getContext().getContentResolver().notifyChange(uri, null, false);
- return count;
}
}
diff --git a/aware-core/src/main/java/com/aware/syncadapters/AwareSyncAdapter.java b/aware-core/src/main/java/com/aware/syncadapters/AwareSyncAdapter.java
index 7f685e13..b18ccbec 100644
--- a/aware-core/src/main/java/com/aware/syncadapters/AwareSyncAdapter.java
+++ b/aware-core/src/main/java/com/aware/syncadapters/AwareSyncAdapter.java
@@ -21,10 +21,15 @@
import com.aware.Aware_Preferences;
import com.aware.R;
import com.aware.providers.Aware_Provider;
+import com.aware.utils.DeviceId;
import com.aware.utils.Http;
import com.aware.utils.Https;
import com.aware.utils.Jdbc;
import com.aware.utils.SSLManager;
+import com.aware.utils.SyncBatchBudget;
+import com.aware.utils.SyncCursor;
+import com.aware.utils.Webservice;
+import com.aware.utils.UploadHealth;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
@@ -49,7 +54,6 @@ public class AwareSyncAdapter extends AbstractThreadedSyncAdapter {
private NotificationManager notManager;
private final ArrayList highFrequencySensors = new ArrayList<>();
- private final ArrayList dontClearSensors = new ArrayList<>();
private int notificationID = 99990;
@@ -75,8 +79,6 @@ public AwareSyncAdapter(Context context, boolean autoInitialize, boolean allowPa
highFrequencySensors.add("screentext");
highFrequencySensors.add("screenshot");
highFrequencySensors.add("plugin_ambient_noise");
-
- dontClearSensors.add("aware_studies");
}
/**
@@ -89,75 +91,43 @@ public AwareSyncAdapter(Context context, boolean autoInitialize, boolean allowPa
* @param syncResult
*/
@Override
- public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) {
+ public void onPerformSync(
+ Account account,
+ Bundle extras,
+ String authority,
+ ContentProviderClient provider,
+ SyncResult syncResult
+ ) {
Log.i(Aware.TAG, "Performing sync for " + Arrays.toString(DATABASE_TABLES));
- if (!Aware.getSetting(mContext, Aware_Preferences.WEBSERVICE_SILENT).equals("true"))
- notManager = (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE);
+ // Pause uploads while a study is awaiting password re-authentication: the stored password is
+ // rejected, so every upload attempt would be a failed login hammering the research database.
+ if (Aware.getSetting(mContext, Aware_Preferences.PENDING_STUDY_REAUTH).trim().length() > 0) {
+ Log.i(Aware.TAG, "Skipping data sync: study awaiting password re-authentication.");
+ return;
+ }
+ // Nothing uploads without an identity to attribute it to. syncBatch() repairs a row stored
+ // before the UUID resolved, but only when it has one to repair it with -- so this is the case
+ // that repair cannot cover, refused here rather than left to the payload. An install with no
+ // UUID has also never joined a study, so in practice offloadData() already returns on the
+ // empty webservice address; stating the rule here is what stops that staying true by
+ // coincidence.
+ if (DeviceId.trimToEmpty(Aware.getDeviceID(mContext)).isEmpty()) {
+ Log.w(Aware.TAG, "Skipping data sync: this install has no device_id yet, so no row can be attributed.");
+ return;
+ }
+ if (!Aware.getSetting(mContext, Aware_Preferences.WEBSERVICE_SILENT).equals("true"))
+ notManager = (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE);
if (DATABASE_TABLES != null && TABLES_FIELDS != null && CONTEXT_URIS != null) {
for (int i = 0; i < DATABASE_TABLES.length; i++) {
-
-
-
offloadData(mContext, DATABASE_TABLES[i], Aware.getSetting(getContext(), Aware_Preferences.WEBSERVICE_SERVER), TABLES_FIELDS[i], CONTEXT_URIS[i]);
}
}
}
- /**
- * Offloads data stored in a content provider to a remote MySQL database.
- *
- * @param context application context
- * @param database_table name of database table that contains the data to offload
- * @param table_fields string representing the fields for database_table
- * @param CONTENT_URI URI of the content provider
- */
- private void offloadData(Context context, String database_table, String table_fields, Uri CONTENT_URI) {
-
- // Check if charging is required for offloading data
- if (Aware.getSetting(context, Aware_Preferences.WEBSERVICE_CHARGING).equals("true")) {
- Intent batt = context.registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED));
- int plugged = batt.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1);
- boolean isCharging = (plugged == BatteryManager.BATTERY_PLUGGED_AC || plugged == BatteryManager.BATTERY_PLUGGED_USB);
-
- if (!isCharging) {
- if (Aware.DEBUG) Log.d(Aware.TAG, "Only sync data if charging...");
- return;
- }
- }
-
- // Check if WiFi is required for offloading data
- if (!isWifiNeededAndConnected()) {
- if (!isForce3G(database_table)) {
- if (Aware.DEBUG)
- Log.d(Aware.TAG, "Sync data only over Wi-Fi. Will try again later...");
- return;
- }
- }
-
- Aware.debug(mContext, "STUDY-SYNC: " + database_table);
-
- boolean web_service_remove_data = Aware.getSetting(context, Aware_Preferences.WEBSERVICE_REMOVE_DATA).equals("true");
- int MAX_POST_SIZE = getBatchSize();
-
- // Max number of rows to place on the HTTP(s) post
- if (MAX_POST_SIZE == 0) {
- Log.d(Aware.TAG, "Device without available memory left for sync.");
- return;
- }
-
-// try {
-// Jdbc.insertData(database_table, table_fields, data);
-// } catch (SQLException e) {
-// e.printStackTrace();
-// }
- }
-
-
-
/// Send data to the database
private void offloadData(Context context, String database_table, String web_server, String table_fields, Uri CONTENT_URI) {
@@ -187,9 +157,8 @@ private void offloadData(Context context, String database_table, String web_serv
}
}
- Aware.debug(mContext, "STUDY-SYNC: " + database_table);
+ Aware.debug(mContext, Aware.LogType.SYNC, "STUDY-SYNC: " + database_table);
- String protocol = web_server.substring(0, web_server.indexOf(":"));
boolean web_service_simple = Aware.getSetting(context, Aware_Preferences.WEBSERVICE_SIMPLE).equals("true");
boolean web_service_remove_data = Aware.getSetting(context, Aware_Preferences.WEBSERVICE_REMOVE_DATA).equals("true");
@@ -209,67 +178,70 @@ private void offloadData(Context context, String database_table, String web_serv
if (Aware.DEBUG)
Log.d(Aware.TAG, "Syncing " + database_table + " to: " + web_server + " in batches of " + MAX_POST_SIZE);
- String device_id = Aware.getSetting(context, Aware_Preferences.DEVICE_ID);
+ String device_id = Aware.getDeviceID(context);
boolean DEBUG = Aware.getSetting(context, Aware_Preferences.DEBUG_FLAG).equals("true");
- // TODO RIO: Remove this
-// String response = createRemoteTable(device_id, table_fields, web_service_simple, protocol, context, web_server, database_table);
try {
String[] columnsStr = getTableColumnsNames(CONTENT_URI, context);
+ String study_condition = getStudySyncCondition(context, database_table);
+
/**
- * We used to check the latest timestamp from the server side. We now keep track of it locally on the phone for scalability and performance hit on MySQL per sync event.
+ * The cursor is read from the local aware_sync_markers table. The server is never
+ * queried for it: one round trip per table per sync event does not scale.
*/
- String latest;
- //latest = getLatestRecordFromServer(device_id, web_service_simple, web_service_remove_data, database_table, protocol, context, web_server);
- latest = getLatestRecordSynched(database_table, columnsStr);
+ Position cursor = getSyncCursor(database_table, CONTENT_URI, columnsStr, study_condition);
- String study_condition = getStudySyncCondition(context, database_table);
- int total_records = getNumberOfRecordsToSync(CONTENT_URI, columnsStr, latest, study_condition, context);
+ int total_records = getNumberOfRecordsToSync(CONTENT_URI, columnsStr, cursor, study_condition, context);
boolean allow_table_maintenance = isTableAllowedForMaintenance(database_table);
if (Aware.DEBUG) {
- if (latest == null) {
- Log.d(Aware.TAG, "Unable to reach the server to retrieve latest... Will try again later.");
- return;
- }
-
- Log.d(Aware.TAG, "Table: " + database_table + " exists");
- Log.d(Aware.TAG, "Last synced record in this table: " + latest);
+ Log.d(Aware.TAG, "Syncing table: " + database_table);
+ Log.d(Aware.TAG, "Upload resumes after " + SyncCursor.orderColumn(columnsStr)
+ + " " + cursor.value + ", row id " + cursor.rowId);
Log.d(Aware.TAG, "Joined study since: " + study_condition);
Log.d(Aware.TAG, "Rows remaining to sync: " + total_records);
}
// If we have records to sync
if (total_records > 0) {
- JSONArray remoteLatestData = new JSONArray(latest);
long start = System.currentTimeMillis();
int uploaded_records = 0;
int batches = (int) Math.ceil(total_records / (double) MAX_POST_SIZE);
- long removeFrom = 0;
- Long lastSynced;
+ long deleteThroughId = 0;
do {
if (!Aware.getSetting(context, Aware_Preferences.WEBSERVICE_SILENT).equals("true"))
notifyUser(context, "Table: " + database_table + " syncing batch " + (uploaded_records + MAX_POST_SIZE) / MAX_POST_SIZE + " of " + batches, false, true, notificationID);
- Cursor sync_data = getSyncData(remoteLatestData, CONTENT_URI, study_condition, columnsStr, uploaded_records, context, MAX_POST_SIZE);
- lastSynced = syncBatch(sync_data, database_table, device_id, context, protocol, web_server, DEBUG);
- if (lastSynced == null) {
- removeFrom = 0;
- Log.d(Aware.TAG, "Connection to server interrupted. Will try again later.");
+ Cursor sync_data = getSyncData(CONTENT_URI, study_condition, columnsStr, cursor, context, MAX_POST_SIZE);
+ BatchOutcome batch = syncBatch(sync_data, database_table, device_id, context, DEBUG, cursor);
+ if (!batch.acknowledged) {
+ Log.d(Aware.TAG, "Batch for " + database_table + " was not acknowledged by the database. Will try again later.");
break;
- } else {
- removeFrom = lastSynced;
}
- uploaded_records += MAX_POST_SIZE;
+
+ // A batch that read no rows has drained the table: the cursor already stands at
+ // the last row, so there is nothing to advance it past.
+ if (batch.rows == 0) break;
+
+ // The cursor moves to the last row the server took in the order the table is
+ // paged by, and is stored before the next batch is read. An interrupted run
+ // therefore resumes from the last acknowledged row rather than from where this
+ // run began. Cleanup follows the highest row id instead, which on a table paged
+ // by completion is a different row from the one the cursor stands on.
+ cursor = batch.cursor;
+ deleteThroughId = Math.max(deleteThroughId, batch.maxRowId);
+ setSyncCursor(database_table, cursor);
+
+ uploaded_records += batch.rows;
}
- while (uploaded_records < total_records && lastSynced > 0 && isWifiNeededAndConnected());
+ while (uploaded_records < total_records && isWifiNeededAndConnected());
//Are we performing database space maintenance?
- if (removeFrom > 0 && allow_table_maintenance)
- performDatabaseSpaceMaintenance(CONTENT_URI, removeFrom, columnsStr, web_service_remove_data, context, database_table, DEBUG);
+ if (deleteThroughId > 0 && allow_table_maintenance)
+ performDatabaseSpaceMaintenance(CONTENT_URI, cursor.value, deleteThroughId, columnsStr, web_service_remove_data, context, database_table, DEBUG);
if (DEBUG)
Log.d(Aware.TAG, database_table + " sync time: " + DateUtils.formatElapsedTime((System.currentTimeMillis() - start) / 1000));
@@ -316,6 +288,14 @@ private void notifyUser(Context mContext, String message, boolean dismiss, boole
}
}
+ /**
+ * Upper bound on the number of rows a batch reads at once, scaled to the device's total RAM.
+ *
+ * This is a row count, so it says nothing about how many bytes those rows carry. The byte
+ * ceiling is {@link SyncBatchBudget#MAX_PAYLOAD_BYTES}, applied while a batch is being built;
+ * whichever of the two binds first ends the batch. Returns 0 when the device is under memory
+ * pressure, which skips the sync entirely.
+ */
private int getBatchSize() {
ActivityManager.MemoryInfo memInfo = new ActivityManager.MemoryInfo();
ActivityManager actManager = (ActivityManager) mContext.getSystemService(Context.ACTIVITY_SERVICE);
@@ -377,10 +357,10 @@ public boolean isForce3G(String database_table) {
long synched = lastSynched.getLong(lastSynched.getColumnIndex(Aware_Provider.Aware_Log.LOG_TIMESTAMP));
Log.d(Aware.TAG, "Checking forced sync over 3G...");
- Log.d(Aware.TAG, "Last sync: " + synched + " elapsed: " + (System.currentTimeMillis() - synched) + " force: " + (System.currentTimeMillis() - synched >= Integer.parseInt(Aware.getSetting(mContext, Aware_Preferences.WEBSERVICE_FALLBACK_NETWORK)) * 60 * 60 * 1000));
+ Log.d(Aware.TAG, "Last sync: " + synched + " elapsed: " + (System.currentTimeMillis() - synched) + " force: " + (System.currentTimeMillis() - synched >= Aware.getSettingAsInt(mContext, Aware_Preferences.WEBSERVICE_FALLBACK_NETWORK, 0) * 60 * 60 * 1000));
lastSynched.close();
- return (System.currentTimeMillis() - synched >= Integer.parseInt(Aware.getSetting(mContext, Aware_Preferences.WEBSERVICE_FALLBACK_NETWORK)) * 60 * 60 * 1000);
+ return (System.currentTimeMillis() - synched >= Aware.getSettingAsInt(mContext, Aware_Preferences.WEBSERVICE_FALLBACK_NETWORK, 0) * 60 * 60 * 1000);
} else
return true; //first time synching.
}
@@ -397,33 +377,106 @@ private String[] getTableColumnsNames(Uri CONTENT_URI, Context mContext) {
return columnsStr;
}
- private String getLatestRecordSynched(String database_table, String[] columnsStr) {
+ /**
+ * Where a table's upload stands: the ordering value it reached, and the row it stopped on.
+ *
+ * The pair is the cursor. On a table paged by row id the value carries the last row's capture
+ * time and only the id decides what comes next; on a table paged by completion both halves do,
+ * the value ordering the rows and the id separating rows that finished in the same millisecond.
+ */
+ private static final class Position {
+ long value;
+ long rowId;
- JSONObject latest = new JSONObject();
- long last_sync_timestamp;
+ Position(long value, long rowId) {
+ this.value = value;
+ this.rowId = rowId;
+ }
+ }
- Cursor lastSynched = mContext.getContentResolver().query(Aware_Provider.Aware_Log.CONTENT_URI, null, Aware_Provider.Aware_Log.LOG_MESSAGE + " LIKE '{\"table\":\"" + database_table + "\",\"last_sync_timestamp\":%'", null, Aware_Provider.Aware_Log.LOG_TIMESTAMP + " DESC LIMIT 1");
- if (lastSynched != null && lastSynched.moveToFirst()) {
- try {
- JSONObject logSyncData = new JSONObject(lastSynched.getString(lastSynched.getColumnIndex(Aware_Provider.Aware_Log.LOG_MESSAGE)));
- last_sync_timestamp = logSyncData.getLong("last_sync_timestamp");
-
- if (exists(columnsStr, "double_end_timestamp")) {
- latest = new JSONObject().put("double_end_timestamp", last_sync_timestamp);
- } else if (exists(columnsStr, "double_esm_user_answer_timestamp")) {
- latest = new JSONObject().put("double_esm_user_answer_timestamp", last_sync_timestamp);
- } else {
- latest = new JSONObject().put("timestamp", last_sync_timestamp);
- }
- } catch (JSONException e) {
- e.printStackTrace();
- }
- lastSynched.close();
- } else {
- return new JSONArray().toString();
+ /** What became of one offered batch. */
+ private static final class BatchOutcome {
+ boolean acknowledged;
+ int rows;
+ /** Highest row id the batch carried, which cleanup deletes through. */
+ long maxRowId;
+ /** The position the last row of the batch leaves the cursor at. */
+ Position cursor;
+
+ BatchOutcome(Position cursor) {
+ this.cursor = cursor;
}
+ }
- return new JSONArray().put(latest).toString();
+ /**
+ * The position {@code database_table} resumes its upload from, opening at the start of the table
+ * for one whose rows have never been offered.
+ *
+ * The marker is keyed by table name in its own table, so finding it is an equality match on one
+ * row rather than a pattern match over log text.
+ *
+ * A marker holding a timestamp alone names a position on a table paged by row id, so it is
+ * translated once into the id of the newest row at or before it. Everything at that instant is
+ * treated as delivered, which is the reading that offers no row twice.
+ */
+ private Position getSyncCursor(String database_table, Uri CONTENT_URI, String[] columnsStr,
+ String study_condition) {
+ long cursorId = 0;
+ long markerValue = 0;
+
+ Cursor marker = mContext.getContentResolver().query(
+ Aware_Provider.Aware_Sync_Markers.CONTENT_URI, null,
+ Aware_Provider.Aware_Sync_Markers.MARKER_TABLE + "=?",
+ new String[]{database_table}, null);
+
+ if (marker != null && marker.moveToFirst()) {
+ int idIndex = marker.getColumnIndex(Aware_Provider.Aware_Sync_Markers.MARKER_LAST_ID);
+ if (idIndex >= 0) cursorId = marker.getLong(idIndex);
+ markerValue = marker.getLong(
+ marker.getColumnIndex(Aware_Provider.Aware_Sync_Markers.MARKER_LAST_SYNCED));
+ }
+ if (marker != null && !marker.isClosed()) marker.close();
+
+ Position cursor = new Position(markerValue, cursorId);
+
+ if (SyncCursor.needsSeeding(columnsStr, cursorId, markerValue)) {
+ cursor.rowId = highestRowIdUpTo(CONTENT_URI, markerValue, study_condition);
+ setSyncCursor(database_table, cursor);
+ if (Aware.DEBUG)
+ Log.d(Aware.TAG, database_table + ": upload resumes from row id " + cursor.rowId);
+ }
+
+ return cursor;
+ }
+
+ /** The newest row captured at or before {@code timestamp}, as its row id. */
+ private long highestRowIdUpTo(Uri CONTENT_URI, long timestamp, String study_condition) {
+ long rowId = 0;
+ Cursor newest = mContext.getContentResolver().query(CONTENT_URI,
+ new String[]{SyncCursor.ROW_ID},
+ "timestamp <= " + timestamp + (study_condition == null ? "" : study_condition),
+ null, SyncCursor.ROW_ID + " DESC LIMIT 1");
+ if (newest != null && newest.moveToFirst()) {
+ rowId = newest.getLong(newest.getColumnIndex(SyncCursor.ROW_ID));
+ }
+ if (newest != null && !newest.isClosed()) newest.close();
+ return rowId;
+ }
+
+ /**
+ * Records how far {@code database_table} has been uploaded.
+ *
+ * Written after the server acknowledges a batch, and read by the next sync of that table to pick
+ * up where this one stopped. The provider replaces the table's previous marker, so this holds one
+ * row per synced table. The value half is the instant the table is current to, which is what the
+ * upload-health and diagnostics screens report.
+ */
+ private void setSyncCursor(String database_table, Position cursor) {
+ ContentValues marker = new ContentValues();
+ marker.put(Aware_Provider.Aware_Sync_Markers.MARKER_TABLE, database_table);
+ marker.put(Aware_Provider.Aware_Sync_Markers.MARKER_LAST_ID, cursor.rowId);
+ marker.put(Aware_Provider.Aware_Sync_Markers.MARKER_LAST_SYNCED, cursor.value);
+ mContext.getContentResolver().insert(Aware_Provider.Aware_Sync_Markers.CONTENT_URI, marker);
}
private String getStudySyncCondition(Context mContext, String DATABASE_TABLE) {
@@ -445,127 +498,73 @@ private String getStudySyncCondition(Context mContext, String DATABASE_TABLE) {
return study_condition;
}
- private int getNumberOfRecordsToSync(Uri CONTENT_URI, String[] columnsStr, String latest, String study_condition, Context mContext) throws JSONException {
- if (latest == null) return 0;
-
- JSONArray remoteData = new JSONArray(latest);
- Log.d(Aware.TAG, "Remote Data: " + remoteData.toString());
-
- int TOTAL_RECORDS = 0;
- if (remoteData.length() == 0) {
- if (exists(columnsStr, "double_end_timestamp")) {
- Cursor counter = mContext.getContentResolver().query(CONTENT_URI, null, "double_end_timestamp != 0" + study_condition, null, "_id ASC");
- Log.d(Aware.TAG, "Query: double_end_timestamp != 0" + study_condition);
- if (counter != null && counter.moveToFirst()) {
- TOTAL_RECORDS = counter.getCount();
- counter.close();
- }
- if (counter != null && !counter.isClosed()) counter.close();
- } else if (exists(columnsStr, "double_esm_user_answer_timestamp")) {
- Cursor counter = mContext.getContentResolver().query(CONTENT_URI, null, "double_esm_user_answer_timestamp != 0" + study_condition, null, "_id ASC");
- Log.d(Aware.TAG, "Query: double_esm_user_answer_timestamp != 0" + study_condition);
- if (counter != null && counter.moveToFirst()) {
- TOTAL_RECORDS = counter.getCount();
- counter.close();
- }
- if (counter != null && !counter.isClosed()) counter.close();
- } else {
- Cursor counter = mContext.getContentResolver().query(CONTENT_URI, null, "1" + study_condition, null, "_id ASC");
- Log.d(Aware.TAG, "Query: 1" + study_condition);
- if (counter != null && counter.moveToFirst()) {
- TOTAL_RECORDS = counter.getCount();
- counter.close();
- }
- if (counter != null && !counter.isClosed()) counter.close();
- }
- } else {
- long last;
- if (exists(columnsStr, "double_end_timestamp")) {
- if (remoteData.getJSONObject(0).has("double_end_timestamp")) {
- last = remoteData.getJSONObject(0).getLong("double_end_timestamp");
- Cursor counter = mContext.getContentResolver().query(CONTENT_URI, null, "timestamp > " + last + " AND double_end_timestamp != 0" + study_condition, null, "_id ASC");
- Log.d(Aware.TAG, "Query: timestamp > " + last + " AND double_end_timestamp != 0" + study_condition);
- if (counter != null && counter.moveToFirst()) {
- TOTAL_RECORDS = counter.getCount();
- counter.close();
- }
- if (counter != null && !counter.isClosed()) counter.close();
- }
- } else if (exists(columnsStr, "double_esm_user_answer_timestamp")) {
- if (remoteData.getJSONObject(0).has("double_esm_user_answer_timestamp")) {
- last = remoteData.getJSONObject(0).getLong("double_esm_user_answer_timestamp");
- Cursor counter = mContext.getContentResolver().query(CONTENT_URI, null, "timestamp > " + last + " AND double_esm_user_answer_timestamp != 0" + study_condition, null, "_id ASC");
- Log.d(Aware.TAG, "Query: timestamp > " + last + " AND double_esm_user_answer_timestamp != 0" + study_condition);
- if (counter != null && counter.moveToFirst()) {
- TOTAL_RECORDS = counter.getCount();
- counter.close();
- }
- if (counter != null && !counter.isClosed()) counter.close();
- }
- } else {
- if (remoteData.getJSONObject(0).has("timestamp")) {
- last = remoteData.getJSONObject(0).getLong("timestamp");
- Cursor counter = mContext.getContentResolver().query(CONTENT_URI, null, "timestamp > " + last + study_condition, null, "_id ASC");
- Log.d(Aware.TAG, "Query: timestamp > " + last + study_condition);
- if (counter != null && counter.moveToFirst()) {
- TOTAL_RECORDS = counter.getCount();
- counter.close();
- }
- if (counter != null && !counter.isClosed()) counter.close();
- }
- }
+ /**
+ * Rows of {@code CONTENT_URI} still to upload: those past the cursor, and finished where the
+ * table gates a row on a completion column.
+ *
+ * Counted rather than estimated, so the batch loop knows when it has drained the table.
+ */
+ private int getNumberOfRecordsToSync(Uri CONTENT_URI, String[] columnsStr, Position cursor,
+ String study_condition, Context mContext) {
+ int total = 0;
+ String selection = SyncCursor.selection(columnsStr, cursor.value, cursor.rowId, study_condition);
+ Cursor counter = mContext.getContentResolver().query(CONTENT_URI,
+ new String[]{SyncCursor.ROW_ID}, selection, null, null);
+ if (counter != null) {
+ total = counter.getCount();
+ counter.close();
}
- return TOTAL_RECORDS;
+ if (Aware.DEBUG) Log.d(Aware.TAG, "Rows to sync where " + selection + ": " + total);
+ return total;
}
-
- private Cursor getSyncData(JSONArray remoteData, Uri CONTENT_URI, String study_condition, String[] columnsStr, int uploaded_records, Context mContext, int MAX_POST_SIZE) throws JSONException {
- Cursor context_data = null;
- if (remoteData.length() == 0) {
- if (exists(columnsStr, "double_end_timestamp")) {
- context_data = mContext.getContentResolver().query(CONTENT_URI, null, "double_end_timestamp != 0" + study_condition, null, "_id ASC LIMIT " + uploaded_records + ", " + MAX_POST_SIZE);
- } else if (exists(columnsStr, "double_esm_user_answer_timestamp")) {
- context_data = mContext.getContentResolver().query(CONTENT_URI, null, "double_esm_user_answer_timestamp != 0" + study_condition, null, "_id ASC LIMIT " + uploaded_records + ", " + MAX_POST_SIZE);
- } else {
- context_data = mContext.getContentResolver().query(CONTENT_URI, null, "1" + study_condition, null, "timestamp ASC LIMIT " + uploaded_records + ", " + MAX_POST_SIZE);
- }
- } else {
- long last;
- if (exists(columnsStr, "double_end_timestamp")) {
- if (remoteData.getJSONObject(0).has("double_end_timestamp")) {
- last = remoteData.getJSONObject(0).getLong("double_end_timestamp");
- context_data = mContext.getContentResolver().query(CONTENT_URI, null, "timestamp > " + last + " AND double_end_timestamp != 0" + study_condition, null, "_id ASC LIMIT " + uploaded_records + ", " + MAX_POST_SIZE);
- }
- } else if (exists(columnsStr, "double_esm_user_answer_timestamp")) {
- if (remoteData.getJSONObject(0).has("double_esm_user_answer_timestamp")) {
- last = remoteData.getJSONObject(0).getLong("double_esm_user_answer_timestamp");
- context_data = mContext.getContentResolver().query(CONTENT_URI, null, "timestamp > " + last + " AND double_esm_user_answer_timestamp != 0" + study_condition, null, "_id ASC LIMIT " + uploaded_records + ", " + MAX_POST_SIZE);
- }
- } else {
- if (remoteData.getJSONObject(0).has("timestamp")) {
- last = remoteData.getJSONObject(0).getLong("timestamp");
- context_data = mContext.getContentResolver().query(CONTENT_URI, null, "timestamp > " + last + study_condition, null, "_id ASC LIMIT " + uploaded_records + ", " + MAX_POST_SIZE);
- }
- }
- }
- return context_data;
+ /**
+ * One batch, read from the cursor forward in insertion order.
+ *
+ * The window is decided by the cursor alone rather than by an offset into the result set, so a
+ * row inserted while this run is in flight lands after the cursor and is read by a later batch
+ * instead of shifting the rows this one takes.
+ */
+ private Cursor getSyncData(Uri CONTENT_URI, String study_condition, String[] columnsStr,
+ Position cursor, Context mContext, int MAX_POST_SIZE) {
+ return mContext.getContentResolver().query(CONTENT_URI, null,
+ SyncCursor.selection(columnsStr, cursor.value, cursor.rowId, study_condition), null,
+ SyncCursor.order(columnsStr, MAX_POST_SIZE));
}
- private void performDatabaseSpaceMaintenance(Uri CONTENT_URI, long last, String[] columnsStr, Boolean WEBSERVICE_REMOVE_DATA, Context mContext, String DATABASE_TABLE, Boolean DEBUG) {
+ /**
+ * Frees local space once the server holds the rows.
+ *
+ * @param lastId highest row id the server acknowledged in this run
+ * @param lastRun instant that acknowledged row was captured, which dates the retention windows
+ */
+ private void performDatabaseSpaceMaintenance(Uri CONTENT_URI, long lastRun, long lastId, String[] columnsStr, Boolean WEBSERVICE_REMOVE_DATA, Context mContext, String DATABASE_TABLE, Boolean DEBUG) {
// keep records when contain end_timestamp (session-based entries), only remove the rows where the end_timestamp > 0
String deleteSessionBasedSensors = "";
if (exists(columnsStr, "double_end_timestamp")) {
deleteSessionBasedSensors = " and double_end_timestamp > 0";
}
+ // A row still to finish is a row still to upload, whichever column the table finishes on, so
+ // the deletion leaves it where an unanswered prompt and an open session are alike.
+ String keepUnfinished = "";
+ String completion = SyncCursor.completionColumn(columnsStr);
+ if (completion != null) keepUnfinished = " AND " + completion + " != 0";
+
if (WEBSERVICE_REMOVE_DATA) {
- mContext.getContentResolver().delete(CONTENT_URI, "timestamp <= " + last, null);
+ // Keyed on _id (insertion order) rather than timestamp (capture order), so the rows
+ // removed are the rows this run had acknowledged. A sample a sensor buffered and
+ // inserted after the batch was read carries a higher _id and an older capture time, and
+ // it stays until it has been uploaded and acknowledged itself.
+ if (lastId > 0)
+ mContext.getContentResolver().delete(CONTENT_URI,
+ SyncCursor.ROW_ID + " <= " + lastId + keepUnfinished, null);
} else if (Aware.getSetting(mContext, Aware_Preferences.FREQUENCY_CLEAN_OLD_DATA).length() > 0) {
Calendar cal = Calendar.getInstance();
- cal.setTimeInMillis(last);
+ cal.setTimeInMillis(lastRun);
int rowsDeleted = 0;
- switch (Integer.parseInt(Aware.getSetting(mContext, Aware_Preferences.FREQUENCY_CLEAN_OLD_DATA))) {
+ switch (Aware.getSettingAsInt(mContext, Aware_Preferences.FREQUENCY_CLEAN_OLD_DATA, 0)) {
case 1: //Weekly
cal.add(Calendar.DAY_OF_YEAR, -7);
if (Aware.DEBUG)
@@ -584,9 +583,12 @@ private void performDatabaseSpaceMaintenance(Uri CONTENT_URI, long last, String[
Log.d(Aware.TAG, "Cleaning locally any data older than today (yyyy/mm/dd): " + cal.get(Calendar.YEAR) + '/' + (cal.get(Calendar.MONTH) + 1) + '/' + cal.get(Calendar.DAY_OF_MONTH) + " from " + CONTENT_URI.toString());
rowsDeleted = mContext.getContentResolver().delete(CONTENT_URI, "timestamp < " + cal.getTimeInMillis() + deleteSessionBasedSensors, null);
break;
- case 4: //Always (experimental)
- if (highFrequencySensors.contains(DATABASE_TABLE))
- rowsDeleted = mContext.getContentResolver().delete(CONTENT_URI, "timestamp <= " + last, null);
+ case 4: //Always — remove acknowledged rows only.
+ // Key the deletion on _id (insertion order), not timestamp (capture order): a
+ // sample a sensor buffered and inserted after this sync read gets a higher _id,
+ // so it is never deleted before it has itself been uploaded and acknowledged.
+ if (highFrequencySensors.contains(DATABASE_TABLE) && lastId > 0)
+ rowsDeleted = mContext.getContentResolver().delete(CONTENT_URI, "_id <= " + lastId + keepUnfinished, null);
break;
}
@@ -595,78 +597,181 @@ private void performDatabaseSpaceMaintenance(Uri CONTENT_URI, long last, String[
}
}
- private Long syncBatch(Cursor context_data, String DATABASE_TABLE, String DEVICE_ID, Context mContext, String protocol, String WEBSERVER, Boolean DEBUG) throws JSONException {
+ /**
+ * Offers one batch to the server and reports what became of it.
+ *
+ * Acknowledged with a row count of 0 is a batch that read no rows, which is a drained table
+ * rather than a refusal.
+ */
+ private BatchOutcome syncBatch(Cursor context_data, String DATABASE_TABLE, String DEVICE_ID, Context mContext, Boolean DEBUG, Position from) throws JSONException {
JSONArray rows = new JSONArray();
+ BatchOutcome outcome = new BatchOutcome(from);
+ // A read that produced no cursor offered the server nothing, so nothing was refused: the
+ // caller ends the run on the row count rather than reporting the server turned a batch away.
+ if (context_data == null) {
+ outcome.acknowledged = true;
+ return outcome;
+ }
+ String orderColumn = SyncCursor.orderColumn(getColumnNames(context_data));
long lastSynced = 0;
- if (context_data != null && context_data.moveToFirst()) {
+ long lastOrderValue = from.value;
+ long lastRowId = from.rowId;
+ long maxId = 0;
+ long payloadBytes = 0;
+ boolean cappedByPayload = false;
+
+ // A read that found no rows still holds a cursor, and this runs once per table per sync
+ // event on every table that is up to date.
+ if (context_data != null && !context_data.moveToFirst()) {
+ context_data.close();
+ outcome.acknowledged = true;
+ return outcome;
+ }
+
+ if (context_data != null) {
do {
JSONObject row = new JSONObject();
+ long rowBytes = 0;
+ long rowId = 0;
String[] columns = context_data.getColumnNames();
for (String c_name : columns) {
- if (c_name.equals("_id")) continue; // Skip local database ID
+ if (c_name.equals("_id")) {
+ // Track the highest local row id in this batch so acknowledged rows can be
+ // deleted by _id (insertion order). A sample a sensor buffers and inserts
+ // after this read gets a higher _id, so it is never deleted before it has
+ // itself been uploaded.
+ rowId = context_data.getLong(context_data.getColumnIndex("_id"));
+ continue; // still skip the local id from the uploaded payload
+ }
if (c_name.equals("timestamp") || c_name.contains("double")) {
row.put(c_name, context_data.getDouble(context_data.getColumnIndex(c_name)));
+ rowBytes += SyncBatchBudget.columnBytes(c_name, SyncBatchBudget.NUMERIC_VALUE_BYTES);
} else if (c_name.contains("float")) {
row.put(c_name, context_data.getFloat(context_data.getColumnIndex(c_name)));
+ rowBytes += SyncBatchBudget.columnBytes(c_name, SyncBatchBudget.NUMERIC_VALUE_BYTES);
} else if (c_name.contains("long")) {
row.put(c_name, context_data.getLong(context_data.getColumnIndex(c_name)));
+ rowBytes += SyncBatchBudget.columnBytes(c_name, SyncBatchBudget.NUMERIC_VALUE_BYTES);
} else if (c_name.contains("blob") || c_name.contains("image_data")) {
byte[] blob = context_data.getBlob(context_data.getColumnIndex(c_name));
- Log.d(Aware.TAG, "BLOB data length: " + blob.length);
- row.put(c_name, Base64.encodeToString(blob, Base64.DEFAULT));
+ String encoded = blob == null ? "" : Base64.encodeToString(blob, Base64.DEFAULT);
+ row.put(c_name, encoded);
+ rowBytes += SyncBatchBudget.columnBytes(c_name, encoded.length());
} else if (c_name.contains("integer")) {
row.put(c_name, context_data.getInt(context_data.getColumnIndex(c_name)));
+ rowBytes += SyncBatchBudget.columnBytes(c_name, SyncBatchBudget.NUMERIC_VALUE_BYTES);
} else {
String str = "";
if (!context_data.isNull(context_data.getColumnIndex(c_name))) { // Fixes nulls and batch inserts not being possible
str = context_data.getString(context_data.getColumnIndex(c_name));
}
+ // Last line of defence for a row stored before device_id could be resolved.
+ // Uploading it blank puts a row on the server that no participant can be
+ // matched to, and the stored copy is the only place left to repair it from.
+ if (c_name.equals("device_id") && DeviceId.trimToEmpty(str).isEmpty()
+ && !DeviceId.trimToEmpty(DEVICE_ID).isEmpty()) {
+ str = DEVICE_ID;
+ if (DEBUG) Log.d(Aware.TAG, DATABASE_TABLE
+ + ": stamped a row that had no device_id before uploading it");
+ }
row.put(c_name, str);
+ rowBytes += SyncBatchBudget.columnBytes(c_name, str.length());
}
}
+
+ // Stop before the payload outgrows what the phone can hold and the server will
+ // accept. The caller resumes from the rows actually taken, so a held-back row is the
+ // first row of the next batch rather than a skipped one.
+ if (SyncBatchBudget.holdForNextBatch(rows.length(), payloadBytes, rowBytes,
+ SyncBatchBudget.MAX_PAYLOAD_BYTES)) {
+ cappedByPayload = true;
+ break;
+ }
+
rows.put(row);
+ payloadBytes += rowBytes;
+ if (rowId > maxId) maxId = rowId;
+ // The cursor stands on the last row the batch read in the order the table is paged
+ // by, which on a table ordered by completion is not the row with the highest id.
+ lastRowId = rowId;
+ lastOrderValue = orderColumn.equals(SyncCursor.ROW_ID)
+ ? rowId
+ : (long) context_data.getDouble(context_data.getColumnIndex(orderColumn));
} while (context_data.moveToNext());
context_data.close(); // Clear phone's memory immediately
- lastSynced = rows.getJSONObject(rows.length() - 1).getLong("timestamp"); // Last record to be synced
- // For some tables, we must not clear everything. Leave one row of these tables.
- if (dontClearSensors.contains(DATABASE_TABLE)) {
- if (rows.length() >= 2) {
- lastSynced = rows.getJSONObject(rows.length() - 2).getLong("timestamp"); // Last record to be synced
- } else {
- lastSynced = 0;
- }
+ if (rows.length() == 0) {
+ outcome.acknowledged = true;
+ return outcome;
}
- boolean dataInserted = Jdbc.insertData(mContext, DATABASE_TABLE, rows);
+ if (DEBUG && cappedByPayload)
+ Log.d(Aware.TAG, DATABASE_TABLE + " batch capped at " + rows.length()
+ + " row(s) / ~" + (payloadBytes / 1024) + " KB by the payload budget");
+
+ lastSynced = rows.getJSONObject(rows.length() - 1).getLong("timestamp"); // Last record to be synced
- // Something went wrong, e.g., server is down, lost internet, etc.
+ // Which path carries the rows is the study's choice, and both answer the same
+ // question: did the server take them. On the webservice path the phone holds
+ // no database credential at all, which is what lets the database stay private.
+ boolean viaWebservice = Webservice.enabled(mContext);
+ boolean dataInserted = viaWebservice
+ ? Webservice.insertData(mContext, DATABASE_TABLE, rows)
+ : Jdbc.insertData(mContext, DATABASE_TABLE, rows);
+
+ // The server did not acknowledge the batch. The path taken has already logged the
+ // underlying cause; unreachable server, rejected credentials and a rejected
+ // statement all arrive here alike, so this line does not guess between them.
if (!dataInserted) {
- if (DEBUG) Log.d(Aware.TAG, DATABASE_TABLE + " FAILED to sync. Server down?");
- return null;
+ if (DEBUG) Log.d(Aware.TAG, DATABASE_TABLE + ": batch of " + rows.length()
+ + " row(s) / ~" + (payloadBytes / 1024) + " KB was not acknowledged. See the "
+ + (viaWebservice ? "webservice" : "JDBC") + " log above.");
+ // Records the outage once rather than once per table per tick, and notifies the
+ // participant only if it lasts. The table name is the reason: which one first failed
+ // is the useful part, and it carries no credentials.
+ UploadHealth.recordFailure(mContext, DATABASE_TABLE, "batch not acknowledged");
+ return outcome;
} else {
- try {
- Aware.debug(mContext, new JSONObject()
- .put("table", DATABASE_TABLE)
- .put("last_sync_timestamp", lastSynced)
- .toString());
- } catch (JSONException e) {
- e.printStackTrace();
- }
+ // The batch was committed (acknowledged) by the database: report the position its
+ // last row leaves the cursor at, the highest id so cleanup deletes through exactly
+ // these rows and no further, and the row count so the caller resumes from what
+ // actually went rather than from the row-count cap it asked for.
+ outcome.acknowledged = true;
+ outcome.rows = rows.length();
+ outcome.maxRowId = maxId;
+ boolean byRowId = orderColumn.equals(SyncCursor.ROW_ID);
+ outcome.cursor = new Position(byRowId ? lastSynced : lastOrderValue,
+ byRowId ? maxId : lastRowId);
+ UploadHealth.recordSuccess(mContext, DATABASE_TABLE);
if (DEBUG)
Log.d(Aware.TAG, "Sync OK into " + DATABASE_TABLE + " [ " + rows.length() + " rows ]");
}
}
- return lastSynced;
+ return outcome;
}
+ /**
+ * Tables holding device state rather than a stream of observations are kept locally after
+ * upload: the study enrolment, the defined schedulers, and the device profile.
+ *
+ * The device profile has to survive because the phone compares against its stored row to decide
+ * whether the device's facts or participant-facing label have changed (see
+ * {@code Aware.get_device_info()}). A locally deleted row makes the comparison find nothing and
+ * label updates match nothing.
+ */
private boolean isTableAllowedForMaintenance(String table_name) {
- //we always keep locally the information of the study and defined schedulers.
- return !table_name.equalsIgnoreCase("aware_studies") && !table_name.equalsIgnoreCase("scheduler");
+ return !table_name.equalsIgnoreCase("aware_studies")
+ && !table_name.equalsIgnoreCase("scheduler")
+ && !table_name.equalsIgnoreCase("aware_device");
+ }
+
+ /** A cursor's column names, or an empty list when the read produced no cursor. */
+ private static String[] getColumnNames(Cursor cursor) {
+ return cursor == null ? new String[0] : cursor.getColumnNames();
}
private static boolean exists(String[] array, String find) {
diff --git a/aware-core/src/main/java/com/aware/ui/PermissionSequence.java b/aware-core/src/main/java/com/aware/ui/PermissionSequence.java
new file mode 100644
index 00000000..4986ba16
--- /dev/null
+++ b/aware-core/src/main/java/com/aware/ui/PermissionSequence.java
@@ -0,0 +1,102 @@
+package com.aware.ui;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Pure, Android-free core of {@link PermissionsHandler}'s request sequencing. Given the permissions a
+ * sensor needs and a way to ask whether each is currently granted, it decides which permission to
+ * prompt for next — skipping any already granted, including a group sibling the OS granted alongside
+ * another (e.g. COARSE once FINE is allowed) — and, once finished, whether the requesting service may
+ * be restarted.
+ *
+ * Extracted from the Activity so the two rules that matter can be unit-tested without the framework:
+ * every permission is prompted at most once and already-granted ones never again, and a denial never
+ * asks the caller to restart — the restart-after-denial is exactly what spins the unbreakable "Allow ..."
+ * prompt loop this guards against.
+ */
+public class PermissionSequence {
+
+ /** Answers whether a permission is currently granted (backed by the OS in production, faked in tests). */
+ public interface GrantChecker {
+ boolean isGranted(String permission);
+ }
+
+ private final List permissions;
+ private final GrantChecker checker;
+ private int index = 0;
+ private boolean anyDenied = false;
+
+ public PermissionSequence(List permissions, GrantChecker checker) {
+ this.permissions = permissions != null ? new ArrayList<>(permissions) : new ArrayList();
+ this.checker = checker;
+ }
+
+ /**
+ * The next permission to prompt for, or {@code null} when none remain. Advances past — and so never
+ * returns — any permission already granted, which is what makes granting one member of a permission
+ * group (the OS reports the siblings as granted too) skip prompting for those siblings.
+ */
+ public String nextToRequest() {
+ while (index < permissions.size() && checker.isGranted(permissions.get(index))) {
+ index++;
+ }
+ return index < permissions.size() ? permissions.get(index) : null;
+ }
+
+ /** Record the system result for the permission at the current index and advance to the next. */
+ public void onResult(boolean granted) {
+ if (!granted) anyDenied = true;
+ index++;
+ }
+
+ /** Record that the user skipped ("Not now") the current permission and advance. */
+ public void onSkipped() {
+ anyDenied = true;
+ index++;
+ }
+
+ /** Decline every remaining item, used when the participant cancels the permission flow. */
+ public void cancelRemaining() {
+ if (index < permissions.size()) anyDenied = true;
+ index = permissions.size();
+ }
+
+ /** True once every permission has been handled (all either granted or advanced past). */
+ public boolean isDone() {
+ return nextToRequest() == null;
+ }
+
+ public boolean anyDenied() {
+ return anyDenied;
+ }
+
+ /**
+ * Whether the requesting service should be restarted now the sequence is finished. Only when every
+ * permission ended up granted: restarting after a denial makes the service find the permission still
+ * missing and relaunch the handler, an unbreakable prompt loop.
+ */
+ public boolean shouldRestartService() {
+ return !anyDenied;
+ }
+
+ /** What the handler should do with the current permission once a system result comes back. */
+ public enum ResultAction {
+ /** Record the outcome (a grant, or a denial the OS will still re-prompt) and move on. */
+ ADVANCE,
+ /** The permission is blocked — asking again is a no-op — so offer the app-settings route. */
+ PROMPT_SETTINGS
+ }
+
+ /**
+ * Given a system permission result, decide whether to just advance the sequence or offer the
+ * app-settings route. A grant, or a denial the OS will still let us re-prompt (rationale allowed),
+ * simply advances. A denial the OS will not re-prompt — blocked via "don't ask again" or a second
+ * denial, or already blocked so no dialog even appeared — means requesting again is a silent no-op,
+ * so a user who asked to allow it has to be sent to app settings to grant it by hand.
+ */
+ public static ResultAction actionAfterResult(boolean granted, boolean shouldShowRationale) {
+ if (granted) return ResultAction.ADVANCE;
+ return shouldShowRationale ? ResultAction.ADVANCE : ResultAction.PROMPT_SETTINGS;
+ }
+}
diff --git a/aware-core/src/main/java/com/aware/ui/PermissionsHandler.java b/aware-core/src/main/java/com/aware/ui/PermissionsHandler.java
index 2a53d554..9e0d7df2 100644
--- a/aware-core/src/main/java/com/aware/ui/PermissionsHandler.java
+++ b/aware-core/src/main/java/com/aware/ui/PermissionsHandler.java
@@ -1,12 +1,17 @@
package com.aware.ui;
import android.app.Activity;
+import android.app.AlertDialog;
import android.content.ComponentName;
+import android.content.DialogInterface;
import android.content.Intent;
import android.content.pm.PackageManager;
+import android.net.Uri;
import android.os.Bundle;
+import android.provider.Settings;
import android.util.Log;
import androidx.core.app.ActivityCompat;
+import androidx.core.content.ContextCompat;
import com.aware.Aware;
import java.util.ArrayList;
@@ -46,6 +51,14 @@ public class PermissionsHandler extends Activity {
private Intent redirect_activity, redirect_service;
+ private PermissionSequence sequence;
+ private boolean sequenceStarted = false;
+ private AlertDialog rationaleDialog;
+
+ // Set to the permission we sent the user to app settings for; re-checked when they return so a
+ // blocked permission granted there is picked up, and one still refused is skipped rather than looped.
+ private String awaitingSettingsFor;
+
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
@@ -55,9 +68,31 @@ protected void onCreate(Bundle savedInstanceState) {
@Override
protected void onResume() {
super.onResume();
+
+ // Coming back from the app-settings screen: pick up the permission if it was granted there;
+ // if it's still refused, count it denied and move past it so we don't re-offer the same one.
+ if (awaitingSettingsFor != null) {
+ String permission = awaitingSettingsFor;
+ awaitingSettingsFor = null;
+ boolean granted = ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED;
+ Log.d(Aware.TAG, permission + (granted ? " was granted in settings" : " still not granted after settings"));
+ if (!granted) sequence.onResult(false);
+ requestNextPermission();
+ return;
+ }
+
+ // Returning from a system permission dialog re-runs onResume; don't restart the sequence.
+ if (sequenceStarted) return;
+
if (getIntent() != null && getIntent().getExtras() != null && getIntent().getSerializableExtra(EXTRA_REQUIRED_PERMISSIONS) != null) {
ArrayList permissionsNeeded = (ArrayList) getIntent().getSerializableExtra(EXTRA_REQUIRED_PERMISSIONS);
- ActivityCompat.requestPermissions(PermissionsHandler.this, permissionsNeeded.toArray(new String[permissionsNeeded.size()]), RC_PERMISSIONS);
+ sequence = new PermissionSequence(permissionsNeeded, new PermissionSequence.GrantChecker() {
+ @Override
+ public boolean isGranted(String permission) {
+ return ContextCompat.checkSelfPermission(PermissionsHandler.this, permission) == PackageManager.PERMISSION_GRANTED;
+ }
+ });
+
if (getIntent().hasExtra(EXTRA_REDIRECT_ACTIVITY)) {
redirect_activity = new Intent();
String[] component = getIntent().getStringExtra(EXTRA_REDIRECT_ACTIVITY).split("/");
@@ -69,64 +104,199 @@ protected void onResume() {
String[] component = getIntent().getStringExtra(EXTRA_REDIRECT_SERVICE).split("/");
redirect_service.setComponent(new ComponentName(component[0], component[1]));
}
+
+ sequenceStarted = true;
+ requestNextPermission();
} else {
Intent activity = new Intent();
setResult(Activity.RESULT_OK, activity);
finish();
}
+ }
+
+ /**
+ * Requests the next not-yet-granted permission on its own, after a short rationale. Already-granted
+ * permissions are skipped. When none remain, hands back to the caller via {@link #finishWithResult()}.
+ */
+ private void requestNextPermission() {
+ final String permission = sequence.nextToRequest();
+ if (permission == null) {
+ finishWithResult();
+ return;
+ }
+ rationaleDialog = new AlertDialog.Builder(this)
+ .setTitle(permissionTitle(permission))
+ .setMessage(permissionRationale(permission))
+ .setCancelable(true)
+ .setPositiveButton("Continue", new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ ActivityCompat.requestPermissions(PermissionsHandler.this, new String[]{permission}, RC_PERMISSIONS);
+ }
+ })
+ .setNegativeButton("Not now", new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ Log.d(Aware.TAG, permission + " was skipped");
+ sequence.onSkipped();
+ requestNextPermission();
+ }
+ })
+ .setOnCancelListener(new DialogInterface.OnCancelListener() {
+ @Override
+ public void onCancel(DialogInterface dialog) {
+ sequence.cancelRemaining();
+ finishWithResult();
+ }
+ })
+ .show();
}
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
if (requestCode == RC_PERMISSIONS) {
- int not_granted = 0;
- for (int i = 0; i < permissions.length; i++) {
- if (grantResults[i] != PackageManager.PERMISSION_GRANTED) {
- not_granted++;
- Log.d(Aware.TAG, permissions[i] + " was not granted");
- } else {
- Log.d(Aware.TAG, permissions[i] + " was granted");
- }
- }
+ // One permission is requested per prompt. An empty result (dialog cancelled) counts as not
+ // granted, so it can't be mistaken for success and restart the loop.
+ String permission = permissions.length > 0 ? permissions[0] : null;
+ boolean granted = grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED;
+ if (permission != null) Log.d(Aware.TAG, permission + (granted ? " was granted" : " was not granted"));
- if (not_granted > 0) {
- if (redirect_activity == null) {
- Intent activity = new Intent();
- setResult(Activity.RESULT_CANCELED, activity);
- }
- if (redirect_activity != null) {
- setResult(Activity.RESULT_CANCELED, redirect_activity);
- startActivity(redirect_activity);
- }
- if (redirect_service != null) {
- startService(redirect_service);
- }
- finish();
+ boolean rationale = permission != null
+ && ActivityCompat.shouldShowRequestPermissionRationale(this, permission);
+ if (permission != null
+ && PermissionSequence.actionAfterResult(granted, rationale) == PermissionSequence.ResultAction.PROMPT_SETTINGS) {
+ // Blocked: requesting again is a silent no-op (this is why the system dialog stopped
+ // appearing). Offer the app-settings route instead of leaving the tap doing nothing.
+ showBlockedDialog(permission);
} else {
- if (redirect_activity == null) {
- Intent activity = new Intent();
- setResult(Activity.RESULT_OK, activity);
- }
- finish();
+ sequence.onResult(granted);
+ requestNextPermission();
}
} else {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
}
}
+ /**
+ * Sets the activity result once every permission has been handled and finishes; the redirect back
+ * to the caller (activity or service) is performed in {@link #onDestroy()}.
+ */
+ private void finishWithResult() {
+ int result = sequence.anyDenied() ? Activity.RESULT_CANCELED : Activity.RESULT_OK;
+ setResult(result, redirect_activity != null ? redirect_activity : new Intent());
+ finish();
+ }
+
+ /**
+ * Shown when a permission the user asked to allow is blocked (the OS won't prompt again). Offers to
+ * open the app's settings so they can grant it by hand, or to skip it. On return, {@link #onResume()}
+ * re-checks it via {@link #awaitingSettingsFor}.
+ */
+ private void showBlockedDialog(final String permission) {
+ rationaleDialog = new AlertDialog.Builder(this)
+ .setTitle(permissionTitle(permission))
+ .setMessage("AWARE can't ask for the " + humanLabel(permission) + " permission again because "
+ + "it was blocked. Enable it in Settings > Permissions for the sensor to work, or "
+ + "choose Not now to skip it.")
+ .setCancelable(true)
+ .setPositiveButton("Open settings", new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ awaitingSettingsFor = permission;
+ Intent settings = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
+ settings.setData(Uri.fromParts("package", getPackageName(), null));
+ settings.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ startActivity(settings);
+ }
+ })
+ .setNegativeButton("Not now", new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ Log.d(Aware.TAG, permission + " is blocked; skipped");
+ sequence.onResult(false);
+ requestNextPermission();
+ }
+ })
+ .setOnCancelListener(new DialogInterface.OnCancelListener() {
+ @Override
+ public void onCancel(DialogInterface dialog) {
+ sequence.cancelRemaining();
+ finishWithResult();
+ }
+ })
+ .show();
+ }
+
+ private static String permissionTitle(String permission) {
+ return "Allow " + humanLabel(permission);
+ }
+
+ private static String permissionRationale(String permission) {
+ return "AWARE needs the " + humanLabel(permission) + " permission to work as this study expects. "
+ + "Allow it on the next screen, or choose Not now to skip it.";
+ }
+
+ /** A short, human-readable name for a permission, for use in the rationale prompt. */
+ private static String humanLabel(String permission) {
+ if (permission == null) return "requested";
+ switch (permission) {
+ case "android.permission.ACCESS_FINE_LOCATION":
+ case "android.permission.ACCESS_COARSE_LOCATION":
+ return "Location";
+ case "android.permission.ACCESS_BACKGROUND_LOCATION":
+ return "Background location";
+ case "android.permission.READ_PHONE_STATE":
+ return "Phone";
+ case "android.permission.READ_CALL_LOG":
+ return "Call log";
+ case "android.permission.READ_SMS":
+ return "SMS";
+ case "android.permission.READ_EXTERNAL_STORAGE":
+ case "android.permission.WRITE_EXTERNAL_STORAGE":
+ return "Storage";
+ case "android.permission.GET_ACCOUNTS":
+ return "Accounts";
+ case "android.permission.BLUETOOTH_SCAN":
+ case "android.permission.BLUETOOTH_CONNECT":
+ return "Nearby devices";
+ case "android.permission.POST_NOTIFICATIONS":
+ return "Notifications";
+ case "android.permission.ACTIVITY_RECOGNITION":
+ return "Physical activity";
+ case "android.permission.RECORD_AUDIO":
+ return "Microphone";
+ case "android.permission.CAMERA":
+ return "Camera";
+ default:
+ String tail = permission.substring(permission.lastIndexOf('.') + 1);
+ return tail.replace('_', ' ').toLowerCase();
+ }
+ }
+
@Override
protected void onDestroy() {
super.onDestroy();
- if (redirect_service != null) {
+ if (rationaleDialog != null && rationaleDialog.isShowing()) rationaleDialog.dismiss();
+ // Only restart the requesting service when every permission was granted. Restarting it after a
+ // denial just makes Aware_Sensor.onStartCommand find the permission still missing and relaunch
+ // this handler, which spins an unbreakable "Allow ..." dialog loop (the redirect starts the
+ // service directly, so it keeps a consent-declined sensor alive regardless of its on/off status).
+ if (redirect_service != null && sequence != null && sequence.shouldRestartService()) {
Log.d(TAG, "Redirecting to Service: " + redirect_service.getComponent().toString());
redirect_service.setAction(ACTION_AWARE_PERMISSIONS_CHECK);
startService(redirect_service);
+ } else if (redirect_service != null) {
+ Log.d(TAG, "Not restarting " + redirect_service.getComponent().toString()
+ + ": user declined a required permission, so no re-prompt loop.");
}
- if (redirect_activity != null) {
+ if (redirect_activity != null && sequence != null && sequence.shouldRestartService()) {
Log.d(TAG, "Redirecting to Activity: " + redirect_activity.getComponent().toString());
setResult(Activity.RESULT_OK, redirect_activity);
startActivity(redirect_activity);
+ } else if (redirect_activity != null) {
+ Log.d(TAG, "Not redirecting to " + redirect_activity.getComponent().toString()
+ + ": a denied permission would immediately reopen this handler.");
}
Log.d("Permissions", "Handled permissions for " + getPackageName());
}
diff --git a/aware-core/src/main/java/com/aware/ui/esms/ESM_Date.java b/aware-core/src/main/java/com/aware/ui/esms/ESM_Date.java
index 910849b2..7deec216 100644
--- a/aware-core/src/main/java/com/aware/ui/esms/ESM_Date.java
+++ b/aware-core/src/main/java/com/aware/ui/esms/ESM_Date.java
@@ -20,9 +20,9 @@
import com.aware.ESM;
import com.aware.R;
import com.aware.providers.ESM_Provider;
+import com.aware.utils.UtcTime;
import org.json.JSONException;
-import java.text.SimpleDateFormat;
import java.util.Calendar;
/**
@@ -129,11 +129,9 @@ public void onClick(View v) {
if (getExpirationThreshold() > 0 && expire_monitor != null)
expire_monitor.cancel(true);
- SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd Z");
-
ContentValues rowData = new ContentValues();
rowData.put(ESM_Provider.ESM_Data.ANSWER_TIMESTAMP, System.currentTimeMillis());
- rowData.put(ESM_Provider.ESM_Data.ANSWER, dateFormat.format(datePicked.getTime()));
+ rowData.put(ESM_Provider.ESM_Data.ANSWER, UtcTime.pickedDate(datePicked));
rowData.put(ESM_Provider.ESM_Data.STATUS, ESM.STATUS_ANSWERED);
getActivity().getContentResolver().update(ESM_Provider.ESM_Data.CONTENT_URI, rowData, ESM_Provider.ESM_Data._ID + "=" + getID(), null);
diff --git a/aware-core/src/main/java/com/aware/ui/esms/ESM_DateTime.java b/aware-core/src/main/java/com/aware/ui/esms/ESM_DateTime.java
index 2623048d..3e1ccff7 100644
--- a/aware-core/src/main/java/com/aware/ui/esms/ESM_DateTime.java
+++ b/aware-core/src/main/java/com/aware/ui/esms/ESM_DateTime.java
@@ -24,10 +24,10 @@
import com.aware.ESM;
import com.aware.R;
import com.aware.providers.ESM_Provider;
+import com.aware.utils.UtcTime;
import com.google.android.material.tabs.TabLayout;
import org.json.JSONException;
-import java.text.SimpleDateFormat;
import java.util.Calendar;
/**
@@ -190,11 +190,9 @@ public void onClick(View v) {
if (getExpirationThreshold() > 0 && expire_monitor != null)
expire_monitor.cancel(true);
- SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z");
-
ContentValues rowData = new ContentValues();
rowData.put(ESM_Provider.ESM_Data.ANSWER_TIMESTAMP, System.currentTimeMillis());
- rowData.put(ESM_Provider.ESM_Data.ANSWER, dateFormat.format(datePicked.getTime()));
+ rowData.put(ESM_Provider.ESM_Data.ANSWER, UtcTime.pickedDateTime(datePicked));
rowData.put(ESM_Provider.ESM_Data.STATUS, ESM.STATUS_ANSWERED);
getActivity().getContentResolver().update(ESM_Provider.ESM_Data.CONTENT_URI, rowData, ESM_Provider.ESM_Data._ID + "=" + getID(), null);
diff --git a/aware-core/src/main/java/com/aware/ui/esms/ESM_Question.java b/aware-core/src/main/java/com/aware/ui/esms/ESM_Question.java
index 91e682bf..4dddd2de 100644
--- a/aware-core/src/main/java/com/aware/ui/esms/ESM_Question.java
+++ b/aware-core/src/main/java/com/aware/ui/esms/ESM_Question.java
@@ -11,6 +11,7 @@
import android.database.Cursor;
import android.os.AsyncTask;
import android.os.Bundle;
+import android.os.SystemClock;
import android.util.Log;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
@@ -350,7 +351,9 @@ public Dialog onCreateDialog(Bundle savedInstanceState) {
if (getExpirationThreshold() > 0) {
expire_monitor = new ESMExpireMonitor(System.currentTimeMillis(), getExpirationThreshold(), getID());
- expire_monitor.execute();
+ // Expiration monitors are long-lived timers; never occupy AsyncTask's global serial
+ // queue, otherwise study join and other short UI work can never begin.
+ expire_monitor.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
} catch (JSONException e) {
e.printStackTrace();
@@ -377,24 +380,12 @@ public ESMExpireMonitor(long display_timestamp, int expires_in_seconds, int esm_
@Override
protected Void doInBackground(Void... params) {
while ((System.currentTimeMillis() - display_timestamp) / 1000 <= expires_in_seconds) {
- if (isCancelled()) {
- Cursor esm = getActivity().getContentResolver().query(ESM_Provider.ESM_Data.CONTENT_URI, null, ESM_Provider.ESM_Data._ID + "=" + esm_id, null, null);
- if (esm != null && esm.moveToFirst()) {
- int status = esm.getInt(esm.getColumnIndex(ESM_Provider.ESM_Data.STATUS));
- switch (status) {
- case ESM.STATUS_ANSWERED:
- if (Aware.DEBUG) Log.d(Aware.TAG, "ESM has been answered!");
- break;
- case ESM.STATUS_DISMISSED:
- if (Aware.DEBUG) Log.d(Aware.TAG, "ESM has been dismissed!");
- break;
- }
- }
- if (esm != null && !esm.isClosed()) esm.close();
- return null;
- }
+ if (isCancelled()) return null;
+ // Avoid a full-speed busy wait for the entire question expiration window.
+ SystemClock.sleep(1000);
}
+ if (isCancelled() || getActivity() == null) return null;
if (Aware.DEBUG) Log.d(Aware.TAG, "ESM has expired!");
ContentValues rowData = new ContentValues();
@@ -496,4 +487,4 @@ public void onStart() {
d.getWindow().setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
}
}
-}
\ No newline at end of file
+}
diff --git a/aware-core/src/main/java/com/aware/ui/esms/ESM_Web.java b/aware-core/src/main/java/com/aware/ui/esms/ESM_Web.java
index 20fd0880..2f36c4da 100644
--- a/aware-core/src/main/java/com/aware/ui/esms/ESM_Web.java
+++ b/aware-core/src/main/java/com/aware/ui/esms/ESM_Web.java
@@ -41,7 +41,7 @@ public String getURL() throws JSONException {
}
//add support to passing AWARE's Device ID as parameter for online surveys
- String url = esm.getString(esm_url).replace("AWARE_DEVICE_ID", Aware.getSetting(getContext(), Aware_Preferences.DEVICE_ID));
+ String url = esm.getString(esm_url).replace("AWARE_DEVICE_ID", Aware.getDeviceID(getContext()));
return url;
}
diff --git a/aware-core/src/main/java/com/aware/utils/Aware_Plugin.java b/aware-core/src/main/java/com/aware/utils/Aware_Plugin.java
index 05c94fe0..7da2d9e4 100644
--- a/aware-core/src/main/java/com/aware/utils/Aware_Plugin.java
+++ b/aware-core/src/main/java/com/aware/utils/Aware_Plugin.java
@@ -8,11 +8,10 @@
import android.os.Bundle;
import android.os.IBinder;
import android.util.Log;
-import androidx.core.content.PermissionChecker;
+import android.content.pm.PackageManager;
+import androidx.core.content.ContextCompat;
import com.aware.Aware;
import com.aware.Aware_Preferences;
-import com.aware.ui.PermissionsHandler;
-
import java.util.ArrayList;
/**
@@ -79,7 +78,9 @@ public void onCreate() {
registerReceiver(contextBroadcaster, filter);
REQUIRED_PERMISSIONS.add(Manifest.permission.WRITE_EXTERNAL_STORAGE);
- REQUIRED_PERMISSIONS.add(Manifest.permission.GET_ACCOUNTS);
+ // GET_ACCOUNTS is only needed below API 26 -- see the comment in ui/Aware_Client.java.
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
+ REQUIRED_PERMISSIONS.add(Manifest.permission.GET_ACCOUNTS);
REQUIRED_PERMISSIONS.add(Manifest.permission.WRITE_SYNC_SETTINGS);
REQUIRED_PERMISSIONS.add(Manifest.permission.READ_SYNC_SETTINGS);
REQUIRED_PERMISSIONS.add(Manifest.permission.READ_SYNC_STATS);
@@ -92,7 +93,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
PERMISSIONS_OK = true;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
for (String p : REQUIRED_PERMISSIONS) {
- if (PermissionChecker.checkSelfPermission(this, p) != PermissionChecker.PERMISSION_GRANTED) {
+ if (ContextCompat.checkSelfPermission(this, p) != PackageManager.PERMISSION_GRANTED) {
PERMISSIONS_OK = false;
break;
}
@@ -100,11 +101,13 @@ public int onStartCommand(Intent intent, int flags, int startId) {
}
if (!PERMISSIONS_OK) {
- Intent permissions = new Intent(this, PermissionsHandler.class);
- permissions.putExtra(PermissionsHandler.EXTRA_REQUIRED_PERMISSIONS, REQUIRED_PERMISSIONS);
- permissions.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
- permissions.putExtra(PermissionsHandler.EXTRA_REDIRECT_SERVICE, getApplicationContext().getPackageName() + "/" + getClass().getName()); //restarts plugin once permissions are accepted
- startActivity(permissions);
+ // Permission requests must come from visible, participant-initiated consent/settings UI.
+ // Several plugins and sensors can start together after a config update; allowing each
+ // service to launch PermissionsHandler stacks dialogs and creates denial/restart loops.
+ Log.w(Aware.TAG, "Not starting " + getClass().getName()
+ + ": required permission is missing; waiting for participant consent");
+ stopSelf(startId);
+ return START_NOT_STICKY;
} else {
PERMISSIONS_OK = true;
diff --git a/aware-core/src/main/java/com/aware/utils/Aware_Sensor.java b/aware-core/src/main/java/com/aware/utils/Aware_Sensor.java
index 6366a826..933cb8d2 100644
--- a/aware-core/src/main/java/com/aware/utils/Aware_Sensor.java
+++ b/aware-core/src/main/java/com/aware/utils/Aware_Sensor.java
@@ -7,10 +7,9 @@
import android.os.Bundle;
import android.os.IBinder;
import android.util.Log;
-import androidx.core.content.PermissionChecker;
+import android.content.pm.PackageManager;
+import androidx.core.content.ContextCompat;
import com.aware.Aware;
-import com.aware.ui.PermissionsHandler;
-
import java.util.ArrayList;
/**
@@ -75,7 +74,9 @@ public void onCreate() {
registerReceiver(contextBroadcaster, filter);
REQUIRED_PERMISSIONS.add(Manifest.permission.WRITE_EXTERNAL_STORAGE);
- REQUIRED_PERMISSIONS.add(Manifest.permission.GET_ACCOUNTS);
+ // GET_ACCOUNTS is only needed below API 26 -- see the comment in ui/Aware_Client.java.
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
+ REQUIRED_PERMISSIONS.add(Manifest.permission.GET_ACCOUNTS);
REQUIRED_PERMISSIONS.add(Manifest.permission.WRITE_SYNC_SETTINGS);
REQUIRED_PERMISSIONS.add(Manifest.permission.READ_SYNC_SETTINGS);
REQUIRED_PERMISSIONS.add(Manifest.permission.READ_SYNC_STATS);
@@ -88,7 +89,7 @@ public int onStartCommand(Intent intent, int flags, int startId) {
PERMISSIONS_OK = true;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
for (String p : REQUIRED_PERMISSIONS) {
- if (PermissionChecker.checkSelfPermission(this, p) != PermissionChecker.PERMISSION_GRANTED) {
+ if (ContextCompat.checkSelfPermission(this, p) != PackageManager.PERMISSION_GRANTED) {
PERMISSIONS_OK = false;
break;
}
@@ -96,11 +97,14 @@ public int onStartCommand(Intent intent, int flags, int startId) {
}
if (!PERMISSIONS_OK) {
- Intent permissions = new Intent(this, PermissionsHandler.class);
- permissions.putExtra(PermissionsHandler.EXTRA_REQUIRED_PERMISSIONS, REQUIRED_PERMISSIONS);
- permissions.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
- permissions.putExtra(PermissionsHandler.EXTRA_REDIRECT_SERVICE, getPackageName() + "/" + getClass().getName()); //restarts plugin once permissions are accepted
- startActivity(permissions);
+ // Never launch permission UI from a background service. Enabling several study sensors
+ // at once otherwise creates several PermissionsHandler activities (often all titled
+ // "Allow Location") which stack faster than the participant can dismiss them. The
+ // consent/settings UI owns permission requests; it restarts AWARE after a grant.
+ Log.w(Aware.TAG, "Not starting " + getClass().getName()
+ + ": required permission is missing; waiting for participant consent");
+ stopSelf(startId);
+ return START_NOT_STICKY;
} else {
PERMISSIONS_OK = true;
diff --git a/aware-core/src/main/java/com/aware/utils/Aware_TTS.java b/aware-core/src/main/java/com/aware/utils/Aware_TTS.java
index 69431d5b..af1f3f1d 100644
--- a/aware-core/src/main/java/com/aware/utils/Aware_TTS.java
+++ b/aware-core/src/main/java/com/aware/utils/Aware_TTS.java
@@ -10,7 +10,9 @@
import android.os.IBinder;
import android.speech.tts.TextToSpeech;
import android.speech.tts.TextToSpeech.OnInitListener;
+import android.speech.tts.UtteranceProgressListener;
import android.util.Log;
+import java.util.HashMap;
public class Aware_TTS extends Service implements OnInitListener {
@@ -24,24 +26,23 @@ public class Aware_TTS extends Service implements OnInitListener {
private boolean ready = false;
private String text;
private String package_requester;
+ private int latestStartId;
/**
* Speak the given text
* @param text
*/
- public void speak(String text) {
+ public void speak(String text, int startId) {
if( ready ) {
+ latestStartId = startId;
+ String utteranceId = Integer.toString(startId);
if(Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP) {
- tts.speak(text, TextToSpeech.QUEUE_ADD, null, null);
+ tts.speak(text, TextToSpeech.QUEUE_ADD, null, utteranceId);
} else {
- tts.speak(text, TextToSpeech.QUEUE_ADD, null);
+ HashMap params = new HashMap<>();
+ params.put(TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID, utteranceId);
+ tts.speak(text, TextToSpeech.QUEUE_ADD, params);
}
-
- while(tts.isSpeaking()) {
- //wait
- }
- //stop service
- stopSelf();
}
}
@@ -50,7 +51,7 @@ public void onInit(int status) {
if( status == TextToSpeech.SUCCESS ) {
ready = true;
if( text != null && text.length() > 0 ) {
- speak(text);
+ speak(text, latestStartId);
}
} else {
ready = false;
@@ -68,21 +69,45 @@ public void onCreate() {
registerReceiver(awareTTS, filter);
tts = new TextToSpeech(this, this);
+ tts.setOnUtteranceProgressListener(new UtteranceProgressListener() {
+ @Override public void onStart(String utteranceId) {}
+
+ @Override public void onDone(String utteranceId) {
+ stopIfLatest(utteranceId);
+ }
+
+ @Override public void onError(String utteranceId) {
+ stopIfLatest(utteranceId);
+ }
+ });
+ }
+
+ private void stopIfLatest(String utteranceId) {
+ try {
+ int completedStartId = Integer.parseInt(utteranceId);
+ if (completedStartId == latestStartId) stopSelf(completedStartId);
+ } catch (NumberFormatException ignored) {
+ stopSelf();
+ }
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if( intent != null ) {
+ latestStartId = startId;
text = intent.getStringExtra(EXTRA_TTS_TEXT);
package_requester = intent.getStringExtra(EXTRA_TTS_REQUESTER);
- if (!getPackageName().equalsIgnoreCase(package_requester)) return super.onStartCommand(intent, flags, startId);
+ if (!getPackageName().equalsIgnoreCase(package_requester)) {
+ stopSelf(startId);
+ return START_NOT_STICKY;
+ }
if( tts != null && text != null && text.length() > 0 ) {
- speak(intent.getStringExtra(EXTRA_TTS_TEXT));
+ speak(intent.getStringExtra(EXTRA_TTS_TEXT), startId);
}
}
- return super.onStartCommand(intent, flags, startId);
+ return START_NOT_STICKY;
}
@Override
@@ -103,9 +128,13 @@ public IBinder onBind(Intent intent) {
public static class Aware_TTS_Receiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
- if( intent.getAction().equals(ACTION_AWARE_TTS_SPEAK) && intent.getStringExtra(EXTRA_TTS_REQUESTER).equals(context.getPackageName()) ) {
+ if (intent != null
+ && ACTION_AWARE_TTS_SPEAK.equals(intent.getAction())
+ && context.getPackageName().equals(
+ intent.getStringExtra(EXTRA_TTS_REQUESTER))) {
Intent tts_work = new Intent( context, Aware_TTS.class );
tts_work.putExtra(EXTRA_TTS_TEXT, intent.getStringExtra(EXTRA_TTS_TEXT));
+ tts_work.putExtra(EXTRA_TTS_REQUESTER, context.getPackageName());
context.startService(tts_work);
}
}
diff --git a/aware-core/src/main/java/com/aware/utils/DatabaseHelper.java b/aware-core/src/main/java/com/aware/utils/DatabaseHelper.java
index 15f953c8..5499f5f3 100644
--- a/aware-core/src/main/java/com/aware/utils/DatabaseHelper.java
+++ b/aware-core/src/main/java/com/aware/utils/DatabaseHelper.java
@@ -44,6 +44,7 @@ public class DatabaseHelper extends SQLiteOpenHelper {
private Context mContext;
private HashMap renamed_columns = new HashMap<>();
+ private List metadataOnlyTrailingColumnDrops = new ArrayList<>();
public DatabaseHelper(Context context, String database_name, CursorFactory cursor_factory, int database_version, String[] database_tables, String[] table_fields) {
super(context, database_name, cursor_factory, database_version);
@@ -59,32 +60,124 @@ public void setRenamedColumns(HashMap renamed) {
renamed_columns = renamed;
}
+ /**
+ * Allows a provider to remove known trailing columns without copying the entire table.
+ *
+ * SQLite records are self-describing. If columns are removed only from the end of a table,
+ * older records may safely retain those trailing values: SQLite ignores values beyond the
+ * table definition, while new records use the shorter definition. Updating sqlite_master is
+ * therefore a metadata-only operation. This is deliberately opt-in because it is safe only for
+ * trailing columns whose values no longer have meaning.
+ */
+ public void setMetadataOnlyTrailingColumnDrops(String... columns) {
+ metadataOnlyTrailingColumnDrops = new ArrayList<>(Arrays.asList(columns));
+ }
+
@Override
public void onCreate(SQLiteDatabase db) {
if (DEBUG) Log.w(TAG, "Creating database: " + db.getPath());
for (int i = 0; i < databaseTables.length; i++) {
db.execSQL("CREATE TABLE IF NOT EXISTS " + databaseTables[i] + " (" + tableFields[i] + ");");
- db.execSQL("CREATE INDEX IF NOT EXISTS time_device ON " + databaseTables[i] + " (timestamp, device_id);");
+ createTimeDeviceIndex(db, i);
}
db.setVersion(newVersion);
}
+ /**
+ * Creates the (timestamp, device_id) lookup index for one table, when that table declares both
+ * columns. Tables that declare neither — aware_settings, aware_plugins, aware_sync_markers —
+ * are skipped: indexing a column a table does not have throws, and inside an upgrade that
+ * aborts the whole migration transaction.
+ */
+ private void createTimeDeviceIndex(SQLiteDatabase db, int table) {
+ List declared = declaredColumns(tableFields[table]);
+ if (!declared.contains("timestamp") || !declared.contains("device_id")) return;
+ db.execSQL("CREATE INDEX IF NOT EXISTS time_device ON " + databaseTables[table]
+ + " (timestamp, device_id);");
+ }
+
+ /**
+ * The column names declared by a table's field definition.
+ *
+ * Read from the definition rather than from the table it creates, because the carry-over below
+ * needs the new column set before the new table holds any rows, and a table's shape is fully
+ * described by the definition already in hand.
+ *
+ * Splits on the commas between column definitions, which means stepping over the commas inside a
+ * trailing table constraint such as {@code UNIQUE(a, b)}; a constraint contributes no column and
+ * is skipped.
+ *
+ * @param fields one entry of the table-fields array, as handed to the constructor
+ * @return the column names, in declaration order
+ */
+ static List declaredColumns(String fields) {
+ List columns = new ArrayList<>();
+ int depth = 0;
+ StringBuilder current = new StringBuilder();
+ for (int i = 0; i <= fields.length(); i++) {
+ char c = i < fields.length() ? fields.charAt(i) : ',';
+ if (c == '(') depth++;
+ if (c == ')') depth--;
+ if (c == ',' && depth == 0) {
+ String definition = current.toString().trim();
+ current.setLength(0);
+ if (definition.isEmpty()) continue;
+ String name = definition.split("\\s+")[0];
+ // A table constraint (UNIQUE(...), PRIMARY KEY(...), FOREIGN KEY ...) names no column.
+ if (name.indexOf('(') >= 0) continue;
+ String upper = name.toUpperCase();
+ if (upper.equals("UNIQUE") || upper.equals("PRIMARY") || upper.equals("FOREIGN")
+ || upper.equals("CHECK") || upper.equals("CONSTRAINT")) continue;
+ columns.add(name);
+ } else {
+ current.append(c);
+ }
+ }
+ return columns;
+ }
+
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
if (DEBUG) Log.w(TAG, "Upgrading database: " + db.getPath());
+ boolean tableDefinitionRewritten = false;
+
for (int i = 0; i < databaseTables.length; i++) {
db.execSQL("CREATE TABLE IF NOT EXISTS " + databaseTables[i] + " (" + tableFields[i] + ");");
//Modify existing tables if there are changes, while retaining old data. This also works for brand new tables, where nothing is changed.
List columns = getColumns(db, databaseTables[i]);
+ List desiredColumns = declaredColumns(tableFields[i]);
+
+ if (!metadataOnlyTrailingColumnDrops.isEmpty()) {
+ // Providers opting into the metadata-only path use a version bump solely to drop
+ // a known trailing field. Do not rebuild their unchanged companion tables.
+ if (columns.equals(desiredColumns)) {
+ createTimeDeviceIndex(db, i);
+ continue;
+ }
+ if (isConfiguredTrailingColumnDrop(columns, desiredColumns,
+ metadataOnlyTrailingColumnDrops)) {
+ rewriteTableDefinition(db, databaseTables[i], tableFields[i]);
+ tableDefinitionRewritten = true;
+ createTimeDeviceIndex(db, i);
+ continue;
+ }
+ }
+ // An upgrade runs in a transaction, so an attempt that fails rolls back and leaves the
+ // original table in place — but a temp_ table created outside that transaction's reach
+ // would survive and collide with the rename below.
+ db.execSQL("DROP TABLE IF EXISTS temp_" + databaseTables[i] + ";");
db.execSQL("ALTER TABLE " + databaseTables[i] + " RENAME TO temp_" + databaseTables[i] + ";");
db.execSQL("CREATE TABLE " + databaseTables[i] + " (" + tableFields[i] + ");");
- db.execSQL("CREATE INDEX IF NOT EXISTS time_device ON " + databaseTables[i] + " (timestamp, device_id);");
+ createTimeDeviceIndex(db, i);
- columns.retainAll(getColumns(db, databaseTables[i]));
+ // The new table is empty at this point, so its shape comes from the definition that
+ // created it. Carrying over only the columns both shapes share is what lets a column be
+ // dropped: it stays behind with the temp table.
+ columns.retainAll(declaredColumns(tableFields[i]));
String cols = TextUtils.join(",", columns);
String new_cols = cols;
@@ -103,9 +196,41 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
db.execSQL(String.format("INSERT INTO %s (%s) SELECT %s from temp_%s;", databaseTables[i], new_cols, cols, databaseTables[i]));
db.execSQL("DROP TABLE temp_" + databaseTables[i] + ";");
}
+
+ if (tableDefinitionRewritten) {
+ // Force SQLite and other connections to discard their cached copy of sqlite_master.
+ Cursor schemaVersion = db.rawQuery("PRAGMA schema_version", null);
+ int version = 0;
+ if (schemaVersion != null && schemaVersion.moveToFirst()) {
+ version = schemaVersion.getInt(0);
+ }
+ if (schemaVersion != null && !schemaVersion.isClosed()) schemaVersion.close();
+ db.execSQL("PRAGMA schema_version = " + (version + 1));
+ }
db.setVersion(newVersion);
}
+ static boolean isConfiguredTrailingColumnDrop(List existing,
+ List desired,
+ List allowedDrops) {
+ if (existing.size() <= desired.size()) return false;
+ if (!existing.subList(0, desired.size()).equals(desired)) return false;
+ for (String dropped : existing.subList(desired.size(), existing.size())) {
+ if (!allowedDrops.contains(dropped)) return false;
+ }
+ return true;
+ }
+
+ private static void rewriteTableDefinition(SQLiteDatabase db, String table, String fields) {
+ db.execSQL("PRAGMA writable_schema = ON");
+ try {
+ db.execSQL("UPDATE sqlite_master SET sql = ? WHERE type = 'table' AND name = ?",
+ new Object[]{"CREATE TABLE " + table + " (" + fields + ")", table});
+ } finally {
+ db.execSQL("PRAGMA writable_schema = OFF");
+ }
+ }
+
/**
* Creates a String of a JSONArray representation of a database cursor result
*
@@ -175,7 +300,6 @@ public synchronized SQLiteDatabase getWritableDatabase() {
}
database = getDatabaseFile();
- if (database == null) return null;
int current_version = database.getVersion();
if (current_version != newVersion) {
@@ -193,23 +317,33 @@ public synchronized SQLiteDatabase getWritableDatabase() {
}
return database;
} catch (Exception e) {
- return null;
+ // A rolled-back migration leaves the old schema and version in place, so later callers
+ // read a database that disagrees with the provider's columns.
+ Log.e(TAG, "Failed to open " + databaseName + " at version " + newVersion
+ + "; the schema migration was rolled back: "
+ + e.getClass().getName() + ": " + e.getMessage());
+ // The cache check at the top returns without consulting the version, so this handle has
+ // to go: it points at the un-migrated schema.
+ if (database != null) {
+ try {
+ database.close();
+ } catch (Exception ignored) {
+ // Already unusable.
+ }
+ database = null;
+ }
+ SQLiteException failure = new SQLiteException("Failed to open " + databaseName
+ + " at version " + newVersion + ": " + e.getMessage());
+ failure.initCause(e);
+ throw failure;
}
}
@Override
public synchronized SQLiteDatabase getReadableDatabase() {
- try {
- if (database != null) {
- if (!database.isOpen()) {
- database = null;
- }
- }
- database = getDatabaseFile();
- return database;
- } catch (Exception e) {
- return null;
- }
+ // This helper has no read-only fallback: openOrCreateDatabase() always requests a writable
+ // handle. Reuse the checked path so reads cannot observe a schema whose migration failed.
+ return getWritableDatabase();
}
/**
@@ -244,7 +378,8 @@ private synchronized SQLiteDatabase getDatabaseFile() {
database = SQLiteDatabase.openOrCreateDatabase(new File(aware_folder, this.databaseName).getPath(), this.cursorFactory);
return database;
} catch (SQLiteException e) {
- return null;
+ Log.e(TAG, "Failed to open database file " + databaseName + ": " + e.getMessage());
+ throw e;
}
}
diff --git a/aware-core/src/main/java/com/aware/utils/DatabaseTransaction.java b/aware-core/src/main/java/com/aware/utils/DatabaseTransaction.java
new file mode 100644
index 00000000..ad5a2a95
--- /dev/null
+++ b/aware-core/src/main/java/com/aware/utils/DatabaseTransaction.java
@@ -0,0 +1,75 @@
+package com.aware.utils;
+
+import android.database.sqlite.SQLiteDatabase;
+
+/**
+ * A transaction scope that always releases SQLite's transaction lock.
+ *
+ * Providers use try-with-resources around every explicit transaction. A return or exception from
+ * any branch therefore reaches {@link #close()}, instead of relying on each branch to remember its
+ * own {@code endTransaction()} call.
+ */
+public final class DatabaseTransaction implements AutoCloseable {
+
+ interface Backend {
+ void begin();
+
+ void setSuccessful();
+
+ void end();
+ }
+
+ private final Backend backend;
+ private boolean closed;
+
+ public static DatabaseTransaction begin(final SQLiteDatabase database) {
+ if (database == null) {
+ throw new IllegalStateException("Cannot begin a transaction without an open database");
+ }
+ return new DatabaseTransaction(new Backend() {
+ @Override
+ public void begin() {
+ database.beginTransaction();
+ }
+
+ @Override
+ public void setSuccessful() {
+ database.setTransactionSuccessful();
+ }
+
+ @Override
+ public void end() {
+ database.endTransaction();
+ }
+ });
+ }
+
+ static DatabaseTransaction begin(Backend backend) {
+ if (backend == null) throw new IllegalArgumentException("backend cannot be null");
+ return new DatabaseTransaction(backend);
+ }
+
+ private DatabaseTransaction(Backend backend) {
+ this.backend = backend;
+ backend.begin();
+ }
+
+ /**
+ * Commits and closes at the call site where providers previously paired
+ * setTransactionSuccessful() with endTransaction(). Closing immediately keeps change
+ * notifications outside the transaction; close() remains a rollback fallback for every failure
+ * path before this point.
+ */
+ public void commit() {
+ if (closed) throw new IllegalStateException("Transaction is already closed");
+ backend.setSuccessful();
+ close();
+ }
+
+ @Override
+ public void close() {
+ if (closed) return;
+ closed = true;
+ backend.end();
+ }
+}
diff --git a/aware-core/src/main/java/com/aware/utils/DeviceFacts.java b/aware-core/src/main/java/com/aware/utils/DeviceFacts.java
new file mode 100644
index 00000000..11afaddd
--- /dev/null
+++ b/aware-core/src/main/java/com/aware/utils/DeviceFacts.java
@@ -0,0 +1,58 @@
+package com.aware.utils;
+
+import com.aware.providers.Aware_Provider.Aware_Device;
+
+import java.util.Map;
+
+/**
+ * Decides whether a device's current hardware and OS facts differ from what the aware_device table
+ * already records for it.
+ *
+ * aware_device holds one row per device, which the phone keeps current: {@code UNIQUE(device_id)}
+ * allows a single row per device_id, and a change to the device's facts rewrites it with a fresh
+ * timestamp. That timestamp is what carries the change to the server, where each rewrite arrives as
+ * its own row — so release/sdk/build_id across two server rows read as a mid-study Android upgrade.
+ * A spurious "changed" verdict therefore costs a server row that says nothing, which is why null and
+ * empty are treated as equal below.
+ *
+ * Free of Android types and of any state, so it is unit-testable directly.
+ */
+public final class DeviceFacts {
+
+ private DeviceFacts() {
+ }
+
+ /**
+ * The aware_device columns that describe the device itself, and so decide whether the stored row
+ * still reflects reality.
+ *
+ * Excluded are {@code _id} and {@code timestamp}, which move on their own terms, and
+ * {@code device_id}, which identifies the row being compared.
+ */
+ public static final String[] COMPARED_COLUMNS = {
+ Aware_Device.BOARD, Aware_Device.DEVICE, Aware_Device.BUILD_ID,
+ Aware_Device.HARDWARE, Aware_Device.MANUFACTURER, Aware_Device.MODEL,
+ Aware_Device.PRODUCT, Aware_Device.RELEASE, Aware_Device.SDK};
+
+ /**
+ * Compares a stored aware_device row against the device's current facts across
+ * {@link #COMPARED_COLUMNS}.
+ *
+ * Null and empty count as the same value: a column the platform reports as null but SQLite
+ * stores as empty text (or the reverse) would otherwise read as changed on every check and
+ * rewrite the row on every service start.
+ *
+ * @param stored the stored row for this device_id, or null when the device has none
+ * @param current the device's facts as the platform reports them now
+ * @return true when the stored row already says everything the current facts say
+ */
+ public static boolean unchanged(Map stored, Map current) {
+ if (stored == null || current == null) return false;
+ for (String column : COMPARED_COLUMNS) {
+ String was = stored.get(column) == null ? "" : stored.get(column);
+ String now = current.get(column) == null ? "" : current.get(column);
+ if (!was.equals(now)) return false;
+ }
+ return true;
+ }
+}
diff --git a/aware-core/src/main/java/com/aware/utils/DeviceId.java b/aware-core/src/main/java/com/aware/utils/DeviceId.java
new file mode 100644
index 00000000..3dbcb9ea
--- /dev/null
+++ b/aware-core/src/main/java/com/aware/utils/DeviceId.java
@@ -0,0 +1,98 @@
+package com.aware.utils;
+
+/**
+ * Where the device UUID is read from, and which copy needs repairing, kept as pure logic so it can be
+ * verified without a Context.
+ *
+ * The UUID lives in the aware_settings table, which Aware.reset() clears wholesale. A sensor thread
+ * inserting during that gap read an empty device_id and stamped a row that no participant can be
+ * matched to, and Aware.onCreate() seeing the same empty table minted a second UUID, splitting one
+ * participant across two identities. A copy in SharedPreferences survives a settings wipe, so the
+ * value can be recovered instead of re-invented.
+ *
+ * This deliberately never generates a UUID. Minting one stays in Aware.onCreate(), which runs once
+ * per process start before any sensor does; letting the ~180 read sites mint one would let two
+ * processes race to invent different identities for the same install.
+ */
+public final class DeviceId {
+
+ /**
+ * Its own SharedPreferences file rather than the "com.aware.phone" one, which
+ * PreferenceManager.setDefaultValues() populates from aware_preferences.xml -- the UUID has no
+ * XML default and must not be reachable by anything that re-applies defaults.
+ */
+ public static final String MIRROR_PREFERENCES = "aware_device_identity";
+
+ public static final String MIRROR_KEY = "device_id";
+
+ private DeviceId() {
+ }
+
+ /**
+ * Which copies of the UUID disagree, and what to do about it.
+ */
+ public static final class Resolution {
+
+ private final String deviceId;
+ private final boolean healSettings;
+ private final boolean healMirror;
+
+ private Resolution(String deviceId, boolean healSettings, boolean healMirror) {
+ this.deviceId = deviceId;
+ this.healSettings = healSettings;
+ this.healMirror = healMirror;
+ }
+
+ /**
+ * The UUID to stamp on rows, or empty when this install has no identity yet.
+ */
+ public String getDeviceId() {
+ return deviceId;
+ }
+
+ /**
+ * True when the settings table lost the UUID and the mirror still has it.
+ */
+ public boolean shouldHealSettings() {
+ return healSettings;
+ }
+
+ /**
+ * True when the mirror is missing or stale -- including on the first run of this build, where
+ * the UUID predates the mirror existing at all.
+ */
+ public boolean shouldHealMirror() {
+ return healMirror;
+ }
+
+ /**
+ * False only when neither copy holds a UUID, which means no row should be stamped yet.
+ */
+ public boolean isResolved() {
+ return !deviceId.isEmpty();
+ }
+ }
+
+ /**
+ * Picks the UUID to use from the two stored copies. The settings table wins when both hold a
+ * value: it is what every other read site and the server already see, so healing towards it keeps
+ * a single install on a single identity even if the mirror somehow diverged.
+ */
+ public static Resolution resolve(String fromSettings, String fromMirror) {
+ String settings = trimToEmpty(fromSettings);
+ String mirror = trimToEmpty(fromMirror);
+
+ if (!settings.isEmpty()) return new Resolution(settings, false, !settings.equals(mirror));
+ if (!mirror.isEmpty()) return new Resolution(mirror, true, false);
+
+ return new Resolution("", false, false);
+ }
+
+ /**
+ * Treats whitespace as absent: a blank UUID orphans a row exactly as an empty one does, and the
+ * column's declared default is the empty string, so blank is what a lost setting reads back as.
+ */
+ public static String trimToEmpty(String value) {
+ return value == null ? "" : value.trim();
+ }
+}
diff --git a/aware-core/src/main/java/com/aware/utils/Encrypter.java b/aware-core/src/main/java/com/aware/utils/Encrypter.java
index 1a8e883b..2c10ce7c 100644
--- a/aware-core/src/main/java/com/aware/utils/Encrypter.java
+++ b/aware-core/src/main/java/com/aware/utils/Encrypter.java
@@ -127,7 +127,7 @@ public static final String _hashProgram(Context context, String clear, String ha
for (String command: hashProgramCommands) {
if (command.equals("salt=device_id")) {
// Salt using the device_id
- clear = clear + Aware.getSetting(context, Aware_Preferences.DEVICE_ID);
+ clear = clear + Aware.getDeviceID(context);
} else if (command.startsWith("salt=")) {
// Salt using any string
String[] command_split = command.split("=");
diff --git a/aware-core/src/main/java/com/aware/utils/Http.java b/aware-core/src/main/java/com/aware/utils/Http.java
index 7962cc5e..d03eb896 100644
--- a/aware-core/src/main/java/com/aware/utils/Http.java
+++ b/aware-core/src/main/java/com/aware/utils/Http.java
@@ -112,6 +112,12 @@ public String dataPOST(final String url, final Hashtable data, f
path_connection.setConnectTimeout(timeout);
path_connection.setRequestMethod("POST");
path_connection.setDoOutput(true);
+ // A form-encoded body needs the header that says so. Without it a server
+ // that parses by content type finds no parameters at all and answers as
+ // though the request were empty -- which is what the AWARE micro-server
+ // does, refusing the insert for a missing device_id.
+ path_connection.setRequestProperty(
+ "Content-Type", "application/x-www-form-urlencoded");
if( is_gzipped ) path_connection.setRequestProperty("accept-encoding","gzip");
diff --git a/aware-core/src/main/java/com/aware/utils/Https.java b/aware-core/src/main/java/com/aware/utils/Https.java
index b0cf10d3..c41a937f 100644
--- a/aware-core/src/main/java/com/aware/utils/Https.java
+++ b/aware-core/src/main/java/com/aware/utils/Https.java
@@ -112,6 +112,12 @@ public String dataPOST(final String url, final Hashtable data, f
path_connection.setConnectTimeout(timeout);
path_connection.setRequestMethod("POST");
path_connection.setDoOutput(true);
+ // A form-encoded body needs the header that says so. Without it a server
+ // that parses by content type finds no parameters at all and answers as
+ // though the request were empty -- which is what the AWARE micro-server
+ // does, refusing the insert for a missing device_id.
+ path_connection.setRequestProperty(
+ "Content-Type", "application/x-www-form-urlencoded");
if (is_gzipped) path_connection.setRequestProperty("accept-encoding", "gzip");
diff --git a/aware-core/src/main/java/com/aware/utils/Jdbc.java b/aware-core/src/main/java/com/aware/utils/Jdbc.java
index 21acab47..387faa42 100644
--- a/aware-core/src/main/java/com/aware/utils/Jdbc.java
+++ b/aware-core/src/main/java/com/aware/utils/Jdbc.java
@@ -15,6 +15,7 @@
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;
+import java.sql.SQLWarning;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
@@ -27,12 +28,197 @@ public class Jdbc {
private static Connection connection;
private static int transactionCount = 0;
+ /**
+ * Bounds on the shared sync connection. Without them the driver inherits Java's defaults, where
+ * a read timeout of 0 means wait forever: a server that accepts the connection and then stops
+ * answering holds {@link #insertBatch}'s lock until TCP keepalive gives up, which is hours. Every
+ * other table's upload waits behind that, and because the call never returns, the sync adapter
+ * records neither success nor failure, so nothing reports the stall.
+ *
+ * The socket timeout applies per read rather than to the batch as a whole, so a large upload on
+ * a slow link is not at risk unless the link itself stalls for this long. A timeout that does
+ * trip is safe: the batch rolls back, the rows stay on the device, and the sync marker does not
+ * advance, so the same rows are retried on the next sync.
+ */
+ static final int SYNC_CONNECT_TIMEOUT_MS = 30_000;
+ static final int SYNC_SOCKET_TIMEOUT_MS = 60_000;
+
+ /**
+ * How long a connection-level failure suppresses further upload attempts.
+ *
+ * The timeouts above bound a single attempt, not a sync cycle: roughly 40 tables across 30 sync
+ * adapters each take their own turn on the shared connection, so a dead server would still cost
+ * 40 timeouts serially. One connection failure means the next table will fail the same way, so
+ * the remaining attempts are skipped instead of re-proved. The window is far shorter than the
+ * sync interval (30 minutes by default), so it delays no scheduled retry.
+ */
+ static final long BREAKER_COOLDOWN_MS = 60_000L;
+
+ private static volatile long connectionFailedAt = 0;
+
private static class JdbcConnectionException extends Exception {
private JdbcConnectionException(String message) {
super(message);
}
}
+ /**
+ * Outcome of a database connection attempt, distinguishing an authentication failure (the
+ * stored password is wrong) from an unreachable server (transient / network). Callers need
+ * this distinction so that "the password changed" is handled differently from "the database is
+ * temporarily down": only the former should ask the participant to re-authenticate.
+ */
+ public enum ConnectionResult { OK, AUTH_FAILED, UNREACHABLE }
+
+ /**
+ * Classifies a {@link SQLException} from a connection attempt as an authentication failure or a
+ * reachability failure. Pure and side-effect free so it can be unit-tested without a database.
+ *
+ * MySQL signals access-denied with SQLState {@code 28000} and vendor error code {@code 1045};
+ * anything else (connect/socket timeout, communications link failure, unknown host, driver
+ * errors) is treated as {@link ConnectionResult#UNREACHABLE}. The safe default is
+ * {@code UNREACHABLE} so an unrecognised error never prompts the participant for a password.
+ *
+ * @param e the exception thrown while connecting
+ * @return {@link ConnectionResult#AUTH_FAILED} for access-denied, else {@link ConnectionResult#UNREACHABLE}
+ */
+ static ConnectionResult classify(SQLException e) {
+ if (e == null) return ConnectionResult.UNREACHABLE;
+ if ("28000".equals(e.getSQLState()) || e.getErrorCode() == 1045) {
+ return ConnectionResult.AUTH_FAILED;
+ }
+ return ConnectionResult.UNREACHABLE;
+ }
+
+ /**
+ * Whether a failure is with the connection itself rather than with the statement, i.e. whether
+ * every other table is about to fail the same way.
+ *
+ * SQLState class {@code 08} is the standard connection-exception class, which MySQL uses for a
+ * communications link failure including a tripped socket timeout; {@code 28000} is access
+ * denied, where no table can succeed either.
+ *
+ * Deliberately conservative: anything else is treated as statement-level. Misreading a
+ * statement error as a connection error would suppress every other table's upload for the
+ * cooldown, which is exactly how one broken table (a column the server lacks) could hide the
+ * rest. Misreading it the other way only costs the redundant attempts this check exists to
+ * avoid, so the cheaper mistake is the one it makes. Pure, so it is unit-testable without a
+ * database.
+ *
+ * @param e the exception thrown while uploading a batch
+ * @return true if the shared connection is the problem
+ */
+ static boolean isConnectionLevel(SQLException e) {
+ if (e == null) return false;
+ String state = e.getSQLState();
+ if (state == null) return false;
+ return state.startsWith("08") || "28000".equals(state);
+ }
+
+ /**
+ * Whether upload attempts are currently suppressed after a connection-level failure.
+ *
+ * Pure, so the cooldown can be unit-tested without waiting on a clock.
+ *
+ * A clock that has stepped backwards since the failure — an NTP correction, or the participant
+ * changing the time — reads as a negative elapsed time. That resolves to closed rather than open:
+ * the alternative is suppressing uploads until the clock catches up, which for a correction of
+ * any size is the silent multi-hour stall this cooldown exists to prevent.
+ *
+ * @param failedAt when the connection last failed, or 0 if it has not
+ * @param now current time
+ * @param cooldownMs how long a failure suppresses attempts
+ */
+ static boolean breakerOpen(long failedAt, long now, long cooldownMs) {
+ if (failedAt <= 0) return false;
+ long elapsed = now - failedAt;
+ return elapsed >= 0 && elapsed < cooldownMs;
+ }
+
+ /**
+ * The shared sync connection's URL. Pure, so the timeout parameters can be verified without a
+ * database or an Android context.
+ */
+ static String syncConnectionUrl(String host, String port, String name, String tlsParameters) {
+ return String.format(
+ "jdbc:mysql://%s:%s/%s?rewriteBatchedStatements=true&connectTimeout=%d&socketTimeout=%d%s",
+ host, port, name, SYNC_CONNECT_TIMEOUT_MS, SYNC_SOCKET_TIMEOUT_MS,
+ tlsParameters == null ? "" : tlsParameters);
+ }
+
+ /**
+ * Summarises a chain of {@link SQLWarning}s into one log-safe line, or null when the chain is
+ * empty.
+ *
+ * The count matters as much as the text: MySQL reports one warning per offending row, so "1"
+ * and "4000" distinguish a single odd value from a column whose type no longer matches what the
+ * client sends. Pure and side-effect free so it can be unit-tested without a database.
+ *
+ * @param warning head of the warning chain, or null
+ * @return a summary such as {@code 2 warning(s); first: [01000/1265] Data truncated}, or null
+ */
+ static String describeWarnings(SQLWarning warning) {
+ if (warning == null) return null;
+
+ int count = 0;
+ SQLWarning current = warning;
+ // A driver that chains a warning to itself would otherwise spin here forever.
+ while (current != null && count < 1000) {
+ count++;
+ SQLWarning next = current.getNextWarning();
+ if (next == current) break;
+ current = next;
+ }
+
+ return count + " warning(s); first: [" + warning.getSQLState() + "/"
+ + warning.getErrorCode() + "] " + warning.getMessage();
+ }
+
+ /**
+ * Probes whether the given credentials can authenticate against the database, on a short-lived
+ * connection that fails fast when the host is unreachable.
+ *
+ * Returns a three-state {@link ConnectionResult} so callers can tell a rejected password
+ * ({@link ConnectionResult#AUTH_FAILED}) from a down/unreachable server
+ * ({@link ConnectionResult#UNREACHABLE}). Connects on its own short-lived connection with
+ * bounded {@code connectTimeout}/{@code socketTimeout}, leaving the shared {@link #connection}
+ * used by uploads untouched, so a probe can run during a sync without disturbing it. The
+ * password and connection string are never logged.
+ *
+ * @param context application context, for the trust store behind {@link MysqlTls}
+ * @param timeoutSeconds maximum time to spend connecting
+ * @return {@link ConnectionResult#OK} if the credentials authenticate, otherwise the classified failure
+ */
+ public static ConnectionResult probeConnection(Context context, String host, String port,
+ String name, String username, String password,
+ int timeoutSeconds) {
+ int timeoutMs = timeoutSeconds * 1000;
+
+ Connection localConnection = null;
+ try {
+ String connectionUrl = String.format(
+ "jdbc:mysql://%s:%s/%s?connectTimeout=%d&socketTimeout=%d%s",
+ host, port, name, timeoutMs, timeoutMs,
+ MysqlTls.connectionParameters(context));
+ Class.forName("com.mysql.jdbc.Driver");
+ localConnection = DriverManager.getConnection(connectionUrl, username, password);
+ return ConnectionResult.OK;
+ } catch (SQLException e) {
+ ConnectionResult result = classify(e);
+ Log.i(TAG, "Database probe result: " + result);
+ return result;
+ } catch (Exception e) {
+ // Driver load or other unexpected failure: treat as unreachable so we never prompt.
+ Log.i(TAG, "Database probe result: " + ConnectionResult.UNREACHABLE);
+ return ConnectionResult.UNREACHABLE;
+ } finally {
+ try {
+ if (localConnection != null && !localConnection.isClosed()) localConnection.close();
+ } catch (SQLException ignored) {
+ }
+ }
+ }
+
/**
* Inserts data into a remote database table.
*
@@ -44,54 +230,128 @@ private JdbcConnectionException(String message) {
public static boolean insertData(Context context, String table, JSONArray rows) {
if (rows.length() == 0) return true;
+ // A recent connection-level failure means this attempt would block for the socket timeout to
+ // learn what the last one already established. Reported as a failure rather than a success,
+ // so the table keeps its rows and the outage stays recorded.
+ if (breakerOpen(connectionFailedAt, System.currentTimeMillis(), BREAKER_COOLDOWN_MS)) {
+ Log.i(TAG, "Skipping upload of '" + table
+ + "': the database failed to respond within the last "
+ + (BREAKER_COOLDOWN_MS / 1000) + "s.");
+ return false;
+ }
+
try {
- Jdbc.transactionCount++;
List fields = new ArrayList<>();
Iterator fieldIterator = rows.getJSONObject(0).keys();
while (fieldIterator.hasNext()) {
fields.add(fieldIterator.next());
}
+ // Claim a reference only once nothing else here can throw: insertBatch's finally is what
+ // releases it, so anything that fails between the two would leave the shared connection
+ // referenced by a caller that has gone away, and never closed.
+ Jdbc.transactionCount++;
Jdbc.insertBatch(context, table, fields, rows);
- } catch (JSONException | SQLException | JdbcConnectionException e) {
+ } catch (SQLException e) {
+ if (isConnectionLevel(e)) openBreaker("upload of '" + table + "' failed: "
+ + e.getSQLState() + " " + e.getMessage());
+ e.printStackTrace();
+ return false;
+ } catch (JdbcConnectionException e) {
+ // connect() could not establish the connection at all, so no table will fare better.
+ openBreaker("could not connect: " + e.getMessage());
+ e.printStackTrace();
+ return false;
+ } catch (JSONException e) {
+ // Malformed rows for this table only; the connection is fine.
e.printStackTrace();
return false;
}
+
+ connectionFailedAt = 0;
return true;
}
+ private static void openBreaker(String reason) {
+ connectionFailedAt = System.currentTimeMillis();
+ Log.w(TAG, "Pausing uploads for " + (BREAKER_COOLDOWN_MS / 1000) + "s — " + reason);
+ }
+
/**
- * Test if a connection to a database can be established.
- * @param host db host
- * @param port db port
- * @param name db name
- * @param username db username
- * @param password db password
- * @return true if a connection was established, false otherwise.
+ * Best-effort, single-shot insert on a short-lived connection that fails fast when the host is
+ * unreachable.
+ *
+ * Unlike {@link #insertData}, this does NOT reuse the shared sync connection and adds bounded
+ * {@code connectTimeout}/{@code socketTimeout} query parameters, so an unreachable database
+ * fails within roughly {@code timeoutSeconds} instead of hanging on the default TCP timeout.
+ * It is used by the study-exit notification: leaving a study must stay responsive and must
+ * never be blocked by an unreachable research database.
+ *
+ * @param context application context
+ * @param table name of the remote table to insert into
+ * @param rows rows to insert
+ * @param timeoutSeconds maximum time to spend connecting/talking to the database
+ * @return true if the database acknowledged the insert; false if it could not be reached or the
+ * insert failed. Callers must treat false as "not notified", never as "leave failed".
*/
- public static boolean testConnection(String host, String port, String name, String username, String password, Boolean config_without_password, String input_password) {
- String connectionUrl = String.format("jdbc:mysql://%s:%s/%s", host, port, name);
- Log.i(TAG, "Establishing connection to remote database...");
-
+ public static boolean insertDataFastFail(Context context, String table, JSONArray rows, int timeoutSeconds) {
+ if (rows.length() == 0) return true;
+ int timeoutMs = timeoutSeconds * 1000;
+ Connection localConnection = null;
try {
- Class.forName("com.mysql.jdbc.Driver").newInstance();
- Log.i(TAG, "Connected to remote database...");
+ String connectionUrl = String.format(
+ "jdbc:mysql://%s:%s/%s?connectTimeout=%d&socketTimeout=%d%s",
+ Aware.getSetting(context, Aware_Preferences.DB_HOST),
+ Aware.getSetting(context, Aware_Preferences.DB_PORT),
+ Aware.getSetting(context, Aware_Preferences.DB_NAME),
+ timeoutMs, timeoutMs,
+ MysqlTls.connectionParameters(context));
+ Class.forName("com.mysql.jdbc.Driver");
+ localConnection = DriverManager.getConnection(connectionUrl,
+ Aware.getSetting(context, Aware_Preferences.DB_USERNAME),
+ Aware.getSetting(context, Aware_Preferences.DB_PASSWORD));
- if (config_without_password == false){
- Log.i(TAG, "No input password. Default password: " + password);
- connection = DriverManager.getConnection(connectionUrl, username, password);
- }else{
- Log.i(TAG, "Input password needed: " + input_password);
- connection = DriverManager.getConnection(connectionUrl, username, input_password);
+ List fields = new ArrayList<>();
+ Iterator fieldIterator = rows.getJSONObject(0).keys();
+ while (fieldIterator.hasNext()) {
+ fields.add(fieldIterator.next());
}
- connection.close();
+ List fieldsWithBacktick = new ArrayList<>(); // in case of reserved keywords
+ List sqlParamPlaceholder = new ArrayList<>();
+ for (int i = 0; i < fields.size(); i++) {
+ fieldsWithBacktick.add("`" + fields.get(i) + "`");
+ sqlParamPlaceholder.add('?');
+ }
+
+ String sqlStatement = String.format("INSERT INTO %s (%s) VALUES (%s)", table,
+ TextUtils.join(",", fieldsWithBacktick),
+ TextUtils.join(",", sqlParamPlaceholder));
+ PreparedStatement ps = localConnection.prepareStatement(sqlStatement);
+
+ for (int i = 0; i < rows.length(); i++) {
+ JSONObject row = rows.getJSONObject(i);
+ int paramIndex = 1;
+ for (String field : fields) {
+ ps.setString(paramIndex, row.getString(field));
+ paramIndex++;
+ }
+ ps.addBatch();
+ }
+
+ ps.executeBatch();
+ Log.i(TAG, "Study-exit notification acknowledged by remote table '" + table + "'");
return true;
} catch (Exception e) {
- Log.e(TAG, "Failed to establish connection to database, reason: " + e.getMessage());
- e.printStackTrace();
+ // Do not log the connection string/credentials; the host is enough to diagnose.
+ Log.w(TAG, "Study-exit notification could not reach the research database: " + e.getMessage());
return false;
+ } finally {
+ try {
+ if (localConnection != null && !localConnection.isClosed()) localConnection.close();
+ } catch (SQLException ignored) {
+ }
}
}
@@ -100,14 +360,15 @@ public static boolean testConnection(String host, String port, String name, Stri
* @param context application context
*/
private static void connect(Context context) throws JdbcConnectionException {
- String connectionUrl = String.format("jdbc:mysql://%s:%s/%s?rewriteBatchedStatements=true",
- Aware.getSetting(context, Aware_Preferences.DB_HOST),
- Aware.getSetting(context, Aware_Preferences.DB_PORT),
- Aware.getSetting(context, Aware_Preferences.DB_NAME));
Log.i(TAG, "Establishing connection to remote database...");
try {
- Class.forName("com.mysql.jdbc.Driver").newInstance();
+ String connectionUrl = syncConnectionUrl(
+ Aware.getSetting(context, Aware_Preferences.DB_HOST),
+ Aware.getSetting(context, Aware_Preferences.DB_PORT),
+ Aware.getSetting(context, Aware_Preferences.DB_NAME),
+ MysqlTls.connectionParameters(context));
+ Class.forName("com.mysql.jdbc.Driver");
connection = DriverManager.getConnection(connectionUrl,
Aware.getSetting(context, Aware_Preferences.DB_USERNAME),
@@ -143,9 +404,12 @@ private static void disconnect() {
* @throws JdbcConnectionException
* @throws JSONException
*/
- private static synchronized void insertBatch(Context context, String table, List fields,
- JSONArray rows)
- throws JdbcConnectionException, JSONException, SQLException {
+ private static synchronized void insertBatch(
+ Context context,
+ String table,
+ List fields,
+ JSONArray rows
+ ) throws JdbcConnectionException, JSONException, SQLException {
try {
if (Jdbc.connection == null || Jdbc.connection.isClosed()) {
Jdbc.transactionCount = 1; // reset transaction count if this is the first INSERT
@@ -164,20 +428,61 @@ private static synchronized void insertBatch(Context context, String table, List
String sqlStatement = String.format("INSERT INTO %s (%s) VALUES (%s)", table,
TextUtils.join(",", fieldsWithBacktick),
TextUtils.join(",", sqlParamPlaceholder));
- PreparedStatement ps = Jdbc.connection.prepareStatement(sqlStatement);
- for (int i = 0; i < rows.length(); i++) {
- JSONObject row = rows.getJSONObject(i);
- int paramIndex = 1;
+ // The batch lands all-or-nothing. rewriteBatchedStatements=true has the driver merge the
+ // batch into several multi-row INSERTs; under autocommit each of those commits on its
+ // own, so a failure part-way leaves the earlier chunks stored server-side while the
+ // phone — which only learns "the batch failed" — keeps every local row and retries the
+ // whole batch, duplicating them. The client's MySQL user has INSERT only, so such
+ // duplicates can never be removed afterwards. Requires the target table to be InnoDB;
+ // MyISAM silently ignores transactions.
+ boolean autoCommitWas = Jdbc.connection.getAutoCommit();
+ Jdbc.connection.setAutoCommit(false);
+ try (PreparedStatement ps = Jdbc.connection.prepareStatement(sqlStatement)) {
+ for (int i = 0; i < rows.length(); i++) {
+ JSONObject row = rows.getJSONObject(i);
+ int paramIndex = 1;
- for (String field: fields) {
- ps.setString(paramIndex, row.getString(field));
- paramIndex++;
+ for (String field: fields) {
+ ps.setString(paramIndex, row.getString(field));
+ paramIndex++;
+ }
+ ps.addBatch();
}
- ps.addBatch();
- }
- ps.executeBatch();
+ // The connection is shared across batches and accumulates warnings, so clear it
+ // first: what is read below has to belong to this batch and no earlier one.
+ Jdbc.connection.clearWarnings();
+ ps.executeBatch();
+
+ // Every value is bound with setString(), including numeric and double_* columns, so
+ // MySQL implicitly converts each one. A server in strict mode raises an error on a
+ // bad conversion, but a non-strict server (or a MyISAM table, where strict mode
+ // degrades to warnings for multi-row inserts) accepts it as a warning and stores
+ // something other than what was sent. Report that rather than call it a clean
+ // upload; the batch is still committed, because refusing to advance the sync marker
+ // over a warning would wedge the table into retrying the same rows forever.
+ String warnings = describeWarnings(ps.getWarnings());
+ if (warnings == null) warnings = describeWarnings(Jdbc.connection.getWarnings());
+ if (warnings != null) {
+ Log.w(TAG, "Remote table '" + table + "' accepted the insert with " + warnings);
+ }
+
+ Jdbc.connection.commit();
+ } catch (SQLException e) {
+ try {
+ Jdbc.connection.rollback();
+ } catch (SQLException rollbackFailed) {
+ Log.e(TAG, "Rollback of the failed batch for '" + table + "' did not complete: "
+ + rollbackFailed.getMessage());
+ }
+ throw e;
+ } finally {
+ try {
+ Jdbc.connection.setAutoCommit(autoCommitWas);
+ } catch (SQLException ignored) {
+ }
+ }
Log.i(TAG, "Inserted " + rows.length() + " row(s) of data into remote table '" + table);
} finally {
Jdbc.transactionCount--;
diff --git a/aware-core/src/main/java/com/aware/utils/LogRedactor.java b/aware-core/src/main/java/com/aware/utils/LogRedactor.java
new file mode 100644
index 00000000..e91ba9e1
--- /dev/null
+++ b/aware-core/src/main/java/com/aware/utils/LogRedactor.java
@@ -0,0 +1,47 @@
+package com.aware.utils;
+
+import java.util.regex.Pattern;
+
+/**
+ * Small, dependency-free helper that strips credential values out of text before it is written to
+ * the log. It does NOT change how credentials are stored or transported (the study configuration
+ * still embeds the database password); it only prevents that password from leaking into Logcat,
+ * crash reports, support bundles, or connected-device tooling.
+ *
+ * The study configuration is logged in several shapes — pretty-printed JSON
+ * ({@code JSONObject.toString(indent)}), compact JSON, {@code ContentValues.toString()}, and
+ * {@code DatabaseUtils.dumpCursorToString()} — but in every case the secret appears as a JSON
+ * key/value pair such as {@code "database_password":"secret"}. Redacting that pair as plain text
+ * therefore covers all of those log shapes with one pass.
+ */
+public class LogRedactor {
+
+ /**
+ * Matches a JSON key whose name contains password/passwd/secret/token (case-insensitive)
+ * followed by a quoted string value, capturing the key-and-separator prefix in group 1 so the
+ * value alone can be replaced. Handles both compact ({@code "k":"v"}) and pretty
+ * ({@code "k" : "v"}) spacing and escaped characters inside the value.
+ */
+ private static final Pattern SENSITIVE_JSON_STRING = Pattern.compile(
+ "(\"[A-Za-z0-9_]*(?:password|passwd|secret|token)[A-Za-z0-9_]*\"\\s*:\\s*)\"(?:\\\\.|[^\"\\\\])*\"",
+ Pattern.CASE_INSENSITIVE);
+
+ private static final String REDACTED = "\"***\"";
+
+ private LogRedactor() {
+ }
+
+ /**
+ * Returns {@code message} with the value of any password/secret/token JSON field replaced by
+ * {@code "***"}. Safe to call on any log string; input without a sensitive field is returned
+ * unchanged. A {@code null} input is returned as-is.
+ *
+ * @param message the text about to be logged
+ * @return the same text with credential values masked
+ */
+ public static String redact(String message) {
+ if (message == null) return null;
+ // "$1" is a backreference to the key/separator prefix; REDACTED has no regex-special chars.
+ return SENSITIVE_JSON_STRING.matcher(message).replaceAll("$1" + REDACTED);
+ }
+}
diff --git a/aware-core/src/main/java/com/aware/utils/MysqlTls.java b/aware-core/src/main/java/com/aware/utils/MysqlTls.java
new file mode 100644
index 00000000..4da90da2
--- /dev/null
+++ b/aware-core/src/main/java/com/aware/utils/MysqlTls.java
@@ -0,0 +1,181 @@
+package com.aware.utils;
+
+import android.content.Context;
+import android.util.Log;
+
+import com.aware.Aware;
+import com.aware.Aware_Preferences;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.security.GeneralSecurityException;
+import java.security.KeyStore;
+import java.security.MessageDigest;
+import java.security.cert.Certificate;
+import java.security.cert.CertificateFactory;
+
+/**
+ * Supplies the TLS parameters the MySQL driver uses when it opens a connection to the research
+ * database.
+ *
+ * TLS carries two guarantees: the traffic is unreadable in transit, and the host that answers is the
+ * one the study means to reach. The traffic is always encrypted. The second guarantee needs an
+ * authority to check the offered certificate against, and that authority belongs to the study, not
+ * to the app — one build of the app serves many studies, each with its own database. So a study
+ * publishes its authority in its configuration, where {@link StudyUtils} stores it under
+ * {@link Aware_Preferences#DB_CA}.
+ *
+ * A study that publishes one gets a verified connection: a certificate from any other authority ends
+ * the handshake, which confines the upload to that study's own server rather than to whichever host
+ * occupies the network path. A study that publishes none gets an encrypted but unverified connection
+ * — the traffic is unreadable in transit, but nothing proves which host is reading it.
+ *
+ * The driver reads its trust store from a URL, so the configured certificate is turned into a PKCS#12
+ * file in the app's private storage and reused from there. The file name carries a digest of the
+ * certificate, so a study that rotates its authority, or a device that moves between studies, builds
+ * a separate store rather than reusing a stale one.
+ */
+public final class MysqlTls {
+
+ private static final String TAG = "MysqlTls";
+
+ /** Alias the authority's certificate is stored under. */
+ private static final String CA_ALIAS = "study-database-ca";
+
+ /**
+ * Integrity password for the trust store file. A trust store holds certificates that are public
+ * by nature — this one ships inside the APK — and PKCS#12 asks for a password to open a store at
+ * all.
+ */
+ static final String TRUST_STORE_PASSWORD = "awaretruststore";
+
+ /** Characters of the certificate digest that name the store file. */
+ private static final int DIGEST_CHARS_IN_NAME = 16;
+
+ /**
+ * The parameters last built, together with the certificate they were built from. Keyed by the
+ * certificate so a study that publishes a new authority is picked up on the next connection
+ * rather than after the process restarts.
+ */
+ private static volatile String cachedForCertificate;
+ private static volatile String cachedParameters;
+
+ private MysqlTls() {
+ }
+
+ /**
+ * Name of the trust store holding a certificate with the given digest. Pure and side-effect free
+ * so it can be unit-tested without a device.
+ *
+ * @param digestHex hex digest of the certificate's encoded form
+ * @return a file name that belongs to that certificate alone
+ */
+ static String trustStoreName(String digestHex) {
+ return "mysql_truststore_" + digestHex.substring(0, DIGEST_CHARS_IN_NAME) + ".p12";
+ }
+
+ /**
+ * The JDBC URL query parameters that have the driver verify the server's certificate against a
+ * trust store. Pure and side-effect free so it can be unit-tested without a device.
+ *
+ * @param trustStorePath absolute path of the trust store file
+ * @return a fragment opening with {@code &}, to append to a JDBC URL that already carries a query
+ */
+ static String sslParameters(String trustStorePath) {
+ return "&useSSL=true&requireSSL=true&verifyServerCertificate=true"
+ + "&trustCertificateKeyStoreType=PKCS12"
+ + "&trustCertificateKeyStoreUrl=file:" + trustStorePath
+ + "&trustCertificateKeyStorePassword=" + TRUST_STORE_PASSWORD;
+ }
+
+ /**
+ * The JDBC URL query parameters for an encrypted connection whose certificate is not checked,
+ * which is what a study that publishes no authority gets. Pure and side-effect free so it can be
+ * unit-tested without a device.
+ *
+ * @return a fragment opening with {@code &}, to append to a JDBC URL that already carries a query
+ */
+ static String unverifiedParameters() {
+ return "&useSSL=true&requireSSL=true&verifyServerCertificate=false";
+ }
+
+ /**
+ * The TLS parameters for the study this device is enrolled in: verified against the study's own
+ * authority when it publishes one, encrypted but unverified when it does not.
+ *
+ * The caller decides what a failure here means: every connection path treats it the same way as a
+ * database it could not reach, so a batch is kept for the next sync and the participant is left
+ * alone. A study that publishes an unreadable certificate therefore stops uploading rather than
+ * quietly falling back to an unverified connection — a configured authority that cannot be honoured
+ * is a problem to surface, not to work around.
+ *
+ * @param context application context
+ * @return the fragment described by {@link #sslParameters} or {@link #unverifiedParameters}
+ * @throws IOException the trust store file is unreadable
+ * @throws GeneralSecurityException the configured certificate cannot be read, digested or stored
+ */
+ public static String connectionParameters(Context context)
+ throws IOException, GeneralSecurityException {
+ String pem = Aware.getSetting(context, Aware_Preferences.DB_CA);
+ if (pem == null) pem = "";
+ pem = pem.trim();
+
+ if (pem.isEmpty()) {
+ Log.i(TAG, "Study publishes no database certificate authority; "
+ + "the upload is encrypted but the server is not verified.");
+ return unverifiedParameters();
+ }
+
+ if (pem.equals(cachedForCertificate) && cachedParameters != null) return cachedParameters;
+
+ Certificate authority = readCertificate(pem);
+ File store = new File(context.getFilesDir(), trustStoreName(digestOf(authority)));
+ if (!store.exists()) writeTrustStore(authority, store);
+
+ String parameters = sslParameters(store.getAbsolutePath());
+ cachedParameters = parameters;
+ cachedForCertificate = pem;
+ return parameters;
+ }
+
+ /** Reads a PEM-encoded certificate as the study published it. */
+ private static Certificate readCertificate(String pem)
+ throws IOException, GeneralSecurityException {
+ InputStream certificate = new ByteArrayInputStream(pem.getBytes("UTF-8"));
+ try {
+ return CertificateFactory.getInstance("X.509").generateCertificate(certificate);
+ } finally {
+ certificate.close();
+ }
+ }
+
+ /** Hex SHA-256 of a certificate's encoded form, which names the store built from it. */
+ private static String digestOf(Certificate certificate) throws GeneralSecurityException {
+ byte[] digest = MessageDigest.getInstance("SHA-256").digest(certificate.getEncoded());
+ StringBuilder hex = new StringBuilder(digest.length * 2);
+ for (byte value : digest) {
+ hex.append(Character.forDigit((value >> 4) & 0xF, 16));
+ hex.append(Character.forDigit(value & 0xF, 16));
+ }
+ return hex.toString();
+ }
+
+ /** Writes a trust store whose sole entry is the study's certificate authority. */
+ private static void writeTrustStore(Certificate authority, File store)
+ throws IOException, GeneralSecurityException {
+ KeyStore trust = KeyStore.getInstance("PKCS12");
+ trust.load(null, null);
+ trust.setCertificateEntry(CA_ALIAS, authority);
+
+ OutputStream out = new FileOutputStream(store);
+ try {
+ trust.store(out, TRUST_STORE_PASSWORD.toCharArray());
+ } finally {
+ out.close();
+ }
+ }
+}
diff --git a/aware-core/src/main/java/com/aware/utils/Scheduler.java b/aware-core/src/main/java/com/aware/utils/Scheduler.java
index e8bf6779..6b9b6700 100644
--- a/aware-core/src/main/java/com/aware/utils/Scheduler.java
+++ b/aware-core/src/main/java/com/aware/utils/Scheduler.java
@@ -135,7 +135,7 @@ public static void saveSchedule(Context context, Schedule schedule) {
String original_id = schedule.getScheduleID();
String random_seed = original_id;
- random_seed += "-" + Aware.getSetting(context, Aware_Preferences.DEVICE_ID);
+ random_seed += "-" + Aware.getDeviceID(context);
// Get the random events for today
ArrayList randoms = random_times(start, end, random.getInt(RANDOM_TIMES), random.getInt(RANDOM_INTERVAL), random_seed);
// Remove events that are in the past
@@ -177,7 +177,7 @@ public static void saveSchedule(Context context, Schedule schedule) {
} else {
ContentValues data = new ContentValues();
data.put(Scheduler_Provider.Scheduler_Data.TIMESTAMP, System.currentTimeMillis());
- data.put(Scheduler_Provider.Scheduler_Data.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ data.put(Scheduler_Provider.Scheduler_Data.DEVICE_ID, Aware.getDeviceID(context));
data.put(Scheduler_Provider.Scheduler_Data.SCHEDULE_ID, schedule.getScheduleID());
data.put(Scheduler_Provider.Scheduler_Data.SCHEDULE, schedule.build().toString());
data.put(Scheduler_Provider.Scheduler_Data.PACKAGE_NAME, (is_global) ? "com.aware.phone" : context.getPackageName());
@@ -244,7 +244,7 @@ public static void saveSchedule(Context context, Schedule schedule, String packa
String original_id = schedule.getScheduleID();
String random_seed = original_id;
- random_seed += "-" + Aware.getSetting(context, Aware_Preferences.DEVICE_ID);
+ random_seed += "-" + Aware.getDeviceID(context);
// Get the random events for today
ArrayList randoms = random_times(start, end, random.getInt(RANDOM_TIMES), random.getInt(RANDOM_INTERVAL), random_seed);
// Remove events that are in the past
@@ -287,7 +287,7 @@ public static void saveSchedule(Context context, Schedule schedule, String packa
} else {
ContentValues data = new ContentValues();
data.put(Scheduler_Provider.Scheduler_Data.TIMESTAMP, System.currentTimeMillis());
- data.put(Scheduler_Provider.Scheduler_Data.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ data.put(Scheduler_Provider.Scheduler_Data.DEVICE_ID, Aware.getDeviceID(context));
data.put(Scheduler_Provider.Scheduler_Data.SCHEDULE_ID, schedule.getScheduleID());
data.put(Scheduler_Provider.Scheduler_Data.SCHEDULE, schedule.build().toString());
data.put(Scheduler_Provider.Scheduler_Data.PACKAGE_NAME, package_name);
@@ -345,7 +345,7 @@ private static void rescheduleRandom(Context context, Schedule schedule) {
String original_id = schedule.getScheduleID();
String random_seed = original_id;
- random_seed += "-" + Aware.getSetting(context, Aware_Preferences.DEVICE_ID);
+ random_seed += "-" + Aware.getDeviceID(context);
ArrayList randoms = random_times(start, end, random.getInt(RANDOM_TIMES), random.getInt(RANDOM_INTERVAL), random_seed);
long max = getLastRandom(randoms);
@@ -366,7 +366,7 @@ private static void rescheduleRandom(Context context, Schedule schedule) {
ContentValues data = new ContentValues();
data.put(Scheduler_Provider.Scheduler_Data.TIMESTAMP, System.currentTimeMillis());
- data.put(Scheduler_Provider.Scheduler_Data.DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ data.put(Scheduler_Provider.Scheduler_Data.DEVICE_ID, Aware.getDeviceID(context));
data.put(Scheduler_Provider.Scheduler_Data.SCHEDULE_ID, newSchedule.getScheduleID());
data.put(Scheduler_Provider.Scheduler_Data.SCHEDULE, newSchedule.build().toString());
data.put(Scheduler_Provider.Scheduler_Data.PACKAGE_NAME, context.getPackageName());
@@ -1089,7 +1089,7 @@ private void performAction(Schedule schedule) {
scheduler_action.putExtra(EXTRA_SCHEDULER_ID, schedule.getScheduleID());
sendBroadcast(scheduler_action);
- Aware.debug(this, "Scheduler triggered: " + schedule.getScheduleID() + " schedule: " + schedule.build().toString() + " package: " + getPackageName());
+ Aware.debug(this, Aware.LogType.SCHEDULER, "Scheduler triggered: " + schedule.getScheduleID() + " schedule: " + schedule.build().toString() + " package: " + getPackageName());
Log.d(TAG, "Scheduler triggered: " + schedule.getScheduleID() + " schedule: " + schedule.build().toString() + " package: " + getPackageName());
if (schedule.getActionType().equals(ACTION_TYPE_BROADCAST)) {
Intent broadcast = new Intent(schedule.getActionIntentAction());
@@ -1114,44 +1114,18 @@ private void performAction(Schedule schedule) {
}
if (schedule.getActionType().equals(ACTION_TYPE_ACTIVITY)) {
- String[] activity_info = schedule.getActionClass().split("/");
-
- Intent activity = new Intent();
- activity.setComponent(new ComponentName(activity_info[0], activity_info[1]));
- activity.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
-
- if (schedule.getActionIntentAction().length() > 0) {
- activity.setAction(schedule.getActionIntentAction());
- }
-
- JSONArray extras = schedule.getActionExtras();
- for (int i = 0; i < extras.length(); i++) {
- JSONObject extra = extras.getJSONObject(i);
- Object extra_obj = extra.get(ACTION_EXTRA_VALUE);
- if (extra_obj instanceof String) {
- activity.putExtra(extra.getString(ACTION_EXTRA_KEY), extra.getString(ACTION_EXTRA_VALUE));
- } else if (extra_obj instanceof Integer) {
- activity.putExtra(extra.getString(ACTION_EXTRA_KEY), extra.getInt(ACTION_EXTRA_VALUE));
- } else if (extra_obj instanceof Double) {
- activity.putExtra(extra.getString(ACTION_EXTRA_KEY), extra.getDouble(ACTION_EXTRA_VALUE));
- } else if (extra_obj instanceof Long) {
- activity.putExtra(extra.getString(ACTION_EXTRA_KEY), extra.getLong(ACTION_EXTRA_VALUE));
- } else if (extra_obj instanceof Boolean) {
- activity.putExtra(extra.getString(ACTION_EXTRA_KEY), extra.getBoolean(ACTION_EXTRA_VALUE));
- }
- }
- startActivity(activity);
- }
-
- if (schedule.getActionType().equals(ACTION_TYPE_SERVICE)) {
- try {
- String[] service_info = schedule.getActionClass().split("/");
-
- Intent service = new Intent();
- service.setComponent(new ComponentName(service_info[0], service_info[1]));
+ ComponentName activityComponent =
+ ComponentName.unflattenFromString(schedule.getActionClass());
+ if (activityComponent == null) {
+ Log.e(TAG, "Ignoring malformed scheduled activity: "
+ + schedule.getActionClass());
+ } else {
+ Intent activity = new Intent();
+ activity.setComponent(activityComponent);
+ activity.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
if (schedule.getActionIntentAction().length() > 0) {
- service.setAction(schedule.getActionIntentAction());
+ activity.setAction(schedule.getActionIntentAction());
}
JSONArray extras = schedule.getActionExtras();
@@ -1159,21 +1133,62 @@ private void performAction(Schedule schedule) {
JSONObject extra = extras.getJSONObject(i);
Object extra_obj = extra.get(ACTION_EXTRA_VALUE);
if (extra_obj instanceof String) {
- service.putExtra(extra.getString(ACTION_EXTRA_KEY), extra.getString(ACTION_EXTRA_VALUE));
+ activity.putExtra(extra.getString(ACTION_EXTRA_KEY), extra.getString(ACTION_EXTRA_VALUE));
} else if (extra_obj instanceof Integer) {
- service.putExtra(extra.getString(ACTION_EXTRA_KEY), extra.getInt(ACTION_EXTRA_VALUE));
+ activity.putExtra(extra.getString(ACTION_EXTRA_KEY), extra.getInt(ACTION_EXTRA_VALUE));
} else if (extra_obj instanceof Double) {
- service.putExtra(extra.getString(ACTION_EXTRA_KEY), extra.getDouble(ACTION_EXTRA_VALUE));
+ activity.putExtra(extra.getString(ACTION_EXTRA_KEY), extra.getDouble(ACTION_EXTRA_VALUE));
} else if (extra_obj instanceof Long) {
- service.putExtra(extra.getString(ACTION_EXTRA_KEY), extra.getLong(ACTION_EXTRA_VALUE));
+ activity.putExtra(extra.getString(ACTION_EXTRA_KEY), extra.getLong(ACTION_EXTRA_VALUE));
} else if (extra_obj instanceof Boolean) {
- service.putExtra(extra.getString(ACTION_EXTRA_KEY), extra.getBoolean(ACTION_EXTRA_VALUE));
+ activity.putExtra(extra.getString(ACTION_EXTRA_KEY), extra.getBoolean(ACTION_EXTRA_VALUE));
}
}
- startService(service);
+ try {
+ startActivity(activity);
+ } catch (RuntimeException e) {
+ Log.e(TAG, "Unable to launch scheduled activity "
+ + schedule.getActionClass(), e);
+ }
+ }
+ }
- } catch (JSONException e) {
- e.printStackTrace();
+ if (schedule.getActionType().equals(ACTION_TYPE_SERVICE)) {
+ try {
+ ComponentName serviceComponent =
+ ComponentName.unflattenFromString(schedule.getActionClass());
+ if (serviceComponent == null) {
+ Log.e(TAG, "Ignoring malformed scheduled service: "
+ + schedule.getActionClass());
+ } else {
+ Intent service = new Intent();
+ service.setComponent(serviceComponent);
+
+ if (schedule.getActionIntentAction().length() > 0) {
+ service.setAction(schedule.getActionIntentAction());
+ }
+
+ JSONArray extras = schedule.getActionExtras();
+ for (int i = 0; i < extras.length(); i++) {
+ JSONObject extra = extras.getJSONObject(i);
+ Object extra_obj = extra.get(ACTION_EXTRA_VALUE);
+ if (extra_obj instanceof String) {
+ service.putExtra(extra.getString(ACTION_EXTRA_KEY), extra.getString(ACTION_EXTRA_VALUE));
+ } else if (extra_obj instanceof Integer) {
+ service.putExtra(extra.getString(ACTION_EXTRA_KEY), extra.getInt(ACTION_EXTRA_VALUE));
+ } else if (extra_obj instanceof Double) {
+ service.putExtra(extra.getString(ACTION_EXTRA_KEY), extra.getDouble(ACTION_EXTRA_VALUE));
+ } else if (extra_obj instanceof Long) {
+ service.putExtra(extra.getString(ACTION_EXTRA_KEY), extra.getLong(ACTION_EXTRA_VALUE));
+ } else if (extra_obj instanceof Boolean) {
+ service.putExtra(extra.getString(ACTION_EXTRA_KEY), extra.getBoolean(ACTION_EXTRA_VALUE));
+ }
+ }
+ startService(service);
+ }
+ } catch (JSONException | RuntimeException e) {
+ Log.e(TAG, "Unable to launch scheduled service "
+ + schedule.getActionClass(), e);
}
}
diff --git a/aware-core/src/main/java/com/aware/utils/SensorAvailability.java b/aware-core/src/main/java/com/aware/utils/SensorAvailability.java
new file mode 100644
index 00000000..f996e8d0
--- /dev/null
+++ b/aware-core/src/main/java/com/aware/utils/SensorAvailability.java
@@ -0,0 +1,85 @@
+package com.aware.utils;
+
+import android.content.Context;
+import android.hardware.Sensor;
+import android.hardware.SensorManager;
+import android.os.Build;
+
+import com.aware.Aware_Preferences;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Single source of truth for "does this device physically have the sensor hardware a given
+ * status_* setting depends on". Lives in aware-core (not aware-phone) so background code — the
+ * config-sync drift check in {@link StudyUtils}, and potentially the compliance/diagnostics
+ * scheduler — can ask this question without depending on the UI module.
+ *
+ * aware-phone's SensorCollection has its own categoryKey-keyed registry for the same underlying
+ * fact (used for participant-facing "why isn't this collecting" messages, keyed by UI preference
+ * key rather than the raw status_* setting string) and delegates its own hardware check to
+ * {@link #hasHardware(Context, int)} here rather than calling SensorManager directly a second
+ * time — this class is the one place that talks to SensorManager for hardware presence.
+ */
+public final class SensorAvailability {
+
+ private SensorAvailability() {}
+
+ /**
+ * status_* setting -> Android Sensor.TYPE_* constant, for every sensor that depends on
+ * physical hardware. A setting absent from this map isn't hardware-gated at all (e.g. it's
+ * permission-gated, or always available), so {@link #isHardwareAvailable} treats it as
+ * available by definition.
+ */
+ private static final Map HARDWARE_BACKED = new HashMap<>();
+
+ static {
+ HARDWARE_BACKED.put(Aware_Preferences.STATUS_ACCELEROMETER, Sensor.TYPE_ACCELEROMETER);
+ HARDWARE_BACKED.put(Aware_Preferences.STATUS_LINEAR_ACCELEROMETER, Sensor.TYPE_LINEAR_ACCELERATION);
+ HARDWARE_BACKED.put(Aware_Preferences.STATUS_SIGNIFICANT_MOTION, Sensor.TYPE_ACCELEROMETER);
+ HARDWARE_BACKED.put(Aware_Preferences.STATUS_BAROMETER, Sensor.TYPE_PRESSURE);
+ HARDWARE_BACKED.put(Aware_Preferences.STATUS_GRAVITY, Sensor.TYPE_GRAVITY);
+ HARDWARE_BACKED.put(Aware_Preferences.STATUS_GYROSCOPE, Sensor.TYPE_GYROSCOPE);
+ HARDWARE_BACKED.put(Aware_Preferences.STATUS_LIGHT, Sensor.TYPE_LIGHT);
+ HARDWARE_BACKED.put(Aware_Preferences.STATUS_MAGNETOMETER, Sensor.TYPE_MAGNETIC_FIELD);
+ HARDWARE_BACKED.put(Aware_Preferences.STATUS_PROXIMITY, Sensor.TYPE_PROXIMITY);
+ HARDWARE_BACKED.put(Aware_Preferences.STATUS_ROTATION, Sensor.TYPE_ROTATION_VECTOR);
+ HARDWARE_BACKED.put(Aware_Preferences.STATUS_TEMPERATURE, Sensor.TYPE_AMBIENT_TEMPERATURE);
+ }
+
+ /**
+ * True if {@code statusSetting} isn't hardware-gated (nothing to check), or the device has the
+ * hardware it needs. False only when the setting requires specific sensor hardware and this
+ * device doesn't have it — the one case where no amount of settings-reapplying can ever make
+ * the sensor start collecting.
+ */
+ public static boolean isHardwareAvailable(Context context, String statusSetting) {
+ if (!isPlatformSupported(statusSetting, Build.VERSION.SDK_INT)) return false;
+ Integer sensorType = HARDWARE_BACKED.get(statusSetting);
+ if (sensorType == null) return true; // not hardware-gated at all
+ return hasHardware(context, sensorType);
+ }
+
+ /**
+ * Some legacy AWARE sensors do not depend on SensorManager hardware but are nevertheless
+ * unavailable on newer Android releases. Processor reads /proc/stat, which Android blocks from
+ * Nougat onward; treating it as available creates a feedback loop where the config turns it on,
+ * the service turns itself off, and config reconciliation turns it on again.
+ */
+ static boolean isPlatformSupported(String statusSetting, int sdkInt) {
+ return !Aware_Preferences.STATUS_PROCESSOR.equals(statusSetting)
+ || sdkInt < Build.VERSION_CODES.N;
+ }
+
+ /** True if {@code statusSetting} depends on specific physical sensor hardware at all. */
+ static boolean isHardwareBacked(String statusSetting) {
+ return HARDWARE_BACKED.containsKey(statusSetting);
+ }
+
+ /** Raw hardware-presence check — the one place in the codebase that should call this. */
+ public static boolean hasHardware(Context context, int sensorType) {
+ SensorManager sm = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
+ return sm != null && sm.getDefaultSensor(sensorType) != null;
+ }
+}
diff --git a/aware-core/src/main/java/com/aware/utils/SensorDiagnostics.java b/aware-core/src/main/java/com/aware/utils/SensorDiagnostics.java
new file mode 100644
index 00000000..e871b759
--- /dev/null
+++ b/aware-core/src/main/java/com/aware/utils/SensorDiagnostics.java
@@ -0,0 +1,554 @@
+package com.aware.utils;
+
+import android.Manifest;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.location.LocationManager;
+import android.net.Uri;
+import android.os.Build;
+import android.text.TextUtils;
+import android.provider.Settings;
+
+import androidx.core.content.ContextCompat;
+
+import com.aware.Applications;
+import com.aware.Aware;
+import com.aware.Aware_Preferences;
+import com.aware.providers.Aware_Provider;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import android.database.Cursor;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Writes one "sensor_status" line per study-enabled sensor into aware_log — a table that already
+ * syncs to the researcher's database via AwareSyncAdapter, so this needs no new server-side schema.
+ * Today a researcher can see *that* a sensor has no rows on a participant's device, but not *why*
+ * (no hardware, missing permission, accessibility off, location services off) — that reason only
+ * ever existed on-device, in aware-phone's SensorCollection, which the participant sees but the
+ * researcher never does. This makes the same reason visible to the researcher.
+ *
+ * Deliberately duplicates a slimmed-down version of SensorCollection's gating registry (permissions
+ * / accessibility / location-services only, keyed by the status_* setting name rather than
+ * SensorCollection's UI categoryKey) because aware-core cannot depend on aware-phone. Hardware
+ * gating specifically is NOT duplicated — this and SensorCollection both delegate to
+ * {@link SensorAvailability}, so there's exactly one place that decides "does this device have the
+ * hardware", even though there are still two places that decide "does this sensor need a runtime
+ * permission" (this class, and SensorCollection's own registry, for its participant-facing UI).
+ *
+ * For regularly sampled sensors this also queries the latest local provider timestamp and applies
+ * the same frequency-derived freshness policy as the participant UI. Event-driven sensors are kept
+ * separate: a quiet period is normal for them, so they report waiting_for_event rather than delayed.
+ * Each periodic record therefore gives the researcher both prerequisite failures and silent/stale
+ * collection failures without requiring a new server-side table.
+ */
+public final class SensorDiagnostics {
+
+ private SensorDiagnostics() {}
+
+ /** Gating requirements for one status_* setting, beyond hardware (see SensorAvailability). */
+ private static final class Gate {
+ final String[] permissions;
+ final boolean needsAccessibility;
+ final boolean needsLocationServices;
+
+ Gate(String[] permissions, boolean needsAccessibility, boolean needsLocationServices) {
+ this.permissions = permissions;
+ this.needsAccessibility = needsAccessibility;
+ this.needsLocationServices = needsLocationServices;
+ }
+ }
+
+ private static final String[] NONE = new String[0];
+ private static final Map GATES = new HashMap<>();
+ private static final Map SAMPLED = new HashMap<>();
+
+ private static final class Source {
+ final String authoritySuffix;
+ final String table;
+ final String frequencySetting;
+ final long defaultFrequency;
+ final SensorFreshness.Unit unit;
+
+ Source(
+ String authoritySuffix,
+ String table,
+ String frequencySetting,
+ long defaultFrequency,
+ SensorFreshness.Unit unit) {
+ this.authoritySuffix = authoritySuffix;
+ this.table = table;
+ this.frequencySetting = frequencySetting;
+ this.defaultFrequency = defaultFrequency;
+ this.unit = unit;
+ }
+ }
+
+ static {
+ GATES.put(
+ Aware_Preferences.STATUS_LOCATION_GPS,
+ new Gate(
+ new String[]{Manifest.permission.ACCESS_FINE_LOCATION},
+ false,
+ false
+ )
+ );
+ GATES.put(
+ Aware_Preferences.STATUS_LOCATION_NETWORK,
+ new Gate(
+ new String[]{Manifest.permission.ACCESS_FINE_LOCATION},
+ false,
+ false
+ )
+ );
+ // WiFi scanning needs the OS-level Location toggle on, in addition to the permission below —
+ // see SensorCollection's identical note on WifiManager.startScan().
+ GATES.put(
+ Aware_Preferences.STATUS_WIFI,
+ new Gate(
+ new String[]{Manifest.permission.ACCESS_COARSE_LOCATION},
+ false,
+ true
+ )
+ );
+ GATES.put(
+ Aware_Preferences.STATUS_BLUETOOTH,
+ new Gate(
+ // API 31+ needs BLUETOOTH_SCAN/CONNECT (referenced by string — added after this
+ // module's compile SDK); older versions gate Bluetooth scanning on location.
+ Build.VERSION.SDK_INT >= 31
+ ? new String[]{"android.permission.BLUETOOTH_SCAN", "android.permission.BLUETOOTH_CONNECT"}
+ : new String[]{Manifest.permission.ACCESS_COARSE_LOCATION},
+ false,
+ false
+ )
+ );
+ GATES.put(
+ Aware_Preferences.STATUS_TELEPHONY,
+ new Gate(
+ new String[]{
+ Manifest.permission.READ_PHONE_STATE,
+ Manifest.permission.ACCESS_COARSE_LOCATION
+ },
+ false,
+ false
+ )
+ );
+ GATES.put(
+ Aware_Preferences.STATUS_COMMUNICATION_EVENTS,
+ new Gate(
+ new String[]{
+ Manifest.permission.READ_CALL_LOG,
+ Manifest.permission.READ_PHONE_STATE,
+ Manifest.permission.READ_SMS
+ },
+ false,
+ false
+ )
+ );
+ GATES.put(
+ Aware_Preferences.STATUS_CALLS,
+ new Gate(
+ new String[]{
+ Manifest.permission.READ_CALL_LOG,
+ Manifest.permission.READ_PHONE_STATE
+ },
+ false,
+ false
+ )
+ );
+ GATES.put(
+ Aware_Preferences.STATUS_MESSAGES,
+ new Gate(
+ new String[]{
+ Manifest.permission.READ_SMS
+ },
+ false,
+ false
+ )
+ );
+ GATES.put(
+ Aware_Preferences.STATUS_APPLICATIONS,
+ new Gate(
+ NONE,
+ true,
+ false
+ )
+ );
+ GATES.put(
+ Aware_Preferences.STATUS_NOTIFICATIONS,
+ new Gate(
+ NONE,
+ true,
+ false
+ )
+ );
+ GATES.put(
+ Aware_Preferences.STATUS_CRASHES,
+ new Gate(
+ NONE,
+ true,
+ false
+ )
+ );
+ GATES.put(
+ Aware_Preferences.STATUS_KEYBOARD,
+ new Gate(
+ NONE,
+ true,
+ false
+ )
+ );
+ GATES.put(
+ Aware_Preferences.STATUS_SCREENSHOT,
+ new Gate(
+ NONE,
+ true,
+ false
+ )
+ );
+ GATES.put(
+ Aware_Preferences.STATUS_PLUGIN_AMBIENT_NOISE,
+ new Gate(
+ new String[]{Manifest.permission.RECORD_AUDIO},
+ false,
+ false
+ )
+ );
+ GATES.put(
+ Aware_Preferences.STATUS_PLUGIN_OPENWEATHER,
+ new Gate(
+ new String[]{Manifest.permission.ACCESS_COARSE_LOCATION},
+ false,
+ false
+ )
+ );
+
+ sampled(Aware_Preferences.STATUS_ACCELEROMETER, ".provider.accelerometer",
+ "accelerometer", Aware_Preferences.FREQUENCY_ACCELEROMETER,
+ 200000, SensorFreshness.Unit.MICROSECONDS);
+ sampled(Aware_Preferences.STATUS_LINEAR_ACCELEROMETER, ".provider.accelerometer.linear",
+ "linear_accelerometer", Aware_Preferences.FREQUENCY_LINEAR_ACCELEROMETER,
+ 200000, SensorFreshness.Unit.MICROSECONDS);
+ sampled(Aware_Preferences.STATUS_BAROMETER, ".provider.barometer",
+ "barometer", Aware_Preferences.FREQUENCY_BAROMETER,
+ 200000, SensorFreshness.Unit.MICROSECONDS);
+ sampled(Aware_Preferences.STATUS_GRAVITY, ".provider.gravity",
+ "gravity", Aware_Preferences.FREQUENCY_GRAVITY,
+ 200000, SensorFreshness.Unit.MICROSECONDS);
+ sampled(Aware_Preferences.STATUS_GYROSCOPE, ".provider.gyroscope",
+ "gyroscope", Aware_Preferences.FREQUENCY_GYROSCOPE,
+ 200000, SensorFreshness.Unit.MICROSECONDS);
+ sampled(Aware_Preferences.STATUS_LIGHT, ".provider.light",
+ "light", Aware_Preferences.FREQUENCY_LIGHT,
+ 200000, SensorFreshness.Unit.MICROSECONDS);
+ sampled(Aware_Preferences.STATUS_MAGNETOMETER, ".provider.magnetometer",
+ "magnetometer", Aware_Preferences.FREQUENCY_MAGNETOMETER,
+ 200000, SensorFreshness.Unit.MICROSECONDS);
+ sampled(Aware_Preferences.STATUS_PROXIMITY, ".provider.proximity",
+ "proximity", Aware_Preferences.FREQUENCY_PROXIMITY,
+ 200000, SensorFreshness.Unit.MICROSECONDS);
+ sampled(Aware_Preferences.STATUS_ROTATION, ".provider.rotation",
+ "rotation", Aware_Preferences.FREQUENCY_ROTATION,
+ 200000, SensorFreshness.Unit.MICROSECONDS);
+ sampled(Aware_Preferences.STATUS_TEMPERATURE, ".provider.temperature",
+ "temperature", Aware_Preferences.FREQUENCY_TEMPERATURE,
+ 200000, SensorFreshness.Unit.MICROSECONDS);
+ sampled(Aware_Preferences.STATUS_BLUETOOTH, ".provider.bluetooth",
+ "bluetooth", Aware_Preferences.FREQUENCY_BLUETOOTH,
+ 60, SensorFreshness.Unit.SECONDS);
+ sampled(Aware_Preferences.STATUS_PROCESSOR, ".provider.processor",
+ "processor", Aware_Preferences.FREQUENCY_PROCESSOR,
+ 10, SensorFreshness.Unit.SECONDS);
+ sampled(Aware_Preferences.STATUS_WIFI, ".provider.wifi",
+ "wifi", Aware_Preferences.FREQUENCY_WIFI,
+ 60, SensorFreshness.Unit.SECONDS);
+ sampled(Aware_Preferences.STATUS_NETWORK_TRAFFIC, ".provider.traffic",
+ "network_traffic", Aware_Preferences.FREQUENCY_NETWORK_TRAFFIC,
+ 30, SensorFreshness.Unit.SECONDS);
+ sampled(Aware_Preferences.STATUS_LOCATION_GPS, ".provider.locations",
+ "locations", Aware_Preferences.FREQUENCY_LOCATION_GPS,
+ 180, SensorFreshness.Unit.SECONDS);
+ sampled(Aware_Preferences.STATUS_LOCATION_NETWORK, ".provider.locations",
+ "locations", Aware_Preferences.FREQUENCY_LOCATION_NETWORK,
+ 300, SensorFreshness.Unit.SECONDS);
+ sampled(Aware_Preferences.STATUS_SCREENSHOT, ".provider.screenshot",
+ "screenshot", Aware_Preferences.CAPTURE_TIME_INTERVAL,
+ 60000, SensorFreshness.Unit.MILLISECONDS);
+ }
+
+ private static void sampled(
+ String statusSetting,
+ String authoritySuffix,
+ String table,
+ String frequencySetting,
+ long defaultFrequency,
+ SensorFreshness.Unit unit) {
+ SAMPLED.put(statusSetting, new Source(
+ authoritySuffix, table, frequencySetting, defaultFrequency, unit));
+ }
+
+ /**
+ * True if a status_* setting needs an explicit participant grant — a runtime permission or the
+ * Accessibility Service — i.e. it's a sensor consent must cover before it may collect. Base
+ * sensors (no gate, or a gate needing only the Location-services toggle) return false: they need
+ * no per-sensor agreement. Lets aware-core (e.g. the config sync) decide which newly-added
+ * sensors to hold off until consent, without depending on aware-phone's SensorCollection.
+ */
+ public static boolean requiresConsent(String statusSetting) {
+ Gate gate = GATES.get(statusSetting);
+ return gate != null && (gate.permissions.length > 0 || gate.needsAccessibility);
+ }
+
+ /**
+ * Pure reason computation given already-resolved booleans — split out from
+ * {@link #computeReason(Context, String, boolean)} so it's directly unit-testable without a
+ * Context, same reasoning as StudyUtils.driftSignature()'s split from liveDriftSignature().
+ *
+ * @param statusSetting the status_* setting, used only to look up its Gate
+ * @param hardwareAvailable result of SensorAvailability.isHardwareAvailable
+ * @param missingPermission the first ungranted permission this sensor needs, or null
+ * @param accessibilityEnabled whether AWARE's Accessibility Service is currently on
+ * @param locationServicesEnabled whether the OS-level Location toggle is currently on
+ * @return "" if nothing is blocking this sensor, otherwise a short human-readable reason
+ */
+ static String reasonGivenState(
+ String statusSetting,
+ boolean hardwareAvailable,
+ String missingPermission,
+ boolean accessibilityEnabled,
+ boolean locationServicesEnabled
+ ) {
+ if (!hardwareAvailable) return "No such sensor hardware on this device";
+
+ Gate gate = GATES.get(statusSetting);
+ if (gate == null) return ""; // not gated by anything besides hardware, already checked above
+
+ if (gate.needsAccessibility && !accessibilityEnabled) return "Accessibility service is off";
+ if (gate.needsLocationServices && !locationServicesEnabled) return "Location services are off";
+ if (missingPermission != null) return "Missing permission: " + shortPermission(missingPermission);
+ return "";
+ }
+
+ /** Context-backed wrapper of {@link #reasonGivenState} — resolves the live state and delegates. */
+ public static String computeReason(
+ Context context,
+ String statusSetting,
+ boolean accessibilityEnabled
+ ) {
+ boolean hardwareAvailable = SensorAvailability.isHardwareAvailable(context, statusSetting);
+ Gate gate = GATES.get(statusSetting);
+ String missingPermission = gate == null ? null : firstMissingPermission(context, gate.permissions);
+ boolean locationServicesEnabled = gate != null && gate.needsLocationServices && isLocationServicesEnabled(context);
+ return reasonGivenState(statusSetting, hardwareAvailable, missingPermission, accessibilityEnabled, locationServicesEnabled);
+ }
+
+ /**
+ * Writes one "sensor_status" line to aware_log for every status_* setting in {@code sensors}
+ * that the study enabled (value == true). Sampled sensors are evaluated against three configured
+ * intervals (with a two-minute floor and one-day cap); event-driven sensors report
+ * waiting_for_event and are not treated as delayed. Format is a fixed, parseable key=value line so a
+ * researcher can filter aware_log with e.g. "WHERE log_message LIKE 'sensor_status%'":
+ * sensor_status ts=<ms> sensor=<key> state=<state> device_enabled=<bool>
+ * last_data_ms=<ms> expected_within_ms=<ms> excluded=<bool> reason="<text>"
+ */
+ public static void logSensorStatus(Context context, JSONArray sensors) {
+ if (sensors == null) return;
+ boolean accessibilityEnabled = isAccessibilityServiceEnabled(context);
+ long now = System.currentTimeMillis();
+
+ for (int i = 0; i < sensors.length(); i++) {
+ JSONObject sensor = sensors.optJSONObject(i);
+ if (sensor == null) continue;
+
+ String setting = sensor.optString("setting", "");
+ if (!setting.startsWith("status_") || !sensor.optBoolean("value", false)) continue;
+
+ String blocker = computeReason(context, setting, accessibilityEnabled);
+ boolean deviceEnabled = "true".equals(Aware.getSetting(context, setting));
+ Source source = SAMPLED.get(setting);
+ long lastData = source == null ? 0 : latestTimestamp(context, source);
+ long freshnessWindow = source == null ? 0 : SensorFreshness.windowMs(
+ Aware.getSetting(context, source.frequencySetting),
+ source.defaultFrequency,
+ source.unit);
+ String state = stateGiven(
+ blocker, deviceEnabled, source == null, now, lastData, freshnessWindow);
+ boolean excluded = !state.equals("collecting") && !state.equals("waiting_for_event");
+ String reason = stateReason(state, blocker);
+ String key = setting.substring("status_".length());
+
+ String line = "sensor_status ts=" + now
+ + " sensor=" + key
+ + " enabled=true"
+ + " configured_enabled=true"
+ + " device_enabled=" + deviceEnabled
+ + " state=" + state
+ + " last_data_ms=" + lastData
+ + " expected_within_ms=" + freshnessWindow
+ + " excluded=" + excluded
+ + " reason=\"" + reason.replace("\"", "'") + "\"";
+ Aware.debug(context, Aware.LogType.DIAGNOSTICS, line);
+ }
+ }
+
+ static String stateGiven(
+ String blocker,
+ boolean deviceEnabled,
+ boolean eventDriven,
+ long now,
+ long lastData,
+ long freshnessWindow) {
+ if (blocker != null && blocker.startsWith("No such sensor hardware")) return "unavailable";
+ if (!deviceEnabled) return "disabled";
+ if (blocker != null && !blocker.isEmpty()) return "blocked";
+ if (eventDriven) return "waiting_for_event";
+ if (lastData == 0) return "waiting_first_sample";
+ return SensorFreshness.isFresh(now, lastData, freshnessWindow) ? "collecting" : "delayed";
+ }
+
+ private static String stateReason(String state, String blocker) {
+ if ("unavailable".equals(state) || "blocked".equals(state)) return blocker;
+ if ("disabled".equals(state)) return "Sensor is disabled on the device";
+ if ("waiting_for_event".equals(state)) return "Enabled; records data when an event occurs";
+ if ("waiting_first_sample".equals(state)) return "Waiting for the first sample";
+ if ("delayed".equals(state)) return "Latest sample is older than the expected window";
+ return "";
+ }
+
+ /**
+ * The most recent moment this sensor is known to have produced data.
+ *
+ * Taken as the later of the newest local row and the point the upload bookmark says was
+ * delivered. The local table alone is not enough: with {@code webservice_remove_data} on,
+ * acknowledged rows are deleted after upload, so a sensor that is collecting and delivering
+ * normally can hold no local rows at all. Reading only the table then reports 0, which
+ * {@link #stateFor} turns into {@code waiting_first_sample} — a sensor that has been working for
+ * hours described as never having started, in the participant's status text and in the
+ * diagnostics uploaded to the researcher.
+ *
+ * The bookmark is a truthful lower bound: if data up to T reached the database, data existed
+ * at T. It recovers exactly the fact deletion destroyed.
+ */
+ private static long latestTimestamp(Context context, Source source) {
+ return observedLatest(newestLocalRow(context, source), deliveredUpTo(context, source.table));
+ }
+
+ /**
+ * Reconciles the two records of when a sensor last produced data. Pure, so the case that caused
+ * the bug — nothing local, something delivered — is covered by a unit test.
+ */
+ static long observedLatest(long newestLocalRow, long deliveredUpTo) {
+ return Math.max(newestLocalRow, deliveredUpTo);
+ }
+
+ private static long newestLocalRow(Context context, Source source) {
+ Uri uri = Uri.parse("content://" + context.getPackageName()
+ + source.authoritySuffix + "/" + source.table);
+ Cursor cursor = null;
+ try {
+ cursor = context.getContentResolver().query(
+ uri, new String[]{"timestamp"}, null, null, "_id DESC LIMIT 1");
+ if (cursor != null && cursor.moveToFirst()) return (long) cursor.getDouble(0);
+ } catch (Exception ignored) {
+ // Missing/unreadable provider is represented as no sample yet.
+ } finally {
+ if (cursor != null) cursor.close();
+ }
+ return 0;
+ }
+
+ /**
+ * The timestamp this table's upload bookmark reports as delivered, or 0 when there is none.
+ * A bookmark keyed by a name no longer in use simply yields 0, leaving the local row as the
+ * only evidence — the behaviour before this lookup existed.
+ */
+ private static long deliveredUpTo(Context context, String table) {
+ Cursor marker = null;
+ try {
+ marker = context.getContentResolver().query(
+ Aware_Provider.Aware_Sync_Markers.CONTENT_URI,
+ new String[]{Aware_Provider.Aware_Sync_Markers.MARKER_LAST_SYNCED},
+ Aware_Provider.Aware_Sync_Markers.MARKER_TABLE + "=?",
+ new String[]{table}, null);
+ if (marker != null && marker.moveToFirst()) return (long) marker.getDouble(0);
+ } catch (Exception ignored) {
+ // No bookmark yet — nothing delivered.
+ } finally {
+ if (marker != null) marker.close();
+ }
+ return 0;
+ }
+
+ /**
+ * Convenience for callers that only have a Context, not an already-parsed sensors array (e.g.
+ * the periodic compliance schedule) — looks up the currently active study and logs its sensors.
+ * No-op if there's no active study or its config can't be parsed.
+ */
+ public static void logActiveStudySensorStatus(Context context) {
+ Cursor study = Aware.getActiveStudy(context);
+ if (study == null) return;
+ try {
+ if (!study.moveToFirst()) return;
+ JSONArray configs = new JSONArray(study.getString(
+ study.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_CONFIG)));
+ for (int i = 0; i < configs.length(); i++) {
+ JSONObject element = configs.optJSONObject(i);
+ if (element != null && element.has("sensors")) {
+ logSensorStatus(context, element.optJSONArray("sensors"));
+ return; // one config element carries the sensors array; no need to keep looking
+ }
+ }
+ } catch (Exception e) {
+ // Malformed/missing config — nothing meaningful to log.
+ } finally {
+ study.close();
+ }
+ }
+
+ private static String firstMissingPermission(Context context, String[] permissions) {
+ for (String p : permissions) {
+ if (ContextCompat.checkSelfPermission(context, p) != PackageManager.PERMISSION_GRANTED) {
+ return p;
+ }
+ }
+ return null;
+ }
+
+ private static String shortPermission(String permission) {
+ int dot = permission.lastIndexOf('.');
+ return dot >= 0 ? permission.substring(dot + 1) : permission;
+ }
+
+ /** The OS-level Location toggle (Settings › Location) — distinct from the location permission. */
+ private static boolean isLocationServicesEnabled(Context context) {
+ LocationManager lm = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);
+ if (lm == null) return false;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ return lm.isLocationEnabled();
+ }
+ return lm.isProviderEnabled(LocationManager.GPS_PROVIDER) || lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER);
+ }
+
+ /** True if AWARE's Accessibility Service (backs Applications, Keyboard, Screenshot) is on. */
+ private static boolean isAccessibilityServiceEnabled(Context context) {
+ ComponentName expected = new ComponentName(context, Applications.class);
+ String enabledServices = Settings.Secure.getString(
+ context.getContentResolver(), Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES);
+ if (enabledServices == null) return false;
+
+ TextUtils.SimpleStringSplitter splitter = new TextUtils.SimpleStringSplitter(':');
+ splitter.setString(enabledServices);
+ while (splitter.hasNext()) {
+ if (expected.equals(ComponentName.unflattenFromString(splitter.next()))) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/aware-core/src/main/java/com/aware/utils/SensorFreshness.java b/aware-core/src/main/java/com/aware/utils/SensorFreshness.java
new file mode 100644
index 00000000..f660ef90
--- /dev/null
+++ b/aware-core/src/main/java/com/aware/utils/SensorFreshness.java
@@ -0,0 +1,68 @@
+package com.aware.utils;
+
+/**
+ * Shared policy for deciding how long regularly sampled sensor data remains "fresh".
+ *
+ * The grace window is three configured sampling intervals, never less than two minutes and never
+ * more than one day. The floor avoids flickering during normal Android scheduling/batching delays;
+ * the cap prevents a bad or extreme setting from making a stopped sensor look healthy forever.
+ * Event-driven sensors do not use this policy.
+ */
+public final class SensorFreshness {
+
+ public enum Unit {
+ MICROSECONDS,
+ MILLISECONDS,
+ SECONDS,
+ MINUTES
+ }
+
+ public static final long MIN_WINDOW_MS = 2L * 60L * 1000L;
+ public static final long MAX_WINDOW_MS = 24L * 60L * 60L * 1000L;
+ private static final long INTERVAL_MULTIPLIER = 3L;
+
+ private SensorFreshness() {}
+
+ public static long windowMs(String configuredValue, long defaultValue, Unit unit) {
+ long value = defaultValue;
+ try {
+ if (configuredValue != null && !configuredValue.trim().isEmpty()) {
+ value = Long.parseLong(configuredValue.trim());
+ }
+ } catch (NumberFormatException ignored) {
+ value = defaultValue;
+ }
+
+ if (value < 0) value = defaultValue;
+ long intervalMs = toMilliseconds(value, unit);
+ long candidate;
+ if (intervalMs > Long.MAX_VALUE / INTERVAL_MULTIPLIER) {
+ candidate = Long.MAX_VALUE;
+ } else {
+ candidate = intervalMs * INTERVAL_MULTIPLIER;
+ }
+ return Math.max(MIN_WINDOW_MS, Math.min(candidate, MAX_WINDOW_MS));
+ }
+
+ public static boolean isFresh(long nowMs, long lastDataMs, long windowMs) {
+ return lastDataMs > 0 && nowMs >= lastDataMs && nowMs - lastDataMs <= windowMs;
+ }
+
+ private static long toMilliseconds(long value, Unit unit) {
+ switch (unit) {
+ case MICROSECONDS:
+ return value / 1000L;
+ case SECONDS:
+ return safeMultiply(value, 1000L);
+ case MINUTES:
+ return safeMultiply(value, 60L * 1000L);
+ case MILLISECONDS:
+ default:
+ return value;
+ }
+ }
+
+ private static long safeMultiply(long value, long multiplier) {
+ return value > Long.MAX_VALUE / multiplier ? Long.MAX_VALUE : value * multiplier;
+ }
+}
diff --git a/aware-core/src/main/java/com/aware/utils/SensorThresholds.java b/aware-core/src/main/java/com/aware/utils/SensorThresholds.java
new file mode 100644
index 00000000..b434cb4d
--- /dev/null
+++ b/aware-core/src/main/java/com/aware/utils/SensorThresholds.java
@@ -0,0 +1,114 @@
+package com.aware.utils;
+
+import com.aware.Aware_Preferences;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * The unit and the usable upper limit of every THRESHOLD_* setting.
+ *
+ * A threshold is a change filter, not a cutoff on the reading. A sensor stores a reading only when
+ * it differs from the last reading it STORED by at least the threshold, in the sensor's own native
+ * unit; on the three-axis sensors only when every axis is within it. Slow drift therefore still
+ * accumulates until it crosses.
+ *
+ * {@link Spec#limit} is the largest value that leaves the sensor recording usefully: the biggest
+ * change the sensor produces in normal use. Past it, readings essentially never differ by that
+ * much, so every sample is filtered out and the sensor goes silent while still reporting itself as
+ * enabled. A deployed study config carried threshold_accelerometer 120 and threshold_magnetometer
+ * 1000000, and those sensors held four rows between them for the study's lifetime.
+ *
+ * The numbers match the presets offered by the study Configurator, so a value picked there and a
+ * value picked on the phone mean the same thing.
+ */
+public final class SensorThresholds {
+
+ /** The unit a threshold is expressed in, and the point past which it stops being useful. */
+ public static final class Spec {
+ public final String unit;
+ public final double limit;
+ public final int axes;
+
+ Spec(String unit, double limit, int axes) {
+ this.unit = unit;
+ this.limit = limit;
+ this.axes = axes;
+ }
+ }
+
+ private static final Map SPECS;
+
+ static {
+ Map specs = new HashMap<>();
+ // Motion: per-axis noise measured across five smartphones is 0.0042-0.0106 m/s², and
+ // changes beyond ~20 m/s² are not what a phone in a pocket produces.
+ specs.put(Aware_Preferences.THRESHOLD_ACCELEROMETER, new Spec("m/s²", 20, 3));
+ specs.put(Aware_Preferences.THRESHOLD_LINEAR_ACCELEROMETER, new Spec("m/s²", 20, 3));
+ // Gravity is an orientation signal: a single axis spans ±9.81.
+ specs.put(Aware_Preferences.THRESHOLD_GRAVITY, new Spec("m/s²", 9.81, 3));
+ // 5 rad/s is about 286°/s, past anything normal handling produces.
+ specs.put(Aware_Preferences.THRESHOLD_GYROSCOPE, new Spec("rad/s", 5, 3));
+ // Rotation vector components are unitless quaternion parts in -1…1.
+ specs.put(Aware_Preferences.THRESHOLD_ROTATION, new Spec("quaternion units", 1, 3));
+ // Earth's field is 23-65 µT; a strong local disturbance is tens, not hundreds.
+ specs.put(Aware_Preferences.THRESHOLD_MAGNETOMETER, new Spec("µT", 100, 3));
+ // 5 hPa is about 40 m of height, or a whole weather system passing.
+ specs.put(Aware_Preferences.THRESHOLD_BAROMETER, new Spec("hPa", 5, 1));
+ // Illuminance genuinely spans five orders of magnitude, so this limit is far looser than
+ // the others - a coarse light threshold is defensible where a coarse motion one is not.
+ specs.put(Aware_Preferences.THRESHOLD_LIGHT, new Spec("lux", 10000, 1));
+ // Ambient temperature spans roughly 70 °C across the range a phone sees.
+ specs.put(Aware_Preferences.THRESHOLD_TEMPERATURE, new Spec("°C", 10, 1));
+ // Most proximity hardware reports two states, near ≈0 cm and far ≈5 cm, and only on
+ // change, so there are no intermediate readings for a threshold to remove.
+ specs.put(Aware_Preferences.THRESHOLD_PROXIMITY, new Spec("cm", 5, 1));
+ SPECS = Collections.unmodifiableMap(specs);
+ }
+
+ private SensorThresholds() {
+ }
+
+ /** The spec for a THRESHOLD_* setting key, or null for any other setting. */
+ public static Spec of(String settingKey) {
+ return settingKey == null ? null : SPECS.get(settingKey);
+ }
+
+ public static boolean isThreshold(String settingKey) {
+ return of(settingKey) != null;
+ }
+
+ /**
+ * Whether a threshold still leaves the sensor recording. 0 disables filtering and is always
+ * valid; a negative value never is. An unknown setting is not judged.
+ */
+ public static boolean isWithinRange(String settingKey, double value) {
+ Spec spec = of(settingKey);
+ if (spec == null) return true;
+ return value >= 0 && value <= spec.limit;
+ }
+
+ /** What a threshold does at this value, for the dialog that accepts a typed-in one. */
+ public static String explain(String settingKey, double value) {
+ Spec spec = of(settingKey);
+ if (spec == null) return "";
+ if (value < 0) return "Enter 0 or more.";
+ if (value == 0) return "0 stores every sample, with no filtering.";
+ if (value > spec.limit) {
+ return format(value) + " " + spec.unit + " is above " + format(spec.limit) + " "
+ + spec.unit + ", more than this sensor's readings change in normal use. At this"
+ + " value almost every sample is filtered out and the sensor records nothing.";
+ }
+ return "Stores a reading once it differs from the last stored one by "
+ + format(value) + " " + spec.unit + "."
+ + (spec.axes == 3
+ ? " A sample is dropped only when all three axes changed by less than that."
+ : "");
+ }
+
+ private static String format(double value) {
+ if (value == Math.rint(value)) return String.valueOf((long) value);
+ return String.valueOf(value);
+ }
+}
diff --git a/aware-core/src/main/java/com/aware/utils/SensorTimeUnits.java b/aware-core/src/main/java/com/aware/utils/SensorTimeUnits.java
new file mode 100644
index 00000000..1d6b7069
--- /dev/null
+++ b/aware-core/src/main/java/com/aware/utils/SensorTimeUnits.java
@@ -0,0 +1,50 @@
+package com.aware.utils;
+
+/**
+ * Shared time-unit conversions for sensor FREQUENCY_* settings, kept in one place instead of
+ * duplicated per sensor class since the underlying math is identical across all of them.
+ */
+public final class SensorTimeUnits {
+
+ private SensorTimeUnits() {
+ }
+
+ /**
+ * FREQUENCY_ACCELEROMETER, FREQUENCY_GRAVITY, FREQUENCY_GYROSCOPE, FREQUENCY_LIGHT,
+ * FREQUENCY_LINEAR_ACCELEROMETER, FREQUENCY_MAGNETOMETER, FREQUENCY_BAROMETER,
+ * FREQUENCY_PROXIMITY, FREQUENCY_ROTATION and FREQUENCY_TEMPERATURE are already stored in
+ * microseconds -- SensorManager.registerListener()'s native sampling-period unit -- so this
+ * is an explicit no-op, not a missing conversion.
+ */
+ public static int samplingPeriodUs(int frequencyMicroseconds) {
+ return frequencyMicroseconds;
+ }
+
+ /**
+ * FREQUENCY_LOCATION_GPS, FREQUENCY_LOCATION_NETWORK, FREQUENCY_BLUETOOTH, FREQUENCY_WIFI,
+ * FREQUENCY_PROCESSOR and FREQUENCY_NETWORK_TRAFFIC are all stored in seconds, but the Android
+ * APIs that consume them (LocationManager.requestLocationUpdates(), AlarmManager.setRepeating(),
+ * Handler.postDelayed()) all want milliseconds.
+ */
+ public static long secondsToMillis(int frequencySeconds) {
+ return frequencySeconds * 1000L;
+ }
+
+ /**
+ * Some AlarmManager.setRepeating()-based sensors (currently Bluetooth) scan on an initial
+ * delay of the configured frequency, but repeat at twice that frequency to cut battery/CPU
+ * overhead on the recurring scan. Reuse this if a future sensor adopts the same pattern.
+ */
+ public static long doubleSecondsToMillis(int frequencySeconds) {
+ return secondsToMillis(frequencySeconds) * 2;
+ }
+
+ /**
+ * FREQUENCY_APPLICATIONS -- and any future setting feeding Scheduler.Schedule#setInterval()
+ * -- is already stored in minutes, which is Scheduler's native unit, so this is an explicit
+ * no-op, not a missing conversion.
+ */
+ public static long minutesAsIs(long frequencyMinutes) {
+ return frequencyMinutes;
+ }
+}
diff --git a/aware-core/src/main/java/com/aware/utils/StudyUtils.java b/aware-core/src/main/java/com/aware/utils/StudyUtils.java
index f6a91d24..466af1e2 100644
--- a/aware-core/src/main/java/com/aware/utils/StudyUtils.java
+++ b/aware-core/src/main/java/com/aware/utils/StudyUtils.java
@@ -11,17 +11,18 @@
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
+import android.content.SharedPreferences;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.database.Cursor;
import android.database.DatabaseUtils;
+import android.preference.PreferenceManager;
import android.net.Uri;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.widget.Toast;
-import com.aware.Applications;
import com.aware.Aware;
import com.aware.Aware_Preferences;
import com.aware.ESM;
@@ -33,14 +34,20 @@
import org.json.JSONException;
import org.json.JSONObject;
import org.skyscreamer.jsonassert.JSONAssert;
+import org.skyscreamer.jsonassert.JSONCompareMode;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Set;
import java.util.Hashtable;
import java.util.List;
import java.util.Map;
+import java.util.TreeMap;
+import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -54,8 +61,18 @@
* Note: joins a study without requiring a QRCode, just the study URL
*/
public class StudyUtils extends IntentService {
- private static final String[] REQUIRED_STUDY_CONFIG_KEYS = {"database", "questions",
+ // "database" is not here: a study whose data goes through the webservice ships no
+ // database block at all, deliberately, so that the phone never holds a credential
+ // for a database it does not contact. It is required on the direct path only --
+ // see firstMissingRequirement.
+ private static final String[] REQUIRED_STUDY_CONFIG_KEYS = {"questions",
"schedules", "sensors", "study_info"};
+ private static final long MAX_STUDY_CONFIG_BYTES = 5L * 1024L * 1024L;
+ private static final OkHttpClient STUDY_CONFIG_HTTP = new OkHttpClient.Builder()
+ .connectTimeout(15, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .writeTimeout(15, TimeUnit.SECONDS)
+ .build();
/**
* Received broadcast to join a study
@@ -104,7 +121,7 @@ protected void onHandleIntent(Intent intent) {
//Request study settings
Hashtable data = new Hashtable<>();
- data.put(Aware_Preferences.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ data.put(Aware_Preferences.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
data.put("platform", "android");
try {
PackageInfo package_info = getApplicationContext().getPackageManager().getPackageInfo(getApplicationContext().getPackageName(), 0);
@@ -142,11 +159,11 @@ protected void onHandleIntent(Intent intent) {
Cursor dbStudy = Aware.getStudy(getApplicationContext(), full_url);
if (Aware.DEBUG)
- Log.d(Aware.TAG, DatabaseUtils.dumpCursorToString(dbStudy));
+ Log.d(Aware.TAG, LogRedactor.redact(DatabaseUtils.dumpCursorToString(dbStudy)));
if (dbStudy == null || !dbStudy.moveToFirst()) {
ContentValues studyData = new ContentValues();
- studyData.put(Aware_Provider.Aware_Studies.STUDY_DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ studyData.put(Aware_Provider.Aware_Studies.STUDY_DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
studyData.put(Aware_Provider.Aware_Studies.STUDY_TIMESTAMP, System.currentTimeMillis());
studyData.put(Aware_Provider.Aware_Studies.STUDY_JOINED, System.currentTimeMillis());
studyData.put(Aware_Provider.Aware_Studies.STUDY_KEY, study_id);
@@ -160,18 +177,17 @@ protected void onHandleIntent(Intent intent) {
getContentResolver().insert(Aware_Provider.Aware_Studies.CONTENT_URI, studyData);
if (Aware.DEBUG) {
- Log.d(Aware.TAG, "New study data: " + studyData.toString());
+ Log.d(Aware.TAG, LogRedactor.redact("New study data: " + studyData.toString()));
}
} else {
//User rejoined a study he was already part of. Mark as abandoned.
ContentValues complianceEntry = new ContentValues();
- complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_TIMESTAMP, System.currentTimeMillis());
complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_KEY, dbStudy.getInt(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_KEY)));
complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_API, dbStudy.getString(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_API)));
complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_URL, dbStudy.getString(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_URL)));
complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_PI, dbStudy.getString(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_PI)));
- complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_CONFIG, dbStudy.getString(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_CONFIG)));
complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_JOINED, dbStudy.getLong(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_JOINED)));
complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_EXIT, System.currentTimeMillis());
complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_TITLE, dbStudy.getString(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_TITLE)));
@@ -182,7 +198,7 @@ protected void onHandleIntent(Intent intent) {
//Update the information to the latest
ContentValues studyData = new ContentValues();
- studyData.put(Aware_Provider.Aware_Studies.STUDY_DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ studyData.put(Aware_Provider.Aware_Studies.STUDY_DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
studyData.put(Aware_Provider.Aware_Studies.STUDY_TIMESTAMP, System.currentTimeMillis());
studyData.put(Aware_Provider.Aware_Studies.STUDY_JOINED, System.currentTimeMillis());
studyData.put(Aware_Provider.Aware_Studies.STUDY_KEY, study_id);
@@ -196,13 +212,18 @@ protected void onHandleIntent(Intent intent) {
getContentResolver().insert(Aware_Provider.Aware_Studies.CONTENT_URI, studyData);
if (Aware.DEBUG) {
- Log.d(Aware.TAG, "Rejoined study data: " + studyData.toString());
+ Log.d(Aware.TAG, LogRedactor.redact("Rejoined study data: " + studyData.toString()));
}
}
if (dbStudy != null && !dbStudy.isClosed()) dbStudy.close();
- applySettings(getApplicationContext(), full_url, study_config, input_password_);
+ // This is a programmatic join with no consent UI, so a sensor that needs a runtime
+ // permission or the Accessibility Service must not be silently switched on. Hold
+ // every such sensor off (persisted, so a later config sync keeps honouring it);
+ // permission-free base sensors still start, and the held ones await the participant.
+ Set declined = holdConsentSensorsUnlessAgreed(getApplicationContext(), study_config);
+ applySettings(getApplicationContext(), full_url, study_config, false, input_password_, declined);
} catch (JSONException e) {
e.printStackTrace();
@@ -210,6 +231,152 @@ protected void onHandleIntent(Intent intent) {
}
}
+ /**
+ * Timeout for the best-effort study-exit notification. Kept short so "leave study" stays
+ * responsive when the research database is unreachable (e.g. the study no longer exists).
+ */
+ private static final int STUDY_EXIT_UPLOAD_TIMEOUT_SECONDS = 8;
+
+ /**
+ * Best-effort, fast-failing upload of a single study-exit compliance row to the research
+ * database, so the researcher is notified when the database is reachable.
+ *
+ * Leaving a study must never depend on this succeeding: callers un-enroll locally regardless of
+ * the result. A {@code false} return means "could not notify" (e.g. the database is gone), not
+ * "leave failed".
+ *
+ * @param context application context
+ * @param exitEntry the study-exit row (the same values written to the local studies provider)
+ * @return true if the research database acknowledged the exit row, false otherwise
+ */
+ public static boolean uploadStudyExit(Context context, ContentValues exitEntry) {
+ if (exitEntry == null) return false;
+ try {
+ JSONObject row = new JSONObject();
+ for (Map.Entry value : exitEntry.valueSet()) {
+ row.put(value.getKey(), value.getValue());
+ }
+ // Whichever path the study uses. The server derives every enrolment window
+ // from `aware_studies`, so a study event that does not arrive leaves the
+ // window open and the participant looking like they never left.
+ JSONArray rows = new JSONArray().put(row);
+ return Webservice.enabled(context)
+ ? Webservice.insertDataFastFail(context, "aware_studies", rows,
+ STUDY_EXIT_UPLOAD_TIMEOUT_SECONDS)
+ : Jdbc.insertDataFastFail(context, "aware_studies", rows,
+ STUDY_EXIT_UPLOAD_TIMEOUT_SECONDS);
+ } catch (Exception e) {
+ Log.e(Aware.TAG, "Study-exit notification could not be built", e);
+ return false;
+ }
+ }
+
+ /**
+ * Timeout for a database credential probe. Short so a rotated-password check (or a re-auth
+ * attempt) never hangs the UI when the server is slow or unreachable.
+ */
+ private static final int STUDY_PROBE_TIMEOUT_SECONDS = 8;
+
+ /**
+ * Attempts to re-authenticate the active study with a participant-entered password.
+ *
+ * On {@link Jdbc.ConnectionResult#OK} the password is stored, the pending re-auth flag is
+ * cleared, and a config sync is triggered so collection resumes with no re-join. On any other
+ * result nothing is changed and the outcome is returned so the UI can tell the participant
+ * whether the password was wrong or the server was unreachable.
+ *
+ * @param context application context
+ * @param newPassword the password the participant entered
+ * @return the probe result
+ */
+ /**
+ * Builds a study compliance row (same columns the join/quit flows use) from the active-study
+ * cursor, tagged with the given compliance reason.
+ */
+ private static ContentValues complianceRow(Context context, Cursor study, String compliance) {
+ ContentValues cv = new ContentValues();
+ cv.put(Aware_Provider.Aware_Studies.STUDY_DEVICE_ID,
+ Aware.getDeviceID(context));
+ cv.put(Aware_Provider.Aware_Studies.STUDY_TIMESTAMP, System.currentTimeMillis());
+ cv.put(Aware_Provider.Aware_Studies.STUDY_KEY,
+ study.getInt(study.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_KEY)));
+ cv.put(Aware_Provider.Aware_Studies.STUDY_API,
+ study.getString(study.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_API)));
+ cv.put(Aware_Provider.Aware_Studies.STUDY_URL,
+ study.getString(study.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_URL)));
+ cv.put(Aware_Provider.Aware_Studies.STUDY_PI,
+ study.getString(study.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_PI)));
+ cv.put(Aware_Provider.Aware_Studies.STUDY_JOINED,
+ study.getLong(study.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_JOINED)));
+ cv.put(Aware_Provider.Aware_Studies.STUDY_EXIT,
+ study.getLong(study.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_EXIT)));
+ cv.put(Aware_Provider.Aware_Studies.STUDY_TITLE,
+ study.getString(study.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_TITLE)));
+ cv.put(Aware_Provider.Aware_Studies.STUDY_DESCRIPTION,
+ study.getString(study.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_DESCRIPTION)));
+ cv.put(Aware_Provider.Aware_Studies.STUDY_COMPLIANCE, compliance);
+ return cv;
+ }
+
+ public static Jdbc.ConnectionResult reauthenticateStudy(Context context, String newPassword) {
+ JSONObject dbInfo = null;
+ ContentValues resumedRow = null;
+ Cursor study = Aware.getActiveStudy(context);
+ if (study != null && study.moveToFirst()) {
+ try {
+ JSONObject config = new JSONObject(study.getString(
+ study.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_CONFIG)));
+ dbInfo = config.optJSONObject("database");
+ } catch (JSONException e) {
+ Log.e(Aware.TAG, "Re-auth: stored study config is unreadable");
+ }
+ resumedRow = complianceRow(context, study,
+ "collection resumed after password re-authentication");
+ }
+ if (study != null && !study.isClosed()) study.close();
+
+ if (dbInfo == null) return Jdbc.ConnectionResult.UNREACHABLE;
+
+ Jdbc.ConnectionResult result = Jdbc.probeConnection(context,
+ dbInfo.optString("database_host", ""),
+ dbInfo.optString("database_port", ""),
+ dbInfo.optString("database_name", ""),
+ dbInfo.optString("database_username", ""),
+ newPassword,
+ STUDY_PROBE_TIMEOUT_SECONDS);
+
+ if (result == Jdbc.ConnectionResult.OK) {
+ Aware.setSetting(context, Aware_Preferences.DB_PASSWORD, newPassword);
+ Aware.setSetting(context, Aware_Preferences.PENDING_STUDY_REAUTH, "");
+ // Re-authenticated inside the app, so the notification was never tapped and
+ // setAutoCancel did not fire. Collection has resumed; clear it.
+ cancelStudyNotification(context, Aware.AWARE_STUDY_REAUTH_NOTIFICATION_ID);
+ // Audit the recovery for the researcher: the data gap was the live signal; this row is
+ // the record, uploaded now that the credentials are valid again.
+ if (resumedRow != null) {
+ context.getContentResolver().insert(
+ Aware_Provider.Aware_Studies.CONTENT_URI, resumedRow);
+ try {
+ JSONObject json = new JSONObject();
+ for (Map.Entry e : resumedRow.valueSet()) {
+ json.put(e.getKey(), e.getValue());
+ }
+ JSONArray audit = new JSONArray().put(json);
+ if (Webservice.enabled(context)) {
+ Webservice.insertData(context, "aware_studies", audit);
+ } else {
+ Jdbc.insertData(context, "aware_studies", audit);
+ }
+ } catch (Exception e) {
+ Log.e(Aware.TAG, "Failed to upload re-auth audit row", e);
+ }
+ }
+ // Resume collection/upload with no re-join.
+ syncStudyConfig(context, false);
+ }
+ return result;
+ }
+
/**
* Sets first all the settings to the client.
* If there are plugins, apply the same settings to them.
@@ -247,13 +414,54 @@ public static void applySettings(Context context, String webserviceServer, JSONA
* @param input_password password for database if required
*/
public static void applySettings(Context context, String webserviceServer, JSONArray configs, Boolean insertCompliance, String input_password) {
+ applySettings(context, webserviceServer, configs, insertCompliance, input_password, Collections.emptySet());
+ }
+
+ /**
+ * Sets first all the settings to the client.
+ * If there are plugins, apply the same settings to them.
+ * This allows us to add plugins to studies from the dashboard.
+ *
+ * @param context
+ * @param webserviceServer
+ * @param configs
+ * @param insertCompliance true to insert a new compliance record (i.e. when updating a study)
+ * @param input_password password for database if required
+ * @param declinedSettings status_* setting keys the participant declined consent for — these are
+ * forced to false regardless of what {@code configs} says, so a declined
+ * sensor is never flipped on even momentarily.
+ */
+ public static void applySettings(Context context, String webserviceServer, JSONArray configs, Boolean insertCompliance, String input_password, Set declinedSettings) {
boolean is_developer = Aware.getSetting(context, Aware_Preferences.DEBUG_FLAG).equals("true");
+ // Preserve across the reset below, same as DEBUG_FLAG: a config re-apply (join or background
+ // sync) is not a consent event, so it must not erase the participant's recorded agreement.
+ // Quitting a study calls Aware.reset() directly, NOT through here — that path intentionally
+ // wipes the record so the next join asks for consent again.
+ String consentRecord = Aware.getSetting(context, Aware_Preferences.STUDY_CONSENT_RECORD);
+ // Preserve across the reset below for the same reason, and additionally fold it into the
+ // declined set enforced below: a background sync passes no declined set, but the
+ // participant's persisted declines must still be honoured (not re-enabled) on every apply.
+ String persistedDeclined = Aware.getSetting(context, Aware_Preferences.STUDY_DECLINED_SENSORS);
//First reset the client to default settings...
Aware.reset(context);
input_password_ = input_password;
if (is_developer) Aware.setSetting(context, Aware_Preferences.DEBUG_FLAG, true);
+ if (consentRecord.length() > 0) Aware.setSetting(context, Aware_Preferences.STUDY_CONSENT_RECORD, consentRecord);
+ if (persistedDeclined.length() > 0) Aware.setSetting(context, Aware_Preferences.STUDY_DECLINED_SENSORS, persistedDeclined);
+
+ // Effective declined set = whatever the caller passed ∪ whatever the participant previously
+ // persisted, so both a fresh decline (consent screen) and a standing one (background sync)
+ // keep the sensor off.
+ Set effectiveDeclined = new HashSet<>();
+ if (declinedSettings != null) effectiveDeclined.addAll(declinedSettings);
+ for (String key : persistedDeclined.split(",")) {
+ if (key.trim().length() > 0) effectiveDeclined.add(key.trim());
+ }
+ effectiveDeclined = expandGroupedConsentDeclines(effectiveDeclined);
+ Aware.setSetting(context, Aware_Preferences.STUDY_DECLINED_SENSORS,
+ joinSettings(effectiveDeclined));
//Now apply the new settings
try {
@@ -274,6 +482,11 @@ public static void applySettings(Context context, String webserviceServer, JSONA
Aware.setSetting(context, Aware_Preferences.DB_PORT, dbConfig.optInt("database_port", 3306));
Aware.setSetting(context, Aware_Preferences.DB_NAME, dbConfig.optString("database_name", ""));
Aware.setSetting(context, Aware_Preferences.DB_USERNAME, dbConfig.optString("database_username", ""));
+ // The authority that signed the research database's certificate, when the study
+ // publishes one. It belongs to the study rather than to the app, so it arrives
+ // with the rest of the study's database settings.
+ Aware.setSetting(context, Aware_Preferences.DB_CA,
+ dbConfig.optString(Aware_Preferences.DB_CA, ""));
boolean configWithoutPassword = dbConfig.optBoolean("config_without_password", false);
if (!configWithoutPassword) {
@@ -292,7 +505,7 @@ public static void applySettings(Context context, String webserviceServer, JSONA
if (insertCompliance) {
try {
ContentValues studyData = new ContentValues();
- studyData.put(Aware_Provider.Aware_Studies.STUDY_DEVICE_ID, Aware.getSetting(context, Aware_Preferences.DEVICE_ID));
+ studyData.put(Aware_Provider.Aware_Studies.STUDY_DEVICE_ID, Aware.getDeviceID(context));
studyData.put(Aware_Provider.Aware_Studies.STUDY_TIMESTAMP, System.currentTimeMillis());
studyData.put(Aware_Provider.Aware_Studies.STUDY_API, "");
studyData.put(Aware_Provider.Aware_Studies.STUDY_URL, webserviceServer);
@@ -307,6 +520,7 @@ public static void applySettings(Context context, String webserviceServer, JSONA
studyData.put(Aware_Provider.Aware_Studies.STUDY_DESCRIPTION,
studyInfo.optString("study_description", ""));
studyData.put(Aware_Provider.Aware_Studies.STUDY_COMPLIANCE, "updated study");
+ studyData.put(Aware_Provider.Aware_Studies.STUDY_UPDATED, System.currentTimeMillis());
studyData.put(Aware_Provider.Aware_Studies.STUDY_JOINED, System.currentTimeMillis());
studyData.put(Aware_Provider.Aware_Studies.STUDY_EXIT, 0);
context.getContentResolver().insert(Aware_Provider.Aware_Studies.CONTENT_URI, studyData);
@@ -360,7 +574,7 @@ public static void applySettings(Context context, String webserviceServer, JSONA
}
// Set the sensors' settings first
- processSensorSettings(context, sensors);
+ processSensorSettings(context, sensors, effectiveDeclined);
// Set the plugins' settings and prepare for activation
ArrayList active_plugins = processPluginSettings(context, plugins);
@@ -398,6 +612,16 @@ public static void applySettings(Context context, String webserviceServer, JSONA
}
}
+ // Log the health of every study-enabled sensor (including unavailable/blocked/disabled,
+ // frequency-based delayed data, and event-driven waiting) into aware_log, which already
+ // syncs to the researcher's database — covers both the join flow and every config update,
+ // since this is the one place both paths funnel through.
+ try {
+ SensorDiagnostics.logSensorStatus(context, sensors);
+ } catch (Exception e) {
+ Log.e(Aware.TAG, "Error logging sensor diagnostics: " + e.getMessage());
+ }
+
// Start Aware service and sync data
Intent aware = new Intent(context, Aware.class);
context.startService(aware);
@@ -465,8 +689,11 @@ private static ArrayList processPluginSettings(Context context, JSONArra
*
* @param context Application context
* @param sensors JSONArray of sensor configurations
+ * @param declinedSettings status_* keys to force to false regardless of {@code sensors}' own value
+ * — the participant declined consent for these, so they must never be
+ * written true in the first place.
*/
- private static void processSensorSettings(Context context, JSONArray sensors) {
+ private static void processSensorSettings(Context context, JSONArray sensors, Set declinedSettings) {
if (sensors == null) {
Log.d(Aware.TAG, "processSensorSettings: sensors array is null");
return;
@@ -477,6 +704,14 @@ private static void processSensorSettings(Context context, JSONArray sensors) {
// Track all settings to verify they're applied correctly
HashMap appliedSettings = new HashMap<>();
+ // Mirror each setting into the UI SharedPreferences too. AWARE keeps sensor state in two
+ // stores: the aware_settings provider (read by startAWARE to actually run sensors) and the
+ // preference-screen SharedPreferences (what the checkboxes show and persist). Writing only
+ // the provider left the checkboxes OFF and let onSharedPreferenceChanged overwrite the
+ // provider back to the (false) UI default — so config-enabled sensors never activated until
+ // the participant tapped them. Keeping the two stores in sync fixes that.
+ SharedPreferences.Editor uiPrefs = PreferenceManager.getDefaultSharedPreferences(context).edit();
+
for (int i = 0; i < sensors.length(); i++) {
try {
JSONObject sensor_config = sensors.getJSONObject(i);
@@ -485,8 +720,41 @@ private static void processSensorSettings(Context context, JSONArray sensors) {
if (sensor_config.has("setting") && sensor_config.has("value")) {
String setting = sensor_config.getString("setting");
Object value = sensor_config.get("value");
+
+ // Declined settings are forced false here rather than written true and unflipped
+ // later, so a declined sensor never runs even momentarily.
+ if (declinedSettings.contains(setting)) {
+ Log.d(Aware.TAG, "processSensorSettings: " + setting +
+ " declined by participant, forcing value to false");
+ value = Boolean.FALSE;
+ }
+
+ // A server may enable every sensor generically, including hardware/platform
+ // features this phone cannot provide. Persist those status flags as false up
+ // front instead of briefly starting a doomed service. In particular Processor
+ // is blocked on Android N+, and its former start-disable-toast cycle caused
+ // config drift reconciliation to reapply the whole study repeatedly.
+ if (Boolean.TRUE.equals(value)
+ && setting.startsWith("status_")
+ && !SensorAvailability.isHardwareAvailable(context, setting)) {
+ Log.d(Aware.TAG, "processSensorSettings: " + setting
+ + " unavailable on this device, forcing value to false");
+ value = Boolean.FALSE;
+ }
+
String valueType = value.getClass().getSimpleName();
+ // WEBSERVICE_SERVER doubles as the join URL that Aware.getStudy()/isStudy()
+ // match against aware_studies.study_url. Some study configs carry a
+ // "webservice_server" sensor entry for the classic PHP-webservice upload
+ // path (a different URL, e.g. per-platform); applying it here silently
+ // overwrote the join URL and broke every getStudy() lookup from then on.
+ if (setting.equals(Aware_Preferences.WEBSERVICE_SERVER)) {
+ Log.d(Aware.TAG, "processSensorSettings: Skipping " + setting +
+ " from sensors config (owned by the join/sync URL, not overridable here)");
+ continue;
+ }
+
Log.d(Aware.TAG, "processSensorSettings: Processing setting: " + setting +
" with value: " + value + " (type: " + valueType + ")");
@@ -506,6 +774,14 @@ private static void processSensorSettings(Context context, JSONArray sensors) {
// For any other type, convert to string
Aware.setSetting(context, setting, value.toString());
}
+ // Mirror to the UI store with the type the preference persists as:
+ // CheckBoxPreference persists a Boolean; EditText/List persist a String.
+ if (value instanceof Boolean) {
+ uiPrefs.putBoolean(setting, (Boolean) value);
+ } else {
+ uiPrefs.putString(setting, String.valueOf(value));
+ }
+
appliedSettings.put(setting, value.toString());
Log.d(Aware.TAG, "processSensorSettings: Successfully applied setting: " + setting);
} catch (Exception e) {
@@ -520,6 +796,9 @@ private static void processSensorSettings(Context context, JSONArray sensors) {
}
}
+ // Commit the mirrored UI settings so the preference screen reflects the study config.
+ uiPrefs.apply();
+
// Verify all settings were successfully applied
Log.d(Aware.TAG, "processSensorSettings: Verifying " + appliedSettings.size() + " applied settings");
for (Map.Entry entry : appliedSettings.entrySet()) {
@@ -868,19 +1147,80 @@ private static void createEsmSchedule(Context context, JSONObject scheduleJson,
* @param toast Whether to show toast messages
*/
public static void syncStudyConfig(Context context, Boolean toast) {
+ syncStudyConfig(context, toast, false, false);
+ }
+
+ /**
+ * Synchronizes the study configuration with the server.
+ *
+ * @param manual true only when the participant explicitly tapped "Check for study updates"
+ */
+ public static void syncStudyConfig(Context context, Boolean toast, boolean manual) {
+ syncStudyConfig(context, toast, manual, false);
+ }
+
+ /**
+ * Synchronizes or previews the study configuration.
+ *
+ * @param approved true after the participant accepted the exact pending server configuration
+ */
+ public static void syncStudyConfig(
+ Context context, Boolean toast, boolean manual, boolean approved) {
if (!Aware.isStudy(context)) return;
+ // Awaiting password re-authentication: don't re-probe/re-apply on every sync — it would keep
+ // hammering the database with the rejected password. The pending prompt drives recovery, and
+ // reauthenticateStudy() clears this flag (then re-runs this sync) once the password is fixed.
+ if (Aware.getSetting(context, Aware_Preferences.PENDING_STUDY_REAUTH).trim().length() > 0) return;
+ boolean editable = Boolean.parseBoolean(Aware.getSetting(
+ context, Aware_Preferences.ENABLE_CONFIG_UPDATE));
+ if (shouldSkipAutomaticConfigSync(editable, manual)) {
+ if (Aware.DEBUG) {
+ Aware.debug(context, Aware.LogType.STUDY,
+ "Skipping automatic study-config update while participant editing is enabled");
+ }
+ return;
+ }
- String studyUrl = Aware.getSetting(context, Aware_Preferences.WEBSERVICE_SERVER);
- Cursor study = Aware.getStudy(context,
- Aware.getSetting(context, Aware_Preferences.WEBSERVICE_SERVER));
+ // Aware.getActiveStudy() instead of Aware.getStudy(context, webservice_server): the latter
+ // does "WHERE study_url LIKE '%'", which silently finds nothing (and
+ // makes this whole method a no-op) the moment webservice_server and the row's own
+ // study_url text ever diverge — e.g. joining via a short link that differs from the
+ // resolved config URL the row actually stored. getActiveStudy() matches on "currently
+ // joined, not exited" instead, with no URL comparison at all.
+ Cursor study = Aware.getActiveStudy(context);
if (study != null && study.moveToFirst()) {
try {
+ String studyUrl = study.getString(study.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_URL));
JSONObject localConfig = new JSONObject(study.getString(
study.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_CONFIG)));
JSONObject newConfig = getStudyConfig(studyUrl);
- if (!validateStudyConfig(context, newConfig, Aware.getSetting(context, Aware_Preferences.DB_PASSWORD))) {
- String msg = "Failed to sync study, something is wrong with the config.";
+ StudyConfigValidation validation = validateStudyConfigDetailed(
+ context, newConfig, Aware.getSetting(context, Aware_Preferences.DB_PASSWORD));
+ if (!configIsApplicable(validation)) {
+ // A password-join study whose stored password is rejected or missing means the
+ // researcher rotated the password: flag the participant for re-authentication
+ // instead of reporting a dead-end config error, and skip applying the config
+ // until they re-authenticate.
+ if (needsParticipantReauth(validation) && requiresParticipantPassword(newConfig)) {
+ Aware.setSetting(context, Aware_Preferences.PENDING_STUDY_REAUTH, studyUrl);
+ Log.w(Aware.TAG, "Study database password rejected; participant re-authentication required.");
+ // Nudge an open UI to prompt immediately instead of only on next app open.
+ context.sendBroadcast(new Intent(Aware.ACTION_AWARE_STUDY_REAUTH_REQUIRED));
+ // The broadcast only reaches a running Aware_Client, and syncs run on their
+ // own schedule with the app closed. The notification is what tells the
+ // participant collection is paused while they are not in the app.
+ postStudyNotification(context, Aware.AWARE_STUDY_REAUTH_NOTIFICATION_ID,
+ R.string.aware_notif_study_reauth_title,
+ R.string.aware_notif_study_reauth);
+ Aware.debug(context, Aware.LogType.STUDY,
+ "Notified the participant that the study password is required");
+ return;
+ }
+
+ String msg = validation == StudyConfigValidation.AUTH_FAILED
+ ? "Failed to sync study, the database rejected the study's credentials."
+ : "Failed to sync study, something is wrong with the config.";
Log.e(Aware.TAG, msg);
if (toast) {
new Handler(Looper.getMainLooper()).post(new Runnable() {
@@ -892,21 +1232,157 @@ public void run() {
}
return;
}
- if (jsonEquals(localConfig, newConfig, false)) {
- String msg = "There are no study updates.";
- if (Aware.DEBUG) Aware.debug(context, msg);
+
+ if (validation == StudyConfigValidation.UNREACHABLE) {
+ // Config retrieval and upload connectivity are separate operations, so an
+ // out-of-reach database does not stop the config applying; the sync adapter
+ // retries the upload on its own schedule. Recorded as upload health, not as a
+ // config failure, and deliberately not toasted — the sync runs every minute.
+ Aware.debug(context, Aware.LogType.STUDY,
+ "Study config applied while the upload database is unreachable");
+ }
+
+ boolean configsEqual = jsonEquals(localConfig, newConfig);
+ // A server change the participant can never act on — one confined to sensors whose
+ // hardware this device lacks — must not keep re-triggering the manual "study update
+ // available" preview. The sensor's checkbox is disabled and it can never collect, so
+ // "Keep my settings" can never reconcile the difference and the dialog would reappear
+ // on every check. Treat a config that differs ONLY in such sensors as "no actionable
+ // update", exactly like an identical config (the same reasoning liveDriftSignature()
+ // already applies to drift). Editable mode only: locked mode has no preview and just
+ // adopts the server config, so it never loops.
+ boolean noActionableDiff = configsEqual
+ || (editable && configsDifferOnlyByUnavailableSensors(
+ context, localConfig, newConfig));
+ boolean approvalMatches = approved
+ && pendingApprovalMatches(context, newConfig);
+ if (shouldPreviewManualConfigUpdate(
+ editable, manual, noActionableDiff, approvalMatches)) {
+ publishConfigUpdatePreview(context, localConfig, newConfig);
if (toast) {
new Handler(Looper.getMainLooper()).post(new Runnable() {
@Override
public void run() {
- Toast.makeText(context, msg, Toast.LENGTH_SHORT).show();
+ Toast.makeText(
+ context,
+ "Study update available for review.",
+ Toast.LENGTH_SHORT).show();
}
});
}
return;
}
+
+ if (noActionableDiff) {
+ Aware.setSetting(
+ context, Aware_Preferences.PENDING_STUDY_CONFIG_APPROVAL, "");
+ // The server config hasn't changed, but that alone doesn't guarantee the
+ // device's live settings still match it — Aware.reset() and an interrupted
+ // apply can both leave aware_settings drifted while the stored config blob
+ // still reads the same as the server. Comparing blobs alone let that drift go
+ // undetected forever: the participant would look "configured" while a sensor
+ // was silently off. Check live settings too and self-heal if they've drifted.
+ String drift = liveDriftSignature(context, newConfig);
+ if (drift.isEmpty()) {
+ String msg = "There are no study updates.";
+ if (Aware.DEBUG) Aware.debug(context, Aware.LogType.STUDY, msg);
+ if (toast) {
+ new Handler(Looper.getMainLooper()).post(new Runnable() {
+ @Override
+ public void run() {
+ Toast.makeText(context, msg, Toast.LENGTH_SHORT).show();
+ }
+ });
+ }
+ Aware.setSetting(context, Aware_Preferences.LAST_DRIFT_SIGNATURE, "");
+ return;
+ }
+
+ String lastDrift = Aware.getSetting(context, Aware_Preferences.LAST_DRIFT_SIGNATURE);
+ long lastReconcileTs = Aware.getSettingAsLong(context, Aware_Preferences.LAST_DRIFT_RECONCILE_TS, 0);
+ boolean sameDriftTriedRecently = drift.equals(lastDrift)
+ && (System.currentTimeMillis() - lastReconcileTs) < DRIFT_RECONCILE_BACKOFF_MS;
+ if (sameDriftTriedRecently) {
+ // Same mismatch we already tried to fix recently — most likely a sensor
+ // the device can't actually satisfy (e.g. no hardware), where re-applying
+ // would restart every sensor service again on every ~1 min sync poll for
+ // no benefit. Back off and retry later in case the cause was transient.
+ if (Aware.DEBUG)
+ Aware.debug(context, Aware.LogType.STUDY, "Live settings drifted from study config but a fix was already attempted recently, skipping: " + drift);
+ return;
+ }
+
+ if (Aware.DEBUG)
+ Aware.debug(context, Aware.LogType.STUDY, "Live settings drifted from study config, self-healing: " + drift);
+ // insertCompliance=false: this is a silent local self-heal, not a real config
+ // change, so it shouldn't log an "updated study" compliance row or notify the
+ // participant the way an actual server-side edit does below.
+ applySettings(context, studyUrl, new JSONArray().put(newConfig), false, Aware.getSetting(context, Aware_Preferences.DB_PASSWORD));
+ // applySettings() resets the settings provider before rebuilding it, so persist
+ // the backoff marker only after the apply. Writing it before apply meant reset()
+ // immediately erased it; the next sync retried seconds later, recreated ESM
+ // schedules, restarted every sensor, and could continue until the phone failed.
+ Aware.setSetting(context, Aware_Preferences.LAST_DRIFT_SIGNATURE, drift);
+ Aware.setSetting(context, Aware_Preferences.LAST_DRIFT_RECONCILE_TS, System.currentTimeMillis());
+ return;
+ }
+
+ // Real config change from the server — clear any stale drift bookkeeping, since
+ // the full re-apply below re-syncs every setting from scratch anyway.
+ Aware.setSetting(context, Aware_Preferences.LAST_DRIFT_SIGNATURE, "");
+ Aware.setSetting(
+ context, Aware_Preferences.PENDING_STUDY_CONFIG_APPROVAL, "");
+
+ // Consent gate for mid-study changes (F2): a sensor the researcher newly enabled that
+ // needs a participant grant must NOT start collecting just because the config says so —
+ // the participant hasn't agreed to it. Hold every such sensor off by adding it to the
+ // persisted declined set before applying; the "study updated" prompt then lets the
+ // participant agree (which un-declines + enables it). Base sensors that need no grant
+ // are not held. Sensors dropped from the config are cleared from the declined set so
+ // stale entries don't accumulate across edits.
+ holdNewlyAddedConsentSensors(context, localConfig, newConfig);
+
applySettings(context, studyUrl, new JSONArray().put(newConfig), true, Aware.getSetting(context, Aware_Preferences.DB_PASSWORD));
- if (Aware.DEBUG) Aware.debug(context, "Updated study config: " + newConfig);
+ if (Aware.DEBUG) Aware.debug(context, Aware.LogType.STUDY, "Updated study config: " + newConfig);
+
+ // Tell any open UI to rebuild (e.g. show newly enabled sensors) without a re-join,
+ // and report which sensors were added / removed so it can notify the participant.
+ ArrayList added = new ArrayList<>();
+ ArrayList removed = new ArrayList<>();
+ diffActiveSensors(localConfig, newConfig, added, removed,
+ unavailableStatusSettings(context, localConfig, newConfig));
+ Boolean configUpdateAllowedNewValue = enableConfigUpdateChanged(localConfig, newConfig);
+
+ // Persist the curated, participant-meaningful part of this diff so it can still be
+ // shown next time the app is opened even if no UI was around to receive the live
+ // broadcast below (syncs run on their own schedule regardless of whether the app is
+ // open). Cleared once shown by whichever path (live or catch-up) shows it first.
+ boolean hasCuratedChanges = !added.isEmpty() || !removed.isEmpty() || configUpdateAllowedNewValue != null;
+ if (hasCuratedChanges) {
+ try {
+ JSONObject notice = new JSONObject();
+ notice.put("added", new JSONArray(added));
+ notice.put("removed", new JSONArray(removed));
+ if (configUpdateAllowedNewValue != null) {
+ notice.put("cfgChanged", true);
+ notice.put("cfgNewValue", configUpdateAllowedNewValue);
+ }
+ notice.put("manual", manual);
+ Aware.setSetting(context, Aware_Preferences.PENDING_STUDY_UPDATE_NOTICE, notice.toString());
+ } catch (JSONException e) {
+ e.printStackTrace();
+ }
+ }
+
+ Intent configUpdated = new Intent(Aware.ACTION_AWARE_STUDY_CONFIG_UPDATED);
+ configUpdated.putStringArrayListExtra(Aware.EXTRA_SENSORS_ADDED, added);
+ configUpdated.putStringArrayListExtra(Aware.EXTRA_SENSORS_REMOVED, removed);
+ if (configUpdateAllowedNewValue != null) {
+ configUpdated.putExtra(Aware.EXTRA_CONFIG_UPDATE_ALLOWED_CHANGED, true);
+ configUpdated.putExtra(Aware.EXTRA_CONFIG_UPDATE_ALLOWED_NEW_VALUE, configUpdateAllowedNewValue);
+ }
+ configUpdated.putExtra(Aware.EXTRA_CONFIG_UPDATE_MANUAL, manual);
+ context.sendBroadcast(configUpdated);
if (toast) {
new Handler(Looper.getMainLooper()).post(new Runnable() {
@Override
@@ -918,25 +1394,232 @@ public void run() {
// TODO RIO: Update last sync date
- // Notify the user that study config has been updated
- Intent intent = new Intent()
- .setComponent(new ComponentName("com.aware.phone", "com.aware.phone.ui.Aware_Light_Client"))
- .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
- PendingIntent clickIntent = PendingIntent.getActivity(context, 0, intent, 0);
+ // Notify only for a change the participant can act on. Frequency- and
+ // threshold-only updates produce no entry in the curated diff, so a notification
+ // about them would open an empty summary. Newly added consent sensors get their own
+ // wording: they stay uncollected until the participant agrees.
+ if (hasCuratedChanges) {
+ postStudyNotification(context, Aware.AWARE_STUDY_UPDATE_NOTIFICATION_ID,
+ R.string.aware_notif_study_update_title,
+ added.isEmpty()
+ ? R.string.aware_notif_study_update_sensors
+ : R.string.aware_notif_study_update_consent);
+ }
+ } catch (JSONException e) {
+ e.printStackTrace();
+ } finally {
+ study.close();
+ }
+ }
+ }
- NotificationCompat.Builder builder = new NotificationCompat.Builder(context, Aware.AWARE_NOTIFICATION_CHANNEL_GENERAL)
+ /**
+ * Posts a study notification that opens the app when tapped. Shared by every participant-facing
+ * study alert so channel, importance and tap target stay consistent; each caller passes its own
+ * notification id. {@code setAutoCancel} clears it on tap — an alert resolved inside the app is
+ * cleared by {@link #cancelStudyNotification} instead.
+ */
+ static void postStudyNotification(Context context, int notificationId,
+ int titleRes, int textRes) {
+ Intent open = new Intent()
+ .setComponent(new ComponentName("com.aware.phone", "com.aware.phone.ui.Aware_Client"))
+ .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
+ // A distinct request code per notification: PendingIntents matching on everything but extras
+ // are deduplicated, so a shared code would give both alerts one tap target.
+ PendingIntent clickIntent = PendingIntent.getActivity(context, notificationId, open,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
+
+ NotificationCompat.Builder builder =
+ new NotificationCompat.Builder(context, Aware.AWARE_NOTIFICATION_CHANNEL_GENERAL)
.setChannelId(Aware.AWARE_NOTIFICATION_CHANNEL_GENERAL)
.setContentIntent(clickIntent)
.setSmallIcon(R.drawable.ic_stat_aware_accessibility)
.setAutoCancel(true)
- .setContentTitle(context.getResources().getString(R.string.aware_notif_study_sync_title))
- .setContentText(context.getResources().getString(R.string.aware_notif_study_sync));
- builder = Aware.setNotificationProperties(builder, Aware.AWARE_NOTIFICATION_IMPORTANCE_GENERAL);
+ .setContentTitle(context.getResources().getString(titleRes))
+ .setContentText(context.getResources().getString(textRes))
+ .setStyle(new NotificationCompat.BigTextStyle()
+ .bigText(context.getResources().getString(textRes)));
+ builder = Aware.setNotificationProperties(
+ builder, Aware.AWARE_NOTIFICATION_IMPORTANCE_GENERAL);
+
+ NotificationManager notManager =
+ (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+ if (notManager != null) notManager.notify(notificationId, builder.build());
+ }
- NotificationManager notManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
- notManager.notify(Applications.ACCESSIBILITY_NOTIFICATION_ID, builder.build());
- } catch (JSONException e) {
- e.printStackTrace();
+ /** Removes a study notification whose condition has been resolved inside the app. */
+ static void cancelStudyNotification(Context context, int notificationId) {
+ NotificationManager notManager =
+ (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+ if (notManager != null) notManager.cancel(notificationId);
+ }
+
+ static boolean shouldSkipAutomaticConfigSync(boolean editable, boolean manual) {
+ return editable && !manual;
+ }
+
+ static boolean shouldPreviewManualConfigUpdate(
+ boolean editable, boolean manual, boolean configsEqual, boolean approvalMatches) {
+ return editable && manual && !configsEqual && !approvalMatches;
+ }
+
+ private static boolean pendingApprovalMatches(Context context, JSONObject serverConfig) {
+ String pending = Aware.getSetting(
+ context, Aware_Preferences.PENDING_STUDY_CONFIG_APPROVAL);
+ if (pending == null || pending.trim().length() == 0) return false;
+ try {
+ return jsonEquals(new JSONObject(pending), serverConfig);
+ } catch (JSONException e) {
+ return false;
+ }
+ }
+
+ private static void publishConfigUpdatePreview(
+ Context context, JSONObject localConfig, JSONObject serverConfig) {
+ Aware.setSetting(
+ context,
+ Aware_Preferences.PENDING_STUDY_CONFIG_APPROVAL,
+ serverConfig.toString());
+
+ ArrayList added = new ArrayList<>();
+ ArrayList removed = new ArrayList<>();
+ diffActiveSensors(localConfig, serverConfig, added, removed,
+ unavailableStatusSettings(context, localConfig, serverConfig));
+ Boolean configUpdateAllowedNewValue =
+ enableConfigUpdateChanged(localConfig, serverConfig);
+
+ Intent available = new Intent(Aware.ACTION_AWARE_STUDY_CONFIG_UPDATE_AVAILABLE);
+ available.putStringArrayListExtra(Aware.EXTRA_SENSORS_ADDED, added);
+ available.putStringArrayListExtra(Aware.EXTRA_SENSORS_REMOVED, removed);
+ if (configUpdateAllowedNewValue != null) {
+ available.putExtra(Aware.EXTRA_CONFIG_UPDATE_ALLOWED_CHANGED, true);
+ available.putExtra(
+ Aware.EXTRA_CONFIG_UPDATE_ALLOWED_NEW_VALUE,
+ configUpdateAllowedNewValue);
+ }
+ context.sendBroadcast(available);
+ }
+
+ /**
+ * Persists a participant's editable-mode sensor change into the active study config and emits
+ * a compliance row carrying that effective config. This makes the configuration received by
+ * the researcher match the settings that actually produced the uploaded sensor data.
+ */
+ public static boolean persistEditableSensorSetting(
+ Context context, String setting, String value) {
+ if (setting == null
+ || setting.length() == 0
+ || Aware_Preferences.ENABLE_CONFIG_UPDATE.equals(setting)
+ || !Aware.isStudy(context)
+ || !Boolean.parseBoolean(Aware.getSetting(
+ context, Aware_Preferences.ENABLE_CONFIG_UPDATE))) {
+ return false;
+ }
+
+ Cursor study = Aware.getActiveStudy(context);
+ if (study == null || !study.moveToFirst()) {
+ if (study != null) study.close();
+ return false;
+ }
+
+ boolean updated = false;
+ try {
+ int studyId = study.getInt(
+ study.getColumnIndex(Aware_Provider.Aware_Studies._ID));
+ String stored = study.getString(
+ study.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_CONFIG));
+ JSONObject current = parseStoredStudyConfig(stored);
+ JSONObject effective = withSensorSetting(current, setting, value);
+ if (jsonEquals(current, effective)) return false;
+
+ ContentValues values = new ContentValues();
+ values.put(Aware_Provider.Aware_Studies.STUDY_CONFIG, effective.toString());
+ values.put(Aware_Provider.Aware_Studies.STUDY_UPDATED, System.currentTimeMillis());
+ updated = context.getContentResolver().update(
+ Aware_Provider.Aware_Studies.CONTENT_URI,
+ values,
+ Aware_Provider.Aware_Studies._ID + "=?",
+ new String[]{String.valueOf(studyId)}) > 0;
+ } catch (JSONException e) {
+ Log.e(Aware.TAG, "Failed to persist editable sensor setting: " + e.getMessage());
+ } finally {
+ study.close();
+ }
+
+ if (updated) {
+ Aware.logStudyCompliance(
+ context, "participant setting changed: " + setting + "=" + value);
+ }
+ return updated;
+ }
+
+ private static JSONObject parseStoredStudyConfig(String stored) throws JSONException {
+ if (stored == null || stored.trim().length() == 0) return new JSONObject();
+ String trimmed = stored.trim();
+ if (!trimmed.startsWith("[")) return new JSONObject(trimmed);
+ JSONArray configs = new JSONArray(trimmed);
+ return configs.length() == 0 ? new JSONObject() : configs.getJSONObject(0);
+ }
+
+ /**
+ * Returns a deep copy of {@code config} with one sensors[] setting replaced or appended.
+ * Existing JSON value types are retained; new values infer booleans and numbers before
+ * falling back to a string.
+ */
+ static JSONObject withSensorSetting(
+ JSONObject config, String setting, String displayedValue) throws JSONException {
+ JSONObject copy = new JSONObject(config.toString());
+ JSONArray sensors = copy.optJSONArray("sensors");
+ if (sensors == null) {
+ sensors = new JSONArray();
+ copy.put("sensors", sensors);
+ }
+
+ for (int i = 0; i < sensors.length(); i++) {
+ JSONObject sensor = sensors.optJSONObject(i);
+ if (sensor == null || !setting.equals(sensor.optString("setting", ""))) continue;
+ sensor.put("value", typedConfigValue(displayedValue, sensor.opt("value")));
+ return copy;
+ }
+
+ JSONObject sensor = new JSONObject();
+ sensor.put("setting", setting);
+ sensor.put("value", typedConfigValue(displayedValue, null));
+ sensors.put(sensor);
+ return copy;
+ }
+
+ private static Object typedConfigValue(String value, Object existingValue) {
+ if (existingValue instanceof Boolean) return Boolean.parseBoolean(value);
+ if (existingValue instanceof Float || existingValue instanceof Double) {
+ try {
+ return Double.parseDouble(value);
+ } catch (NumberFormatException ignored) {
+ return value;
+ }
+ }
+ if (existingValue instanceof Number) {
+ try {
+ long number = Long.parseLong(value);
+ return number >= Integer.MIN_VALUE && number <= Integer.MAX_VALUE
+ ? (int) number : number;
+ } catch (NumberFormatException ignored) {
+ return value;
+ }
+ }
+
+ if ("true".equalsIgnoreCase(value) || "false".equalsIgnoreCase(value)) {
+ return Boolean.parseBoolean(value);
+ }
+ try {
+ long number = Long.parseLong(value);
+ return number >= Integer.MIN_VALUE && number <= Integer.MAX_VALUE
+ ? (int) number : number;
+ } catch (NumberFormatException ignored) {
+ try {
+ return Double.parseDouble(value);
+ } catch (NumberFormatException alsoIgnored) {
+ return value;
}
}
}
@@ -963,11 +1646,26 @@ public static JSONObject getStudyConfig(String studyUrl) throws JSONException {
studyUrl = studyUrl.replace("www.dropbox.com", "dl.dropboxusercontent.com");
}
- OkHttpClient client = new OkHttpClient();
- Request request = new Request.Builder().url(studyUrl).build();
-
- try (Response response = client.newCall(request).execute()) {
+ // Always fetch fresh so researcher edits are picked up. no-store rather than no-cache: the
+ // latter only asks caches to revalidate before reuse, no-store tells them not to keep a
+ // copy at all — a stronger guarantee against intermediary proxies serving something stale.
+ Request request = new Request.Builder()
+ .url(studyUrl)
+ .header("Cache-Control", "no-store")
+ .build();
+
+ try (Response response = STUDY_CONFIG_HTTP.newCall(request).execute()) {
+ if (!response.isSuccessful() || response.body() == null) return null;
+ long contentLength = response.body().contentLength();
+ if (contentLength > MAX_STUDY_CONFIG_BYTES) {
+ Log.e(Aware.TAG, "Study configuration is too large: " + contentLength);
+ return null;
+ }
String responseStr = response.body().string();
+ if (responseStr.length() > MAX_STUDY_CONFIG_BYTES) {
+ Log.e(Aware.TAG, "Study configuration exceeded size limit while reading");
+ return null;
+ }
JSONObject responseJson = new JSONObject(responseStr);
return responseJson;
} catch (IOException e) {
@@ -976,58 +1674,588 @@ public static JSONObject getStudyConfig(String studyUrl) throws JSONException {
}
/**
- * Validates that the study config has the correct JSON schema for AWARE.
- * It needs to have the keys: "database", "sensors" and "study_info".
+ * Why a study configuration was rejected. Both callers — the join screen and the config sync —
+ * need to tell the failures apart: the join screen has to say what the participant should do
+ * about it (re-type the password, try again later, contact the researcher), and config sync has
+ * to decide whether the configuration is still applicable.
+ */
+ public enum StudyConfigValidation {
+ /** Required schema is present and the database credentials authenticate. */
+ OK,
+ /** Absent, incomplete, or malformed configuration. The participant cannot fix this. */
+ INVALID_CONFIG,
+ /** The study expects a participant-supplied password and none was given. */
+ PASSWORD_REQUIRED,
+ /** The database rejected the password (access denied). */
+ AUTH_FAILED,
+ /** The database could not be reached — down, blocked, or too slow. Not a credential problem. */
+ UNREACHABLE
+ }
+
+ /**
+ * Validates a study configuration's schema and database credentials, classifying the failure.
+ *
+ * Credentials are checked with {@link Jdbc#probeConnection}, which reports a rejected password
+ * separately from an unreachable host, is bounded by {@link #STUDY_PROBE_TIMEOUT_SECONDS} so a
+ * dead host cannot stall a join indefinitely, and works on its own short-lived connection.
*
- * @param context application context
- * @param config JSON representing a study configuration
- * @return true if the study config is valid, false otherwise
+ * @param context application context, which the probe needs to verify the server's certificate
+ * @param config study configuration to validate; null is {@link StudyConfigValidation#INVALID_CONFIG}
+ * @param input_password password typed by the participant, used only by
+ * {@code config_without_password=true} studies. Never logged.
+ * @return the classified outcome
*/
- public static boolean validateStudyConfig(Context context, JSONObject config, String input_password) {
- if (config == null) {
- Log.e(Aware.TAG, "Study configuration is null");
- return false;
+ public static StudyConfigValidation validateStudyConfigDetailed(Context context, JSONObject config, String input_password) {
+ String missing = firstMissingRequirement(config);
+ if (missing != null) {
+ Log.e(Aware.TAG, "Study configuration is missing: " + missing);
+ return StudyConfigValidation.INVALID_CONFIG;
+ }
+
+ if (usesWebservice(config)) {
+ // No credential to verify, so reachability is the whole question. A study
+ // URL that does not answer now is the same problem a rejected password
+ // would be: the participant cannot join, and telling them so at the QR
+ // code is better than a phone that collects and never delivers.
+ return Webservice.reachable(settingValue(config, "webservice_server"))
+ ? StudyConfigValidation.OK
+ : StudyConfigValidation.UNREACHABLE;
+ }
+
+ JSONObject dbInfo = config.optJSONObject("database");
+ // config_without_password=true means the config deliberately ships no password and the
+ // participant supplies it; false means the config carries its own.
+ boolean participantSuppliesPassword = requiresParticipantPassword(config);
+ String password = participantSuppliesPassword
+ ? (input_password == null ? "" : input_password)
+ : dbInfo.optString("database_password", "");
+ if (participantSuppliesPassword && password.isEmpty()) {
+ Log.e(Aware.TAG, "Study requires a participant-supplied password, none was given");
+ return StudyConfigValidation.PASSWORD_REQUIRED;
}
- // Check for required keys
+ Jdbc.ConnectionResult probe = Jdbc.probeConnection(context,
+ dbInfo.optString("database_host", ""),
+ dbInfo.optString("database_port", ""),
+ dbInfo.optString("database_name", ""),
+ dbInfo.optString("database_username", ""),
+ password,
+ STUDY_PROBE_TIMEOUT_SECONDS);
+ switch (probe) {
+ case OK:
+ return StudyConfigValidation.OK;
+ case AUTH_FAILED:
+ return StudyConfigValidation.AUTH_FAILED;
+ default:
+ return StudyConfigValidation.UNREACHABLE;
+ }
+ }
+
+ /**
+ * Whether a downloaded configuration can be applied despite this validation outcome.
+ * {@link StudyConfigValidation#UNREACHABLE} concerns the upload database, not the configuration,
+ * so it does not block. Pure and Context-free so it can be unit-tested without a device.
+ */
+ static boolean configIsApplicable(StudyConfigValidation validation) {
+ return validation == StudyConfigValidation.OK
+ || validation == StudyConfigValidation.UNREACHABLE;
+ }
+
+ /**
+ * Whether this outcome means the participant has to supply a password again, as opposed to a
+ * failure they cannot act on. Both values are reported only after the schema checked out; the
+ * caller still confirms the study expects a participant-supplied password.
+ */
+ static boolean needsParticipantReauth(StudyConfigValidation validation) {
+ return validation == StudyConfigValidation.AUTH_FAILED
+ || validation == StudyConfigValidation.PASSWORD_REQUIRED;
+ }
+
+ /** Database fields that must be present before a connection attempt is even worth making. */
+ private static final String[] REQUIRED_DATABASE_FIELDS = {
+ "database_host", "database_port", "database_name", "database_username"};
+
+ /**
+ * Returns the name of the first required study-config field that is absent or empty, or null when
+ * the configuration carries everything needed to attempt a database connection.
+ *
+ * Package-private and free of logging, network and Context so it can be unit-tested directly —
+ * same reasoning as {@link Jdbc#classify}. Checking the database fields here rather than letting
+ * a connection attempt fail on them keeps "the researcher's config is broken" distinguishable
+ * from "the server is down", which the participant-facing message depends on.
+ */
+ static String firstMissingRequirement(JSONObject config) {
+ if (config == null) return "study configuration";
+
for (String key: REQUIRED_STUDY_CONFIG_KEYS) {
- if (!config.has(key)) {
- Log.e(Aware.TAG, "Study configuration missing required key: " + key);
- return false;
- }
+ if (!config.has(key)) return key;
}
- // Test database connection
- try {
- JSONObject dbInfo = config.getJSONObject("database");
- return Jdbc.testConnection(
- dbInfo.getString("database_host"),
- dbInfo.getString("database_port"),
- dbInfo.getString("database_name"),
- dbInfo.getString("database_username"),
- dbInfo.getString("database_password"),
- dbInfo.optBoolean("config_without_password", false),
- input_password);
- } catch (JSONException e) {
- Log.e(Aware.TAG, "Error validating database configuration: " + e.getMessage());
- return false;
+ if (usesWebservice(config)) {
+ // The study URL is this path's whole address: the phone posts its rows to
+ // it and fetches its config from it, and there is nothing else to check.
+ return settingValue(config, "webservice_server").isEmpty()
+ ? "webservice_server"
+ : null;
+ }
+
+ JSONObject dbInfo = config.optJSONObject("database");
+ if (dbInfo == null) return "database";
+
+ for (String field: REQUIRED_DATABASE_FIELDS) {
+ if (dbInfo.optString(field, "").isEmpty()) return field;
+ }
+ return null;
+ }
+
+ /**
+ * A named setting's value from a study config's sensors list, or "" when absent.
+ * Pure, so it is unit-testable.
+ */
+ static String settingValue(JSONObject config, String setting) {
+ if (config == null) return "";
+ JSONArray sensors = config.optJSONArray("sensors");
+ if (sensors == null) return "";
+ for (int i = 0; i < sensors.length(); i++) {
+ JSONObject entry = sensors.optJSONObject(i);
+ if (entry != null && setting.equals(entry.optString("setting", ""))) {
+ return entry.optString("value", "");
+ }
}
+ return "";
+ }
+
+ /**
+ * Whether this configuration uploads through the webservice rather than opening
+ * the database.
+ *
+ * The declared field first, then the channel setting the server derives from the
+ * same choice -- so a config written before the field existed still reads
+ * correctly, which is every config an already-enrolled phone is holding. Pure, so
+ * it is unit-testable.
+ */
+ static boolean usesWebservice(JSONObject config) {
+ if (config == null) return false;
+ String declared = config.optString("dataflow", "");
+ if ("webservice".equalsIgnoreCase(declared)) return true;
+ if ("direct".equalsIgnoreCase(declared)) return false;
+ return "true".equalsIgnoreCase(settingValue(config, "status_webservice"));
}
/**
- * Compares two JSON objects for equality
+ * True when the study's config deliberately ships no database password and the participant is
+ * expected to supply it ({@code config_without_password=true}). Pure, so it is unit-testable.
+ */
+ static boolean requiresParticipantPassword(JSONObject config) {
+ JSONObject dbInfo = config == null ? null : config.optJSONObject("database");
+ return dbInfo != null && dbInfo.optBoolean("config_without_password", false);
+ }
+
+ /**
+ * Compares two JSON objects for equality.
+ *
+ * Uses NON_EXTENSIBLE rather than LENIENT: LENIENT treats arrays as one-directional subset
+ * checks, so a config edit that only adds entries to the sensors/schedulers arrays (rather
+ * than toggling an existing entry's value) compared equal to the old config and was silently
+ * ignored on sync. NON_EXTENSIBLE requires both arrays to contain exactly the same elements
+ * (order-independent), which catches additions and removals alike.
*
* @param obj1 First JSON object
* @param obj2 Second JSON object
- * @param strict Whether to perform strict comparison
* @return true if the objects are equal, false otherwise
*/
- private static boolean jsonEquals(JSONObject obj1, JSONObject obj2, boolean strict) {
+ // Package-private rather than private so StudyUtilsTest (same package, src/test) can call this
+ // directly and lock in the NON_EXTENSIBLE regression above without reflection.
+ static boolean jsonEquals(JSONObject obj1, JSONObject obj2) {
try {
- JSONAssert.assertEquals(obj1, obj2, strict);
+ JSONAssert.assertEquals(obj1, obj2, JSONCompareMode.NON_EXTENSIBLE);
return true;
} catch (JSONException | AssertionError e) {
return false;
}
}
-}
\ No newline at end of file
+
+ /**
+ * How long to wait before retrying a self-heal for the same detected live-settings drift.
+ * Prevents an unfixable drift (e.g. a sensor whose hardware is missing, so it can never
+ * actually match the config) from re-triggering applySettings() — and restarting every sensor
+ * service — on every ~1 minute sync poll. Long enough to stop the hot loop, short enough that
+ * a transient cause (e.g. a permission the participant grants later) still self-heals same-day.
+ */
+ private static final long DRIFT_RECONCILE_BACKOFF_MS = 60 * 60 * 1000; // 1 hour
+
+ /**
+ * Compares every status_* sensor setting in {@code config} against its live value in the
+ * aware_settings provider (what actually controls whether a sensor service runs) and returns a
+ * stable, sorted signature of any mismatches — empty string if live settings already match.
+ *
+ * Only status_* (on/off) settings are checked, not every setting (frequency/threshold etc.):
+ * those don't affect whether data collection is happening at all, just its granularity, so a
+ * mismatch there isn't the "participant thinks they're compliant but a sensor is off" failure
+ * mode this exists to catch — and checking them would make the signature (and the reapply
+ * cadence below) noisier without covering a materially worse bug.
+ *
+ * Settings whose sensor hardware this device doesn't have (per SensorAvailability) are excluded
+ * entirely rather than left for the 1-hour reconcile backoff to suppress: a missing sensor is a
+ * permanent, known fact about the device, not a transient failure that might self-heal, so it
+ * shouldn't ever count as "drift" or occupy a slot in the backoff-tracked signature at all.
+ */
+ private static String liveDriftSignature(Context context, JSONObject config) {
+ JSONArray sensors = config.optJSONArray("sensors");
+ if (sensors == null) return "";
+
+ // Read every candidate setting's live value (and hardware availability) up front so the
+ // comparison itself (below) is a pure function of data, not of Context/ContentResolver —
+ // makes it directly unit-testable without Robolectric, same reasoning as
+ // Aware.parseLongOrDefault being split out from Aware.getSettingAsLong().
+ Map liveValues = new HashMap<>();
+ Set excluded = new HashSet<>();
+ for (int i = 0; i < sensors.length(); i++) {
+ JSONObject sensor = sensors.optJSONObject(i);
+ if (sensor == null) continue;
+ String setting = sensor.optString("setting", "");
+ if (!setting.startsWith("status_") || !sensor.has("value")) continue;
+ liveValues.put(setting, Aware.getSetting(context, setting));
+ if (!SensorAvailability.isHardwareAvailable(context, setting)) {
+ excluded.add(setting);
+ }
+ }
+ // Also exclude sensors the participant declined: like a missing-hardware sensor, a decline is
+ // a deliberate, known reason the live value differs from the config — not drift to self-heal.
+ // Without this the ~1-minute sync would re-enable every declined sensor, undoing the decline.
+ String persistedDeclined = Aware.getSetting(context, Aware_Preferences.STUDY_DECLINED_SENSORS);
+ for (String key : persistedDeclined.split(",")) {
+ if (key.trim().length() > 0) excluded.add(key.trim());
+ }
+ return driftSignature(config, liveValues, excluded);
+ }
+
+ /**
+ * Context-free core of {@link #liveDriftSignature(Context, JSONObject)}: compares each
+ * status_* sensor setting in {@code config} against its value in {@code liveValues} (missing
+ * from the map is treated the same as an empty/unset live setting) and returns a stable, sorted
+ * signature of any mismatches — empty string if everything matches. Settings named in
+ * {@code hardwareUnavailable} are skipped regardless of their live value: the device can never
+ * satisfy them, so they're not "drift" in the sense this method exists to catch. Split out so
+ * it's unit-testable without a Context.
+ *
+ * Only status_* (on/off) settings are checked, not every setting (frequency/threshold etc.):
+ * those don't affect whether data collection is happening at all, just its granularity, so a
+ * mismatch there isn't the "participant thinks they're compliant but a sensor is off" failure
+ * mode this exists to catch — and checking them would make the signature (and the reapply
+ * cadence below) noisier without covering a materially worse bug.
+ */
+ static String driftSignature(JSONObject config, Map liveValues, Set excludedSettings) {
+ JSONArray sensors = config.optJSONArray("sensors");
+ if (sensors == null) return "";
+
+ TreeMap mismatches = new TreeMap<>();
+ for (int i = 0; i < sensors.length(); i++) {
+ JSONObject sensor = sensors.optJSONObject(i);
+ if (sensor == null) continue;
+
+ String setting = sensor.optString("setting", "");
+ if (!setting.startsWith("status_") || !sensor.has("value")) continue;
+ if (excludedSettings.contains(setting)) continue;
+
+ String expected = String.valueOf(sensor.opt("value"));
+ String live = liveValues.containsKey(setting) ? liveValues.get(setting) : "";
+ if (!expected.equalsIgnoreCase(live)) {
+ mismatches.put(setting, expected + "!=" + live);
+ }
+ }
+
+ if (mismatches.isEmpty()) return "";
+ StringBuilder signature = new StringBuilder();
+ for (Map.Entry mismatch : mismatches.entrySet()) {
+ signature.append(mismatch.getKey()).append('=').append(mismatch.getValue()).append(';');
+ }
+ return signature.toString();
+ }
+
+ /**
+ * Computes which sensors became active/inactive between two study configs, as human-readable
+ * names, into {@code added} and {@code removed}. Settings in {@code excludedStatusSettings}
+ * (e.g. sensors whose hardware this device lacks) are left out entirely — the participant can't
+ * act on them, so listing them as "to activate" / "no longer collecting" would be misleading.
+ */
+ private static void diffActiveSensors(JSONObject oldConfig, JSONObject newConfig,
+ List added, List removed,
+ Set excludedStatusSettings) {
+ Set before = activeSensorNames(oldConfig, excludedStatusSettings);
+ Set after = activeSensorNames(newConfig, excludedStatusSettings);
+ for (String s : after) if (!before.contains(s)) added.add(s);
+ for (String s : before) if (!after.contains(s)) removed.add(s);
+ }
+
+ /**
+ * Human-readable names of sensors whose status_* setting is enabled (true) in a config,
+ * skipping any setting named in {@code excludedStatusSettings}.
+ */
+ private static Set activeSensorNames(JSONObject config, Set excludedStatusSettings) {
+ Set active = new HashSet<>();
+ JSONArray sensors = config.optJSONArray("sensors");
+ if (sensors == null) return active;
+ for (int i = 0; i < sensors.length(); i++) {
+ JSONObject sensor = sensors.optJSONObject(i);
+ if (sensor == null) continue;
+ String setting = sensor.optString("setting", "");
+ if (excludedStatusSettings.contains(setting)) continue;
+ if (setting.startsWith("status_") && sensor.optBoolean("value", false)) {
+ active.add(setting.substring("status_".length()).replace('_', ' '));
+ }
+ }
+ return active;
+ }
+
+ /**
+ * The status_* settings in {@code configs} whose sensor hardware this device doesn't have (per
+ * {@link SensorAvailability}). Such sensors can never collect regardless of the config, so a
+ * difference confined to them isn't an actionable study update — see
+ * {@link #configsDifferOnlyByUnavailableSensors} and the preview gate in {@link #syncStudyConfig}.
+ */
+ private static Set unavailableStatusSettings(Context context, JSONObject... configs) {
+ Set unavailable = new HashSet<>();
+ for (JSONObject config : configs) {
+ JSONArray sensors = config == null ? null : config.optJSONArray("sensors");
+ if (sensors == null) continue;
+ for (int i = 0; i < sensors.length(); i++) {
+ JSONObject sensor = sensors.optJSONObject(i);
+ if (sensor == null) continue;
+ String setting = sensor.optString("setting", "");
+ if (setting.startsWith("status_")
+ && !SensorAvailability.isHardwareAvailable(context, setting)) {
+ unavailable.add(setting);
+ }
+ }
+ }
+ return unavailable;
+ }
+
+ /**
+ * True when {@code local} and {@code server} are not identical, yet become identical once the
+ * status_* settings for sensors this device can't support (no hardware) are ignored — i.e. the
+ * only thing the server changed is a sensor the participant could never turn on anyway.
+ */
+ private static boolean configsDifferOnlyByUnavailableSensors(
+ Context context, JSONObject local, JSONObject server) {
+ Set unavailable = unavailableStatusSettings(context, local, server);
+ if (unavailable.isEmpty()) return false;
+ return configsEqualIgnoringSensors(local, server, unavailable);
+ }
+
+ /**
+ * True if {@code a} and {@code b} are equal (per {@link #jsonEquals}) after removing every
+ * sensors[] entry whose "setting" is in {@code ignoredStatusSettings} from both. Split out as a
+ * pure, Context-free function so it's directly unit-testable, same reasoning as
+ * {@link #driftSignature}. Only the named status_* entries are dropped, not sibling frequency /
+ * threshold entries for the same sensor — AWARE study edits toggle a sensor's status_* value in
+ * place, so a status-only compare matches how the codebase already defines a sensor being on/off
+ * ({@link #driftSignature}, {@link #diffActiveSensors}).
+ */
+ static boolean configsEqualIgnoringSensors(
+ JSONObject a, JSONObject b, Set ignoredStatusSettings) {
+ try {
+ return jsonEquals(
+ withoutSensorSettings(a, ignoredStatusSettings),
+ withoutSensorSettings(b, ignoredStatusSettings));
+ } catch (JSONException e) {
+ return false;
+ }
+ }
+
+ /** Deep copy of {@code config} with every sensors[] entry named in {@code settingsToRemove} dropped. */
+ private static JSONObject withoutSensorSettings(
+ JSONObject config, Set settingsToRemove) throws JSONException {
+ JSONObject copy = new JSONObject(config.toString());
+ JSONArray sensors = copy.optJSONArray("sensors");
+ if (sensors == null) return copy;
+ JSONArray kept = new JSONArray();
+ for (int i = 0; i < sensors.length(); i++) {
+ JSONObject sensor = sensors.optJSONObject(i);
+ if (sensor != null && settingsToRemove.contains(sensor.optString("setting", ""))) continue;
+ kept.put(sensors.get(i));
+ }
+ copy.put("sensors", kept);
+ return copy;
+ }
+
+ /** The raw status_* setting keys enabled (value true) in a config. */
+ private static Set enabledStatusSettings(JSONObject config) {
+ Set out = new HashSet<>();
+ if (config == null) return out;
+ JSONArray sensors = config.optJSONArray("sensors");
+ if (sensors == null) return out;
+ for (int i = 0; i < sensors.length(); i++) {
+ JSONObject sensor = sensors.optJSONObject(i);
+ if (sensor == null) continue;
+ String setting = sensor.optString("setting", "");
+ if (setting.startsWith("status_") && sensor.optBoolean("value", false)) out.add(setting);
+ }
+ return out;
+ }
+
+ /**
+ * After a server-side config change, hold every newly-enabled sensor that needs a participant
+ * grant OFF until they agree, by adding it to the persisted declined set (F2). "Newly enabled" =
+ * enabled in {@code newConfig} but not in {@code oldConfig}; "needs a grant" per
+ * {@link SensorDiagnostics#requiresConsent}. Also drops from the declined set any setting the new
+ * config no longer enables, so stale entries don't accumulate across successive edits.
+ */
+ private static void holdNewlyAddedConsentSensors(Context context, JSONObject oldConfig, JSONObject newConfig) {
+ Set oldEnabled = enabledStatusSettings(oldConfig);
+ Set newEnabled = enabledStatusSettings(newConfig);
+
+ Set declined = new HashSet<>();
+ for (String key : Aware.getSetting(context, Aware_Preferences.STUDY_DECLINED_SENSORS).split(",")) {
+ if (key.trim().length() > 0) declined.add(key.trim());
+ }
+
+ // Keep only declines for sensors the config still enables — drop the rest as stale.
+ declined.retainAll(newEnabled);
+
+ // Hold each newly-enabled, consent-requiring sensor off until the participant agrees.
+ for (String setting : newEnabled) {
+ if (!oldEnabled.contains(setting) && SensorDiagnostics.requiresConsent(setting)) {
+ declined.add(setting);
+ }
+ }
+
+ StringBuilder joined = new StringBuilder();
+ for (String setting : declined) {
+ if (joined.length() > 0) joined.append(',');
+ joined.append(setting);
+ }
+ Aware.setSetting(context, Aware_Preferences.STUDY_DECLINED_SENSORS, joined.toString());
+ }
+
+ /**
+ * The consent-requiring status_* settings enabled across {@code configs} — every sensor that
+ * gates on a runtime permission or the Accessibility Service (per
+ * {@link SensorDiagnostics#requiresConsent}). Permission-free base sensors are excluded. Pure
+ * (no Context) so it's unit-testable, mirroring {@link #driftSignature}'s split from
+ * {@link #liveDriftSignature}.
+ */
+ static Set consentRequiringEnabledSettings(JSONArray configs) {
+ Set out = new HashSet<>();
+ if (configs == null) return out;
+ for (int i = 0; i < configs.length(); i++) {
+ for (String setting : enabledStatusSettings(configs.optJSONObject(i))) {
+ if (SensorDiagnostics.requiresConsent(setting)) out.add(setting);
+ }
+ }
+ return out;
+ }
+
+ /**
+ * Installation events use package broadcasts and do not need Accessibility at the Android API
+ * level. In the participant-facing consent, however, they are disclosed as part of Applications
+ * usage. Preserve that product-level consent boundary: declining the Applications choice also
+ * declines installation events.
+ */
+ static Set expandGroupedConsentDeclines(Set declined) {
+ Set expanded = new HashSet<>();
+ if (declined != null) expanded.addAll(declined);
+ if (expanded.contains(Aware_Preferences.STATUS_APPLICATIONS)
+ || expanded.contains(Aware_Preferences.STATUS_NOTIFICATIONS)
+ || expanded.contains(Aware_Preferences.STATUS_CRASHES)) {
+ expanded.add(Aware_Preferences.STATUS_INSTALLATIONS);
+ }
+ return expanded;
+ }
+
+ private static String joinSettings(Set settings) {
+ StringBuilder joined = new StringBuilder();
+ for (String setting : settings) {
+ if (joined.length() > 0) joined.append(',');
+ joined.append(setting);
+ }
+ return joined.toString();
+ }
+
+ /**
+ * Migrates existing enrolments made before installation events were tied to Applications
+ * consent, and mirrors the forced-off value into both setting stores before services start.
+ */
+ public static void enforceGroupedConsent(Context context) {
+ Set declined = new HashSet<>();
+ for (String key : Aware.getSetting(
+ context, Aware_Preferences.STUDY_DECLINED_SENSORS).split(",")) {
+ if (key.trim().length() > 0) declined.add(key.trim());
+ }
+ Set expanded = expandGroupedConsentDeclines(declined);
+ if (!expanded.contains(Aware_Preferences.STATUS_INSTALLATIONS)) {
+ return;
+ }
+ if (!declined.contains(Aware_Preferences.STATUS_INSTALLATIONS)) {
+ Aware.setSetting(context, Aware_Preferences.STUDY_DECLINED_SENSORS,
+ joinSettings(expanded));
+ }
+ Aware.setSetting(context, Aware_Preferences.STATUS_INSTALLATIONS, false);
+ PreferenceManager.getDefaultSharedPreferences(context).edit()
+ .putBoolean(Aware_Preferences.STATUS_INSTALLATIONS, false)
+ .apply();
+ }
+
+ /**
+ * Holds every consent-requiring sensor enabled in {@code configs} OFF, by persisting it into the
+ * declined set (unioned with anything already there), and returns the resulting declined set.
+ * The programmatic join entry points ({@link Aware#joinStudy} / the {@link StudyUtils} service)
+ * have no consent UI, so a sensor needing a runtime permission or the Accessibility Service must
+ * not be silently enabled — this keeps it off, and keeps it off across later config syncs via the
+ * persisted set. Permission-free base sensors are untouched and still start; a held sensor can be
+ * enabled later once the participant consents (the consent screen, or the per-sensor Enable
+ * action).
+ */
+ public static Set holdConsentSensorsUnlessAgreed(Context context, JSONArray configs) {
+ Set declined = new HashSet<>();
+ for (String key : Aware.getSetting(context, Aware_Preferences.STUDY_DECLINED_SENSORS).split(",")) {
+ if (key.trim().length() > 0) declined.add(key.trim());
+ }
+ declined.addAll(consentRequiringEnabledSettings(configs));
+
+ StringBuilder joined = new StringBuilder();
+ for (String setting : declined) {
+ if (joined.length() > 0) joined.append(',');
+ joined.append(setting);
+ }
+ Aware.setSetting(context, Aware_Preferences.STUDY_DECLINED_SENSORS, joined.toString());
+ return declined;
+ }
+
+ /**
+ * Value of a given setting key in a config's sensors array, or null if not present.
+ * Boolean-only (unlike processSensorSettings()'s multi-type handling elsewhere in this file) —
+ * fine for enable_config_update, but don't reuse this for a non-boolean setting.
+ */
+ private static Boolean sensorSettingValue(JSONObject config, String key) {
+ JSONArray sensors = config.optJSONArray("sensors");
+ if (sensors == null) return null;
+ for (int i = 0; i < sensors.length(); i++) {
+ JSONObject sensor = sensors.optJSONObject(i);
+ if (sensor == null) continue;
+ if (key.equals(sensor.optString("setting", ""))) {
+ return sensor.optBoolean("value", false);
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Null if the study's effective editability didn't change; otherwise its new value, for
+ * the participant-facing "study updated" notice. Absent {@code enable_config_update} means the
+ * default, researcher-controlled (locked) state, so it is normalised to {@code false} before
+ * comparing: a config that merely starts or stops spelling the setting out (absent ↔ false) is NOT
+ * a change and must not tell participants their edit access changed. Only a real flip
+ * (locked ↔ editable) returns non-null.
+ */
+ // Package-private so StudyUtilsTest can lock in the absent-means-false normalisation.
+ static Boolean enableConfigUpdateChanged(JSONObject oldConfig, JSONObject newConfig) {
+ Boolean before = sensorSettingValue(oldConfig, Aware_Preferences.ENABLE_CONFIG_UPDATE);
+ Boolean after = sensorSettingValue(newConfig, Aware_Preferences.ENABLE_CONFIG_UPDATE);
+ boolean effectiveBefore = before != null && before;
+ boolean effectiveAfter = after != null && after;
+ if (effectiveBefore == effectiveAfter) return null;
+ return effectiveAfter;
+ }
+}
diff --git a/aware-core/src/main/java/com/aware/utils/SyncBatchBudget.java b/aware-core/src/main/java/com/aware/utils/SyncBatchBudget.java
new file mode 100644
index 00000000..d63c59d5
--- /dev/null
+++ b/aware-core/src/main/java/com/aware/utils/SyncBatchBudget.java
@@ -0,0 +1,72 @@
+package com.aware.utils;
+
+/**
+ * Bounds an upload batch by the size of its payload rather than by its row count alone.
+ *
+ * A row-count cap is payload-blind: the same limit covers an accelerometer row of roughly a hundred
+ * bytes and a screenshot row carrying a base64-encoded image of several hundred kilobytes to over a
+ * megabyte. At the row counts a modern phone is given (5,000–10,000), a screenshot backlog reaches
+ * gigabytes per batch, which fails twice over — the phone materialises the whole batch as JSON in
+ * its heap, and the merged INSERT statement passes the server's {@code max_allowed_packet}.
+ *
+ * That failure does not settle at "some batches fail". A batch that never lands is retried
+ * unchanged, the sync marker never advances, local cleanup never runs, the table only grows, and the
+ * next batch is larger still. Capping the bytes is what keeps a backlog draining.
+ */
+public final class SyncBatchBudget {
+
+ private SyncBatchBudget() {
+ }
+
+ /**
+ * Ceiling on the estimated payload of a single batch.
+ *
+ * Comfortably inside a 64 MB {@code max_allowed_packet} while leaving the driver room for the
+ * statement text around the values, and small enough that the JSON the phone holds in its heap
+ * to build the batch stays affordable on a low-memory device.
+ */
+ public static final long MAX_PAYLOAD_BYTES = 8L * 1024L * 1024L;
+
+ /**
+ * Bytes a column contributes beyond its value: the column name, the quoting around the value,
+ * and the separators between columns.
+ */
+ public static final int COLUMN_OVERHEAD_BYTES = 8;
+
+ /**
+ * Bytes charged for a numeric value in place of measuring it, generously rounded up so the
+ * estimate errs towards smaller batches. Avoids formatting every number to a string purely to
+ * take its length, on the tables where rows are numerous and individually tiny.
+ */
+ public static final int NUMERIC_VALUE_BYTES = 24;
+
+ /**
+ * The estimated contribution of one column to a batch's payload.
+ *
+ * @param columnName the column being written
+ * @param valueLength length of the value's text form, or {@link #NUMERIC_VALUE_BYTES} for a number
+ */
+ public static long columnBytes(String columnName, int valueLength) {
+ return columnName.length() + COLUMN_OVERHEAD_BYTES + valueLength;
+ }
+
+ /**
+ * Whether a row must be left for the next batch because adding it would take this one past the
+ * cap.
+ *
+ * A batch already holding nothing takes the row regardless of its size. That clause is what
+ * keeps an oversized single row from wedging the table: a batch of zero rows reports nothing
+ * uploaded, the sync marker never advances, and the same row is offered again on every sync
+ * forever. Taking it means an unsendable row fails loudly against the server instead.
+ *
+ * @param rowsInBatch rows already accepted into the batch
+ * @param bytesInBatch estimated payload of those rows
+ * @param rowBytes estimated payload of the row being considered
+ * @param capBytes ceiling for this batch, normally {@link #MAX_PAYLOAD_BYTES}
+ * @return true to hold the row back for a later batch
+ */
+ public static boolean holdForNextBatch(int rowsInBatch, long bytesInBatch, long rowBytes,
+ long capBytes) {
+ return rowsInBatch > 0 && bytesInBatch + rowBytes > capBytes;
+ }
+}
diff --git a/aware-core/src/main/java/com/aware/utils/SyncCursor.java b/aware-core/src/main/java/com/aware/utils/SyncCursor.java
new file mode 100644
index 00000000..aaf2db77
--- /dev/null
+++ b/aware-core/src/main/java/com/aware/utils/SyncCursor.java
@@ -0,0 +1,125 @@
+package com.aware.utils;
+
+/**
+ * Where a table's upload resumes from, and which rows the next batch takes.
+ *
+ * The cursor is the row id, which SQLite assigns in insertion order and never reuses. That gives an
+ * upload two properties a capture timestamp cannot: every row has a distinct position, and a row
+ * buffered by a sensor and inserted later sits after the cursor rather than behind it. A batch
+ * therefore resumes exactly where the last acknowledged one stopped, and the rows it takes are the
+ * rows that have never been offered.
+ *
+ * Paging is by cursor position rather than by offset, so the window a batch reads is decided by the
+ * last acknowledged row alone. Inserts arriving between two batches of the same run land after that
+ * position and are read by a later batch.
+ */
+public final class SyncCursor {
+
+ /** The row id, assigned in insertion order. */
+ public static final String ROW_ID = "_id";
+
+ /** Session tables carry the instant a session closed. */
+ public static final String SESSION_END = "double_end_timestamp";
+
+ /** ESM tables carry the instant the participant answered. */
+ public static final String ESM_ANSWER = "double_esm_user_answer_timestamp";
+
+ private SyncCursor() {
+ }
+
+ /**
+ * The column a table gates a finished row on, or null when every stored row is ready to upload.
+ *
+ * @param columns the table's column names
+ */
+ public static String completionColumn(String[] columns) {
+ if (contains(columns, SESSION_END)) return SESSION_END;
+ if (contains(columns, ESM_ANSWER)) return ESM_ANSWER;
+ return null;
+ }
+
+ /** Whether a table is ordered by the instant its rows finished rather than by row id. */
+ public static boolean pagesByCompletion(String[] columns) {
+ return completionColumn(columns) != null;
+ }
+
+ /**
+ * The column a table's rows are ordered by: the instant they finished where the table has one,
+ * and the row id otherwise.
+ */
+ public static String orderColumn(String[] columns) {
+ String completion = completionColumn(columns);
+ return completion == null ? ROW_ID : completion;
+ }
+
+ /**
+ * The rows one batch takes: those past the cursor, and finished where the table says so.
+ *
+ * A table whose rows finish after they are stored is ordered by the instant they finished, and
+ * the cursor is the pair (that instant, row id). A row is past the cursor when it finished later,
+ * or finished in the same millisecond and sits after it in insertion order. Every write of a
+ * completion column stamps the moment of completion, so a row that finishes now finishes after
+ * every row already uploaded and is offered on the next sync however long it ran.
+ *
+ * @param columns the table's column names
+ * @param cursorValue the ordering value the cursor stands at
+ * @param cursorId row id of the last acknowledged row; 0 offers the table from its start
+ * @param studyCondition additional clause restricting rows to the study, or null
+ */
+ public static String selection(String[] columns, long cursorValue, long cursorId,
+ String studyCondition) {
+ StringBuilder selection = new StringBuilder();
+ String completion = completionColumn(columns);
+
+ if (completion == null) {
+ selection.append(ROW_ID).append(" > ").append(cursorId);
+ } else {
+ selection.append(completion).append(" != 0")
+ .append(" AND (").append(completion).append(" > ").append(cursorValue)
+ .append(" OR (").append(completion).append(" = ").append(cursorValue)
+ .append(" AND ").append(ROW_ID).append(" > ").append(cursorId).append("))");
+ }
+
+ if (studyCondition != null) selection.append(studyCondition);
+
+ return selection.toString();
+ }
+
+ /**
+ * One batch of rows, read from the cursor forward in the order the table is paged by.
+ *
+ * @param columns the table's column names
+ * @param batchSize rows the batch may carry
+ */
+ public static String order(String[] columns, int batchSize) {
+ String completion = completionColumn(columns);
+ if (completion == null) return ROW_ID + " ASC LIMIT " + batchSize;
+ return completion + " ASC, " + ROW_ID + " ASC LIMIT " + batchSize;
+ }
+
+ /**
+ * Whether a table's cursor is still to be derived from a timestamp marker.
+ *
+ * A marker recording only how far a table was uploaded by capture time names a position the row
+ * id cursor can be seeded from, so an upload continues from there rather than offering the table
+ * from its start again. The translation holds for a table paged by row id, where capture order
+ * and insertion order agree. A table paged by completion is ordered on an axis that marker says
+ * nothing about, so its cursor opens at the start of that axis and the rows already held are
+ * offered once more.
+ *
+ * @param columns the table's column names
+ * @param cursorId the stored row id cursor
+ * @param markerTimestamp the stored timestamp marker
+ */
+ public static boolean needsSeeding(String[] columns, long cursorId, long markerTimestamp) {
+ return !pagesByCompletion(columns) && cursorId <= 0 && markerTimestamp > 0;
+ }
+
+ private static boolean contains(String[] columns, String name) {
+ if (columns == null) return false;
+ for (String column : columns) {
+ if (name.equals(column)) return true;
+ }
+ return false;
+ }
+}
diff --git a/aware-core/src/main/java/com/aware/utils/UploadHealth.java b/aware-core/src/main/java/com/aware/utils/UploadHealth.java
new file mode 100644
index 00000000..d1096b8b
--- /dev/null
+++ b/aware-core/src/main/java/com/aware/utils/UploadHealth.java
@@ -0,0 +1,279 @@
+package com.aware.utils;
+
+import android.content.Context;
+import android.database.Cursor;
+
+import com.aware.Aware;
+import com.aware.Aware_Preferences;
+import com.aware.R;
+import com.aware.providers.Aware_Provider;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+/**
+ * Whether data is reaching the research database, and what the participant is told about it.
+ *
+ * Upload health is kept as state rather than as a stream of events, and **per table**.
+ * {@code onPerformSync} runs once per table, so a single outage produces a failure for every table on
+ * every tick; recording each one would fill aware_log with the same fact. Instead each table's outage
+ * is recorded when it starts and cleared when that table is next acknowledged, so one outage leaves
+ * one entry however long it lasts.
+ *
+ * Per table because the failures seen in the field were per table: a column the server lacked, and a
+ * sensor that had silently stopped collecting. With one shared flag, the next table's success cleared
+ * the broken table's outage, so both reported themselves healthy — for two hours and twenty-one hours
+ * respectively.
+ *
+ * The participant is told two things. The app always shows how far delivery has reached, which costs
+ * no attention. A notification is posted as soon as delivery starts failing, so a phone that has
+ * stopped reaching the study is visible straight away rather than discovered later. It is posted once
+ * per outage — the edge, not every failure — and cancelled when delivery recovers.
+ */
+public final class UploadHealth {
+
+ private UploadHealth() {
+ }
+
+ /**
+ * Records that a table's batch was acknowledged: ends that table's outage, and clears the
+ * notification once no table is failing.
+ *
+ * Clearing only the named table is the whole point. Roughly 30 sync adapters run in parallel and
+ * each calls this for its own table, so a version that cleared everything let the next successful
+ * table erase a broken one's outage — which is how a table with a missing server column reported
+ * itself healthy for two hours.
+ */
+ public static void recordSuccess(Context context, String table) {
+ Map outages = outages(context);
+ if (!outages.containsKey(table)) return;
+
+ Map remaining = withSuccess(outages, table);
+ saveOutages(context, remaining);
+ Aware.debug(context, Aware.LogType.SYNC,
+ "Upload recovered for " + table + "; its data is reaching the database again");
+
+ if (remaining.isEmpty()) {
+ StudyUtils.cancelStudyNotification(context, Aware.AWARE_UPLOAD_HEALTH_NOTIFICATION_ID);
+ Aware.setSetting(context, Aware_Preferences.UPLOAD_OUTAGE_NOTIFIED, "false");
+ Aware.debug(context, Aware.LogType.SYNC,
+ "Upload recovered; every table is reaching the database again");
+ }
+ }
+
+ /**
+ * Records that a table's batch was not acknowledged.
+ *
+ * @param table the table that failed
+ * @param reason short, non-sensitive description of why — never a connection string or password
+ */
+ public static void recordFailure(Context context, String table, String reason) {
+ Map outages = outages(context);
+
+ if (!outages.containsKey(table)) {
+ // Edge-triggered per table: the first failure of this table's outage is the one worth
+ // recording. Later failures of the same table say the same thing.
+ saveOutages(context, withFailure(outages, table, System.currentTimeMillis()));
+ Aware.debug(context, Aware.LogType.SYNC, "Upload failing for " + table + ": " + reason);
+ }
+
+ if (shouldNotify(outageSince(context), alreadyNotified(context))) {
+ StudyUtils.postStudyNotification(context, Aware.AWARE_UPLOAD_HEALTH_NOTIFICATION_ID,
+ R.string.aware_notif_upload_stalled_title,
+ R.string.aware_notif_upload_stalled);
+ Aware.setSetting(context, Aware_Preferences.UPLOAD_OUTAGE_NOTIFIED, "true");
+ Aware.debug(context, Aware.LogType.SYNC,
+ "Notified the participant that data is not reaching the database");
+ }
+ }
+
+ /** Whether this table specifically is failing to deliver. */
+ public static boolean isFailing(Context context, String table) {
+ return outages(context).containsKey(table);
+ }
+
+ /** The tables currently failing to deliver, alphabetically; empty when delivery is healthy. */
+ public static List failingTables(Context context) {
+ return new ArrayList<>(outages(context).keySet());
+ }
+
+ private static Map outages(Context context) {
+ return parseOutages(Aware.getSetting(context, Aware_Preferences.UPLOAD_OUTAGE_TABLES));
+ }
+
+ private static void saveOutages(Context context, Map outages) {
+ Aware.setSetting(context, Aware_Preferences.UPLOAD_OUTAGE_TABLES, formatOutages(outages));
+ // Kept in step for the screens and for any researcher reading the settings: the oldest
+ // failing table's start time is when delivery stopped being wholly healthy.
+ Aware.setSetting(context, Aware_Preferences.UPLOAD_OUTAGE_SINCE, earliestOutage(outages));
+ Aware.setSetting(context, Aware_Preferences.UPLOAD_OUTAGE_REASON,
+ outages.isEmpty() ? "" : describeFailing(new ArrayList<>(outages.keySet()))
+ + " not acknowledged");
+ }
+
+ // --- Pure outage-set operations. Separated from the Context work so the behaviour that actually
+ // masked a broken table can be unit-tested rather than only reasoned about.
+
+ /** Parses {@code table:sinceMs} pairs; unparseable or empty entries are skipped, not guessed. */
+ static Map parseOutages(String raw) {
+ Map outages = new TreeMap<>();
+ if (raw == null || raw.trim().isEmpty()) return outages;
+ for (String entry : raw.split(",")) {
+ int split = entry.lastIndexOf(':');
+ if (split <= 0 || split == entry.length() - 1) continue;
+ try {
+ long since = Long.parseLong(entry.substring(split + 1).trim());
+ if (since > 0) outages.put(entry.substring(0, split).trim(), since);
+ } catch (NumberFormatException ignored) {
+ }
+ }
+ return outages;
+ }
+
+ static String formatOutages(Map outages) {
+ StringBuilder formatted = new StringBuilder();
+ for (Map.Entry outage : outages.entrySet()) {
+ if (formatted.length() > 0) formatted.append(',');
+ formatted.append(outage.getKey()).append(':').append(outage.getValue());
+ }
+ return formatted.toString();
+ }
+
+ /** Adds a table's outage, keeping the start time of one already recorded. */
+ static Map withFailure(Map outages, String table, long now) {
+ Map updated = new TreeMap<>(outages);
+ if (!updated.containsKey(table)) updated.put(table, now);
+ return updated;
+ }
+
+ /** Removes only the named table's outage, leaving every other table's untouched. */
+ static Map withSuccess(Map outages, String table) {
+ Map updated = new TreeMap<>(outages);
+ updated.remove(table);
+ return updated;
+ }
+
+ /** When delivery first stopped being wholly healthy, or 0 when no table is failing. */
+ static long earliestOutage(Map outages) {
+ long earliest = 0;
+ for (long since : outages.values()) {
+ if (since > 0 && (earliest == 0 || since < earliest)) earliest = since;
+ }
+ return earliest;
+ }
+
+ /** Names the failing tables for a participant: {@code "bluetooth"}, {@code "a and b"}, {@code "a, b and c"}. */
+ static String describeFailing(List tables) {
+ if (tables == null || tables.isEmpty()) return "";
+ if (tables.size() == 1) return tables.get(0);
+ StringBuilder described = new StringBuilder();
+ for (int i = 0; i < tables.size() - 1; i++) {
+ if (i > 0) described.append(", ");
+ described.append(tables.get(i));
+ }
+ return described.append(" and ").append(tables.get(tables.size() - 1)).toString();
+ }
+
+ /**
+ * Whether a delivery-failure notification is due: delivery is failing and the participant has not
+ * already been told about this outage.
+ *
+ * The second condition is what keeps one outage to one notification. Every table reports the same
+ * outage on every sync tick, and re-posting the same notification id alerts again each time, so
+ * without it the participant would be buzzed once per table per minute.
+ *
+ * Pure, so it can be unit-tested without a device.
+ *
+ * @param outageSince when the current outage began, or 0 if delivery is healthy
+ */
+ static boolean shouldNotify(long outageSince, boolean alreadyNotified) {
+ return outageSince > 0 && !alreadyNotified;
+ }
+
+ /**
+ * One line for the participant, describing how far delivery has reached.
+ *
+ * Pure and free of Android formatting: the caller passes a rendered relative time so this stays
+ * unit-testable. A null {@code deliveredUpToRelative} means nothing has ever been delivered, which
+ * reads differently from a delivery that has fallen behind — on a new enrolment there is no gap
+ * to explain yet.
+ */
+ public static String statusLine(CharSequence deliveredUpToRelative, List failingTables,
+ int pendingRecords) {
+ StringBuilder line = new StringBuilder();
+ if (deliveredUpToRelative == null) {
+ line.append("Nothing delivered yet");
+ } else {
+ line.append("Delivered up to ").append(deliveredUpToRelative);
+ }
+ if (failingTables != null && !failingTables.isEmpty()) {
+ // Naming them is the point: "not delivering" alone reads as a whole-study outage, when
+ // the common case is one sensor failing while the rest are fine.
+ line.append(" — ").append(describeFailing(failingTables))
+ .append(failingTables.size() == 1 ? " is" : " are")
+ .append(" not delivering right now");
+ if (pendingRecords > 0) {
+ line.append("; ").append(pendingRecords).append(" record")
+ .append(pendingRecords == 1 ? "" : "s").append(" waiting");
+ }
+ line.append(". Your data is kept on the device until it can be sent.");
+ }
+ return line.toString();
+ }
+
+ /** When the current outage began, or 0 when delivery is healthy. */
+ public static long outageSince(Context context) {
+ return parseLong(Aware.getSetting(context, Aware_Preferences.UPLOAD_OUTAGE_SINCE));
+ }
+
+ /**
+ * The point the research database has been brought up to: the newest row timestamp any table's
+ * upload bookmark reports as delivered, or 0 when nothing has been.
+ *
+ * Read from the bookmarks rather than from the clock time of the last successful upload, because
+ * those diverge exactly when it matters. Draining a backlog, an upload can succeed this minute
+ * while the server is still only caught up to last week — reporting the upload time would claim
+ * the data had arrived. This is the same quantity {@code SensorCollection.lastDeliveredMs} shows
+ * per sensor, maximised across tables, so the two screens cannot disagree.
+ */
+ public static long deliveredUpToMs(Context context) {
+ Cursor markers = context.getContentResolver().query(
+ Aware_Provider.Aware_Sync_Markers.CONTENT_URI,
+ new String[]{Aware_Provider.Aware_Sync_Markers.MARKER_LAST_SYNCED},
+ null, null, null);
+ if (markers == null) return 0L;
+ long newest = 0L;
+ try {
+ int column = markers.getColumnIndex(Aware_Provider.Aware_Sync_Markers.MARKER_LAST_SYNCED);
+ if (column < 0) return 0L;
+ while (markers.moveToNext()) {
+ long delivered = markers.getLong(column);
+ if (delivered > newest) newest = delivered;
+ }
+ } finally {
+ markers.close();
+ }
+ return newest;
+ }
+
+ /** Why delivery is currently failing, or an empty string when it is not. */
+ public static String outageReason(Context context) {
+ String reason = Aware.getSetting(context, Aware_Preferences.UPLOAD_OUTAGE_REASON);
+ return reason == null ? "" : reason;
+ }
+
+ private static boolean alreadyNotified(Context context) {
+ return "true".equals(Aware.getSetting(context, Aware_Preferences.UPLOAD_OUTAGE_NOTIFIED));
+ }
+
+ private static long parseLong(String value) {
+ if (value == null || value.trim().isEmpty()) return 0L;
+ try {
+ return Long.parseLong(value.trim());
+ } catch (NumberFormatException e) {
+ return 0L;
+ }
+ }
+}
diff --git a/aware-core/src/main/java/com/aware/utils/UtcTime.java b/aware-core/src/main/java/com/aware/utils/UtcTime.java
new file mode 100644
index 00000000..f9abfdf0
--- /dev/null
+++ b/aware-core/src/main/java/com/aware/utils/UtcTime.java
@@ -0,0 +1,82 @@
+package com.aware.utils;
+
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.Locale;
+import java.util.TimeZone;
+
+/**
+ * UTC formatting for the few places that record a date or a time as text rather than as epoch
+ * milliseconds. Every sensor's {@code timestamp} column holds System.currentTimeMillis(), which is
+ * an absolute UTC-anchored instant, so these helpers keep the text-valued fields on the same footing
+ * and a researcher reads one timezone across the whole dataset. Localising back to what the
+ * participant saw on their screen is done with the Timezone sensor, which is the only sensor that
+ * records a timezone-dependent value.
+ *
+ * SimpleDateFormat rather than java.time because minSdkVersion is 24 and core library desugaring is
+ * not enabled.
+ */
+public final class UtcTime {
+
+ /**
+ * ISO-8601 instant in UTC, e.g. {@code 2026-08-17T14:30:00Z}.
+ */
+ public static final String ISO_INSTANT = "yyyy-MM-dd'T'HH:mm:ss'Z'";
+
+ /**
+ * ISO-8601 basic format, for filenames where {@code :} is not portable across filesystems.
+ */
+ public static final String ISO_INSTANT_BASIC = "yyyyMMdd'T'HHmmss'Z'";
+
+ private UtcTime() {
+ }
+
+ private static SimpleDateFormat utcFormat(String pattern) {
+ SimpleDateFormat format = new SimpleDateFormat(pattern, Locale.US);
+ format.setTimeZone(TimeZone.getTimeZone("UTC"));
+ return format;
+ }
+
+ /**
+ * Formats an epoch-millisecond timestamp as a UTC ISO-8601 instant.
+ */
+ public static String instant(long epochMillis) {
+ return utcFormat(ISO_INSTANT).format(new Date(epochMillis));
+ }
+
+ /**
+ * Formats an epoch-millisecond timestamp for use inside a filename.
+ */
+ public static String fileStamp(long epochMillis) {
+ return utcFormat(ISO_INSTANT_BASIC).format(new Date(epochMillis));
+ }
+
+ /**
+ * Converts a date-and-time the participant picked -- held in a Calendar on the device's local
+ * timezone -- into the UTC instant it denotes. Seconds and milliseconds are dropped because the
+ * pickers only offer minute resolution, so whatever sits in those fields came from the moment
+ * the dialog opened rather than from the participant.
+ */
+ public static String pickedDateTime(Calendar picked) {
+ Calendar instant = (Calendar) picked.clone();
+ instant.set(Calendar.SECOND, 0);
+ instant.set(Calendar.MILLISECOND, 0);
+ return instant(instant.getTimeInMillis());
+ }
+
+ /**
+ * Converts a date-only pick into midnight UTC on the same calendar day.
+ *
+ * A date-only answer names a day, not an instant, so the year/month/day fields are re-anchored in
+ * UTC instead of being converted from local time. Converting would move the answer onto the
+ * neighbouring day for any participant whose local offset crosses midnight -- the picked day is
+ * the datum here, and it survives a round trip to any timezone only when it sits at 00:00:00Z.
+ */
+ public static String pickedDate(Calendar picked) {
+ Calendar utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
+ utc.clear();
+ utc.set(picked.get(Calendar.YEAR), picked.get(Calendar.MONTH), picked.get(Calendar.DAY_OF_MONTH));
+ return instant(utc.getTimeInMillis());
+ }
+}
diff --git a/aware-core/src/main/java/com/aware/utils/Webservice.java b/aware-core/src/main/java/com/aware/utils/Webservice.java
new file mode 100644
index 00000000..6aef17f3
--- /dev/null
+++ b/aware-core/src/main/java/com/aware/utils/Webservice.java
@@ -0,0 +1,163 @@
+package com.aware.utils;
+
+import android.content.Context;
+import android.util.Log;
+
+import com.aware.Aware;
+import com.aware.Aware_Preferences;
+
+import org.json.JSONArray;
+
+import java.util.Hashtable;
+
+/**
+ * Uploading through the study's webservice instead of opening the database.
+ *
+ * The counterpart to {@link Jdbc}, and deliberately the same contract: a boolean
+ * that means "the server acknowledged these rows", so a caller can keep its rows
+ * on false without knowing which path carried them. Which path a study uses is
+ * {@link #enabled(Context)}, read from the config the phone already holds.
+ *
+ * What the server expects is the legacy AWARE webservice protocol the micro-server
+ * implements: a form POST to {@code //insert} carrying
+ * {@code device_id} and {@code data}, where {@code data} is the same JSON array of
+ * rows the JDBC path would have inserted. The response body is not parsed --- the
+ * status code is the acknowledgement, and {@link Http#dataPOST} already returns
+ * null for anything that is not 200.
+ *
+ * Why this exists on the server's terms rather than the phone's: the phone holds no
+ * database credential on this path, so the address it is given is a study URL and
+ * nothing else. That is what lets the database stay private, and it is the whole
+ * reason for preferring this path where a network allows it.
+ */
+public class Webservice {
+
+ private static final String TAG = "AWARE::Webservice";
+
+ /** Matches Jdbc's fast-fail budget, so a study exit stays responsive either way. */
+ private static final int FAST_FAIL_SECONDS = 10;
+
+ /** The ordinary upload timeout, in milliseconds. */
+ private static final int TIMEOUT_MS = 60 * 1000;
+
+ private Webservice() {}
+
+ /**
+ * Whether this study uploads through the webservice rather than the database.
+ *
+ * Read from {@code status_webservice}, which the study config carries and the
+ * server derives from the study's declared dataflow. A phone whose config
+ * predates that field reads false and keeps using the database, which is what
+ * it was already doing.
+ */
+ public static boolean enabled(Context context) {
+ return "true".equals(Aware.getSetting(context, Aware_Preferences.STATUS_WEBSERVICE));
+ }
+
+ /**
+ * The endpoint a table's rows are posted to.
+ *
+ * Built from {@code webservice_server}, which on this path is the study URL the
+ * server hands out; the table and the operation are appended exactly as the
+ * legacy protocol expects. Empty when no study URL is set, which callers treat
+ * as "not configured" rather than guessing at a host.
+ */
+ public static String insertUrl(Context context, String table) {
+ String server = Aware.getSetting(context, Aware_Preferences.WEBSERVICE_SERVER);
+ if (server == null || server.trim().length() == 0) return "";
+ String base = server.trim();
+ while (base.endsWith("/")) base = base.substring(0, base.length() - 1);
+ return base + "/" + table + "/insert";
+ }
+
+ /**
+ * Post a batch of rows, and say whether the server took them.
+ *
+ * @return true when the server acknowledged the batch; false when it did not, in
+ * which case the caller keeps its rows exactly as it would on a failed
+ * database insert.
+ */
+ public static boolean insertData(Context context, String table, JSONArray rows) {
+ return post(context, table, rows, TIMEOUT_MS);
+ }
+
+ /**
+ * The same upload on a short budget, for a caller that must not block.
+ *
+ * Mirrors {@link Jdbc#insertDataFastFail}: leaving a study has to stay
+ * responsive even when the server cannot be reached, so this bounds the wait
+ * rather than inheriting the default socket timeout.
+ */
+ public static boolean insertDataFastFail(Context context, String table, JSONArray rows,
+ int timeoutSeconds) {
+ int seconds = timeoutSeconds > 0 ? timeoutSeconds : FAST_FAIL_SECONDS;
+ return post(context, table, rows, seconds * 1000);
+ }
+
+ /**
+ * Whether the study's webservice answers at all.
+ *
+ * The counterpart to {@link Jdbc#probeConnection}, and necessarily coarser: a
+ * database refuses a bad credential distinctly, while this path has no
+ * credential to refuse. So reachable or not is the whole answer, and a caller
+ * that wanted to tell "wrong password" from "wrong host" cannot on this path.
+ */
+ public static boolean reachable(Context context) {
+ return reachable(Aware.getSetting(context, Aware_Preferences.WEBSERVICE_SERVER));
+ }
+
+ /**
+ * The same question about a URL that is not the stored one yet.
+ *
+ * Join-time validation needs this: the config is being checked before it is
+ * adopted, so the setting still holds the previous study's address or none.
+ */
+ public static boolean reachable(String server) {
+ if (server == null || server.trim().length() == 0) return false;
+ Http http = new Http();
+ http.setTimeout(FAST_FAIL_SECONDS * 1000);
+ return http.dataGET(server.trim(), true) != null;
+ }
+
+ private static boolean post(Context context, String table, JSONArray rows, int timeoutMs) {
+ if (rows == null || rows.length() == 0) return true;
+
+ // Through the same resolver the sync adapter uses, so a device whose id
+ // lives only in the mirror is not treated as having none.
+ String deviceId = DeviceId.trimToEmpty(Aware.getDeviceID(context));
+ if (deviceId.isEmpty()) {
+ // The server refuses a batch that names no device, and it is right to:
+ // rows stored against no device belong to nobody. Failing here keeps them
+ // on the phone until the id resolves rather than spending a request to be
+ // told the same thing.
+ Log.w(TAG, "Not uploading '" + table + "': this install has no device_id yet.");
+ return false;
+ }
+
+ String url = insertUrl(context, table);
+ if (url.length() == 0) {
+ Log.w(TAG, "Not uploading '" + table + "': no webservice_server is set.");
+ return false;
+ }
+
+ Hashtable form = new Hashtable<>();
+ form.put("device_id", deviceId);
+ form.put("data", rows.toString());
+
+ Http http = new Http();
+ http.setTimeout(timeoutMs);
+ // The body is never logged here. It is the participant's data, and a log is
+ // not where it belongs.
+ String answer = http.dataPOST(url, form, true);
+ if (answer == null) {
+ Log.w(TAG, "Upload of '" + table + "' was not acknowledged (" + rows.length()
+ + " row(s)).");
+ return false;
+ }
+
+ if (Aware.DEBUG) {
+ Log.d(TAG, "Uploaded " + rows.length() + " row(s) to '" + table + "'.");
+ }
+ return true;
+ }
+}
diff --git a/aware-core/src/main/res/values/arrays.xml b/aware-core/src/main/res/values/arrays.xml
index 498ffdb0..d15faeab 100644
--- a/aware-core/src/main/res/values/arrays.xml
+++ b/aware-core/src/main/res/values/arrays.xml
@@ -46,4 +46,234 @@
- Fast (20ms)
- Fastest (0ms)
+
+
+ - 20000
+ - 50000
+ - 100000
+ - 1000000
+
+
+ - Fall / impact detection (50 Hz)
+ - Activity / transport-mode classification (20 Hz)
+ - Coarse mobility type (10 Hz)
+ - Long-term, battery-conservative (1 Hz)
+
+
+
+ - 50000
+ - 200000
+ - 1000000
+
+
+ - Indoor positioning / heading fusion (20 Hz)
+ - Orientation / compass context (5 Hz)
+ - Minimal footprint (1 Hz)
+
+
+
+ - 200000
+ - 1000000
+ - 30000000
+
+
+ - Fine environmental transitions (5 Hz)
+ - Circadian / screen-exposure proxy (1 Hz)
+ - Long-term passive monitoring (every 30s)
+
+
+
+ - 200000
+ - 1000000
+ - 30000000
+
+
+ - Real-time floor / vertical-transition detection (5 Hz)
+ - Coarse elevation / weather context (1 Hz)
+ - Minimal footprint (every 30s)
+
+
+
+ - 200000
+ - 1000000
+
+
+ - Real-time call / pocket-state detection (5 Hz)
+ - Standard context logging (1 Hz)
+
+
+
+ - 10000000
+ - 60000000
+
+
+ - Standard environmental logging (every 10s)
+ - Minimal footprint (every 60s)
+
+
+
+ - 30
+ - 180
+ - 600
+
+
+ - Trip / transportation-mode capture (30s)
+ - Standard mobility-pattern capture (3 min)
+ - Battery-conservative (10 min)
+
+
+
+ - 15
+ - 60
+ - 300
+
+
+ - Real-time co-location / indoor positioning (15s)
+ - Standard social-proximity detection (60s)
+ - Battery-conservative (5 min)
+
+
+
+ - 1
+ - 10
+ - 60
+
+
+ - Diagnostic / real-time load monitoring (1s)
+ - Standard housekeeping (10s)
+ - Minimal footprint (60s)
+
+
+
+
+
+
+ - 0
+ - 0.05
+ - 0.3
+ - 1.0
+
+
+ - Record every sample (no filtering)
+ - Drop sensor noise only (0.05 m/s²)
+ - Movement vs. stillness (0.3 m/s²)
+ - Pronounced motion only (1.0 m/s²)
+
+
+
+
+ - 0
+ - 0.05
+ - 0.2
+ - 1.0
+
+
+ - Record every sample (no filtering)
+ - Drop sensor noise only (0.05 m/s²)
+ - Tilt changes (0.2 m/s², about 1°)
+ - Large reorientation only (1.0 m/s², about 6°)
+
+
+
+
+ - 0
+ - 0.01
+ - 0.05
+ - 0.2
+
+
+ - Record every sample (no filtering)
+ - Drop sensor noise only (0.01 rad/s)
+ - Deliberate rotation (0.05 rad/s, about 3°/s)
+ - Brisk rotation only (0.2 rad/s, about 11°/s)
+
+
+
+
+ - 0
+ - 0.005
+ - 0.02
+ - 0.1
+
+
+ - Record every sample (no filtering)
+ - Drop sensor noise only (0.005, about 0.6°)
+ - Orientation changes (0.02, about 2°)
+ - Coarse orientation states only (0.1, about 11°)
+
+
+
+
+ - 0
+ - 1
+ - 3
+ - 10
+
+
+ - Record every sample (no filtering)
+ - Drop sensor noise only (1 µT)
+ - Heading and environment changes (3 µT)
+ - Strong disturbances only (10 µT)
+
+
+
+
+ - 0
+ - 0.02
+ - 0.05
+ - 0.4
+
+
+ - Record every sample (no filtering)
+ - Drop sensor noise only (0.02 hPa)
+ - Vertical movement (0.05 hPa, about 0.4 m)
+ - Floor transitions only (0.4 hPa, about one floor)
+
+
+
+
+ - 0
+ - 1
+ - 10
+ - 50
+
+
+ - Record every sample (best for circadian / sleep)
+ - Drop sensor noise only (1 lux)
+ - Indoor lighting changes (10 lux)
+ - Room and indoor/outdoor transitions only (50 lux)
+
+
+
+ - 0
+ - 0.1
+ - 0.5
+
+
+ - Record every sample (no filtering)
+ - Drop sensor noise only (0.1 °C)
+ - Environmental changes (0.5 °C)
+
+
+
+
+ - 0
+
+
+ - Record every near/far change (recommended)
+
\ No newline at end of file
diff --git a/aware-core/src/main/res/values/strings.xml b/aware-core/src/main/res/values/strings.xml
index 68f5a263..a5ebaf4f 100644
--- a/aware-core/src/main/res/values/strings.xml
+++ b/aware-core/src/main/res/values/strings.xml
@@ -6,6 +6,8 @@
General AWARE notifications
Non-interruptive AWARE notifications
AWARE datasync notifications
+ AWARE background collection
+ Shows when AWARE is collecting study data in the background
Allow read-only access to the local data.
Allow read and write access to the local data.
@@ -22,9 +24,17 @@
Please, recharge your phone as soon as possible.
Study Sync
Your study configuration was updated.
+ Study password required
+ Data collection is paused. Tap to enter the study password.
+ Your study changed
+ New sensors need your consent. Tap to review what the study collects.
+ Tap to see which sensors changed.
+ Study data is not being sent
+ Your data is still being collected and kept safely on this device, but it is not reaching the study server right now. Tap for details. Checking your internet connection may help.
Join Study by link
Team
QRCode
+ Or scan the study QR code
Sync
Study
Other
@@ -40,7 +50,7 @@
Sign Up!
Install
AWARE
- Data collection active
+ Collecting study data in the background
Sync
ScreenText
@@ -77,4 +87,4 @@
Please wait...
Screenshot
-
\ No newline at end of file
+
diff --git a/aware-core/src/main/res/xml/aware_preferences.xml b/aware-core/src/main/res/xml/aware_preferences.xml
index c4728de5..8844bd70 100644
--- a/aware-core/src/main/res/xml/aware_preferences.xml
+++ b/aware-core/src/main/res/xml/aware_preferences.xml
@@ -47,22 +47,23 @@
android:title="Activate" />
-
+ android:summary="%s"
+ android:title="Sensitivity" />
-
+ android:summary="%s"
+ android:title="Sensitivity" />
-
-
+ android:summary="%s"
+ android:title="Sensitivity" />
-
+ android:summary="%s"
+ android:title="Sensitivity" />
-
-
-
+ android:summary="%s"
+ android:title="Sensitivity" />
-
+ android:summary="%s"
+ android:title="Sensitivity" />
-
+ android:summary="%s"
+ android:title="Sensitivity" />
-
-
+ android:summary="%s"
+ android:title="Sensitivity" />
-
+ android:summary="%s"
+ android:title="Sensitivity" />
-
+ android:summary="%s"
+ android:title="Sensitivity" />
-
diff --git a/aware-core/src/test/java/com/aware/AwareSettingsParsingTest.java b/aware-core/src/test/java/com/aware/AwareSettingsParsingTest.java
new file mode 100644
index 00000000..6fc12e63
--- /dev/null
+++ b/aware-core/src/test/java/com/aware/AwareSettingsParsingTest.java
@@ -0,0 +1,79 @@
+package com.aware;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+
+/**
+ * Regression test for the crash class fixed across every sensor's onStartCommand(): a study
+ * setting (frequency/threshold) parsed directly with Integer.parseInt/Long.parseLong/Double.parseDouble
+ * throws NumberFormatException and crashes the sensor service if the setting is empty (e.g.
+ * transiently mid-config-apply) or malformed (e.g. a bad study config). Aware.getSettingAsInt/
+ * Long/Double wrap the parse with a fallback default instead; these tests cover the Context-free
+ * parse core (parseIntOrDefault/parseLongOrDefault/parseDoubleOrDefault) directly, so they don't
+ * need a real or mocked Android Context.
+ */
+public class AwareSettingsParsingTest {
+
+ @Test
+ public void validInt_isParsed() {
+ assertEquals(42, Aware.parseIntOrDefault("42", 200000));
+ }
+
+ @Test
+ public void emptyInt_fallsBackToDefault() {
+ assertEquals(200000, Aware.parseIntOrDefault("", 200000));
+ }
+
+ @Test
+ public void malformedInt_fallsBackToDefault() {
+ assertEquals(200000, Aware.parseIntOrDefault("not_a_number", 200000));
+ }
+
+ @Test
+ public void nullInt_fallsBackToDefault() {
+ assertEquals(200000, Aware.parseIntOrDefault(null, 200000));
+ }
+
+ @Test
+ public void validLong_isParsed() {
+ assertEquals(60L, Aware.parseLongOrDefault("60", 30L));
+ }
+
+ @Test
+ public void emptyLong_fallsBackToDefault() {
+ assertEquals(30L, Aware.parseLongOrDefault("", 30L));
+ }
+
+ @Test
+ public void malformedLong_fallsBackToDefault() {
+ assertEquals(30L, Aware.parseLongOrDefault("not_a_number", 30L));
+ }
+
+ @Test
+ public void nullLong_fallsBackToDefault() {
+ assertEquals(30L, Aware.parseLongOrDefault(null, 30L));
+ }
+
+ @Test
+ public void validDouble_isParsed() {
+ assertEquals(1.5, Aware.parseDoubleOrDefault("1.5", 0.0), 0.0);
+ }
+
+ @Test
+ public void emptyDouble_fallsBackToDefault() {
+ assertEquals(0.0, Aware.parseDoubleOrDefault("", 0.0), 0.0);
+ }
+
+ @Test
+ public void malformedDouble_fallsBackToDefault() {
+ assertEquals(0.0, Aware.parseDoubleOrDefault("not_a_number", 0.0), 0.0);
+ }
+
+ @Test
+ public void nullDouble_fallsBackToDefault() {
+ // Double.parseDouble(null) throws NullPointerException rather than NumberFormatException —
+ // this is the case that would have slipped through a catch (NumberFormatException e) only.
+ assertEquals(0.0, Aware.parseDoubleOrDefault(null, 0.0), 0.0);
+ }
+}
diff --git a/aware-core/src/test/java/com/aware/SensorFrequencyUnitsTest.java b/aware-core/src/test/java/com/aware/SensorFrequencyUnitsTest.java
new file mode 100644
index 00000000..9c4b75a5
--- /dev/null
+++ b/aware-core/src/test/java/com/aware/SensorFrequencyUnitsTest.java
@@ -0,0 +1,85 @@
+package com.aware;
+
+import static org.junit.Assert.assertEquals;
+
+import com.aware.utils.SensorTimeUnits;
+
+import org.junit.Test;
+
+/**
+ * Locks in the time unit that each user-configurable FREQUENCY_* setting is expressed in, and
+ * the conversion (if any) each sensor applies before handing the value to the underlying Android
+ * API. This was prompted by a real mismatch: Aware_Preferences.FREQUENCY_APPLICATIONS was
+ * documented as "seconds (default = 30)" but Applications.java actually fed it straight into
+ * Scheduler.Schedule#setInterval(long), which takes minutes, with a real default of 0 -- so the
+ * doc comment described neither the unit nor the default the code used.
+ *
+ * Every sensor routes its "raw setting -> value passed to the Android API" step through
+ * SensorTimeUnits, so it can be verified here without a real or mocked Android Context. The
+ * conversions are identical across sensors, so they live in one shared utility -- reusable by
+ * any future sensor rather than re-derived -- and are tested once rather than once per sensor
+ * class. The reference table is:
+ *
+ *
+ * Setting Unit Conversion Verified via
+ * --------------------------------------------------------------------------------------------------
+ * FREQUENCY_ACCELEROMETER microseconds none (native unit) SensorTimeUnits.samplingPeriodUs
+ * FREQUENCY_GRAVITY microseconds none (native unit) SensorTimeUnits.samplingPeriodUs
+ * FREQUENCY_GYROSCOPE microseconds none (native unit) SensorTimeUnits.samplingPeriodUs
+ * FREQUENCY_LIGHT microseconds none (native unit) SensorTimeUnits.samplingPeriodUs
+ * FREQUENCY_LINEAR_ACCELEROMETER microseconds none (native unit) SensorTimeUnits.samplingPeriodUs
+ * FREQUENCY_MAGNETOMETER microseconds none (native unit) SensorTimeUnits.samplingPeriodUs
+ * FREQUENCY_BAROMETER microseconds none (native unit) SensorTimeUnits.samplingPeriodUs
+ * FREQUENCY_PROXIMITY microseconds none (native unit) SensorTimeUnits.samplingPeriodUs
+ * FREQUENCY_ROTATION microseconds none (native unit) SensorTimeUnits.samplingPeriodUs
+ * FREQUENCY_TEMPERATURE microseconds none (native unit) SensorTimeUnits.samplingPeriodUs
+ * FREQUENCY_LOCATION_GPS seconds * 1000 -> millis SensorTimeUnits.secondsToMillis
+ * FREQUENCY_LOCATION_NETWORK seconds * 1000 -> millis SensorTimeUnits.secondsToMillis
+ * FREQUENCY_BLUETOOTH (start) seconds * 1000 -> millis SensorTimeUnits.secondsToMillis
+ * FREQUENCY_BLUETOOTH (repeat) seconds * 2000 -> millis SensorTimeUnits.doubleSecondsToMillis
+ * FREQUENCY_WIFI seconds * 1000 -> millis SensorTimeUnits.secondsToMillis
+ * FREQUENCY_NETWORK_TRAFFIC seconds * 1000 -> millis SensorTimeUnits.secondsToMillis
+ * FREQUENCY_PROCESSOR seconds * 1000 -> millis SensorTimeUnits.secondsToMillis
+ * FREQUENCY_APPLICATIONS minutes none (Scheduler unit) SensorTimeUnits.minutesAsIs
+ *
+ */
+public class SensorFrequencyUnitsTest {
+
+ // --- Microsecond sensors: SensorManager.registerListener()'s native sampling-period unit,
+ // so the setting passes through unchanged. Covers the documented SENSOR_DELAY_* presets too.
+ // Shared by Accelerometer, Gravity, Gyroscope, Light, LinearAccelerometer, Magnetometer,
+ // Barometer, Proximity, Rotation and Temperature.
+
+ @Test
+ public void samplingPeriodUs_passesThroughUnchanged() {
+ assertEquals(200000, SensorTimeUnits.samplingPeriodUs(200000)); // normal (default)
+ assertEquals(0, SensorTimeUnits.samplingPeriodUs(0)); // fastest
+ assertEquals(20000, SensorTimeUnits.samplingPeriodUs(20000)); // game
+ assertEquals(60000, SensorTimeUnits.samplingPeriodUs(60000)); // UI
+ }
+
+ // --- Seconds-based polling sensors: setting is in seconds, Android API wants milliseconds.
+ // Shared by Locations (GPS + network), Bluetooth (scan start), WiFi and Processor.
+
+ @Test
+ public void secondsToMillis_convertsEachSensorsDocumentedDefault() {
+ assertEquals(180_000L, SensorTimeUnits.secondsToMillis(180)); // Locations GPS default
+ assertEquals(300_000L, SensorTimeUnits.secondsToMillis(300)); // Locations network default
+ assertEquals(60_000L, SensorTimeUnits.secondsToMillis(60)); // Bluetooth / WiFi default
+ assertEquals(10_000L, SensorTimeUnits.secondsToMillis(10)); // Processor default
+ assertEquals(30_000L, SensorTimeUnits.secondsToMillis(30)); // Network traffic default
+ }
+
+ @Test
+ public void doubleSecondsToMillis_isTwiceTheFrequencyInMillis() {
+ assertEquals(120_000L, SensorTimeUnits.doubleSecondsToMillis(60)); // Bluetooth's repeat interval, documented default
+ }
+
+ // --- Minutes-based scheduling: Scheduler.Schedule#setInterval() already takes minutes.
+
+ @Test
+ public void minutesAsIs_passesThroughUnchanged() {
+ assertEquals(0L, SensorTimeUnits.minutesAsIs(0)); // Applications' real default: disabled
+ assertEquals(30L, SensorTimeUnits.minutesAsIs(30));
+ }
+}
diff --git a/aware-core/src/test/java/com/aware/TrafficTest.java b/aware-core/src/test/java/com/aware/TrafficTest.java
new file mode 100644
index 00000000..2af47650
--- /dev/null
+++ b/aware-core/src/test/java/com/aware/TrafficTest.java
@@ -0,0 +1,26 @@
+package com.aware;
+
+import android.net.TrafficStats;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+
+public class TrafficTest {
+
+ @Test
+ public void counterDelta_returnsIncreaseAndRejectsCounterReset() {
+ assertEquals(25, Traffic.counterDelta(125, 100));
+ assertEquals(0, Traffic.counterDelta(90, 100));
+ }
+
+ @Test
+ public void nonMobileCounter_subtractsMobileTraffic() {
+ assertEquals(700, Traffic.nonMobileCounter(1_000, 300));
+ }
+
+ @Test
+ public void nonMobileCounter_treatsUnsupportedMobileCounterAsZero() {
+ assertEquals(1_000, Traffic.nonMobileCounter(1_000, TrafficStats.UNSUPPORTED));
+ }
+}
diff --git a/aware-core/src/test/java/com/aware/providers/AwareSchemaTest.java b/aware-core/src/test/java/com/aware/providers/AwareSchemaTest.java
new file mode 100644
index 00000000..97279de8
--- /dev/null
+++ b/aware-core/src/test/java/com/aware/providers/AwareSchemaTest.java
@@ -0,0 +1,105 @@
+package com.aware.providers;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.aware.providers.Aware_Provider.Aware_Device;
+import com.aware.providers.Aware_Provider.Aware_Log;
+import com.aware.providers.Aware_Provider.Aware_Studies;
+import com.aware.providers.Aware_Provider.Aware_Sync_Markers;
+import com.aware.utils.DeviceFacts;
+
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Ties the column names the framework writes to the schema it creates.
+ *
+ * These are the mismatches that stay quiet until they cost data: a column compared but absent reads
+ * as changed on every check, and a column written but absent from the remote table fails the whole
+ * upload batch for that table. Both are plain string arrays, so both are checkable here.
+ */
+public class AwareSchemaTest {
+
+ private static final List TABLES = Arrays.asList(Aware_Provider.DATABASE_TABLES);
+
+ private static String schemaOf(String table) {
+ return Aware_Provider.TABLES_FIELDS[TABLES.indexOf(table)];
+ }
+
+ @Test
+ public void everyComparedDeviceColumnExistsInTheDeviceTable() {
+ // A compared column the table lacks is read back as null on every check, so the stored row
+ // never matches the device and gets rewritten on every service start — each rewrite reaching
+ // the server as a device change that did not happen.
+ String schema = schemaOf("aware_device");
+ for (String column : DeviceFacts.COMPARED_COLUMNS) {
+ assertTrue(column + " is compared but missing from aware_device",
+ schema.contains(column + " text"));
+ }
+ }
+
+ @Test
+ public void theDeviceTableCarriesNoUnsupportedHardwareColumns() {
+ // Each of these either stopped being reported by Android, or repeated what another column
+ // already said. A column reinstated here reaches the research database as a column the remote
+ // table does not have, which fails the whole upload batch for aware_device.
+ String schema = schemaOf("aware_device");
+ for (String column : new String[]{"brand", "serial", "release_type"}) {
+ assertFalse(column + " is declared in aware_device again",
+ schema.contains(column + " text"));
+ }
+ }
+
+ @Test
+ public void theDeviceTableCarriesTheParticipantFacingLabel() {
+ assertTrue(schemaOf("aware_device").contains(Aware_Device.LABEL + " text"));
+ }
+
+ @Test
+ public void theDeviceTableHoldsOneRowPerDevice() {
+ // get_device_info relies on this to update in place rather than accumulate rows.
+ assertTrue(schemaOf("aware_device").contains("UNIQUE(" + Aware_Device.DEVICE_ID + ")"));
+ }
+
+ @Test
+ public void theLogCarriesItsType() {
+ assertTrue(schemaOf("aware_log").contains(Aware_Log.LOG_TYPE + " text"));
+ }
+
+ @Test
+ public void studiesCarryTheirLastUpdate() {
+ assertTrue(schemaOf("aware_studies").contains(Aware_Studies.STUDY_UPDATED + " real"));
+ }
+
+ @Test
+ public void thereIsOneSyncMarkerPerTable() {
+ // The marker store answers "how far did this table get" with a single row, so writing a
+ // marker has to supersede that table's previous one rather than pile up beside it.
+ assertTrue(schemaOf("aware_sync_markers")
+ .contains("UNIQUE(" + Aware_Sync_Markers.MARKER_TABLE + ")"));
+ }
+
+ @Test
+ public void aSyncMarkerCarriesTheRowItStoppedAt() {
+ // The cursor an upload resumes from is a row id: it is distinct per row and assigned in
+ // insertion order, so a batch resumes on the row after the last one the server took.
+ assertTrue(schemaOf("aware_sync_markers")
+ .contains(Aware_Sync_Markers.MARKER_LAST_ID + " integer"));
+ }
+
+ @Test
+ public void everyDeclaredTableHasASchema() {
+ assertTrue(Aware_Provider.DATABASE_TABLES.length == Aware_Provider.TABLES_FIELDS.length);
+ }
+
+ @Test
+ public void theMarkerTableIsNotOneOfTheUploadedThree() {
+ // Aware_Sync uploads indices 0, 3 and 4. Markers are the phone's own bookkeeping and adding
+ // them to that set would send them to the server and subject them to its retention.
+ int markers = TABLES.indexOf("aware_sync_markers");
+ assertTrue(markers != 0 && markers != 3 && markers != 4);
+ }
+}
diff --git a/aware-core/src/test/java/com/aware/providers/SensorSchemaTest.java b/aware-core/src/test/java/com/aware/providers/SensorSchemaTest.java
new file mode 100644
index 00000000..93c4c901
--- /dev/null
+++ b/aware-core/src/test/java/com/aware/providers/SensorSchemaTest.java
@@ -0,0 +1,41 @@
+package com.aware.providers;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+/**
+ * Documents which legacy label columns are redundant and which still carry sensor state.
+ */
+public class SensorSchemaTest {
+
+ private static void assertHasNoLabel(String table, String schema) {
+ assertFalse(table + " still declares the unused label column",
+ schema.contains("label text"));
+ }
+
+ @Test
+ public void physicalSensorSamplesDoNotCarryUnusedLabels() {
+ assertHasNoLabel("accelerometer", Accelerometer_Provider.TABLES_FIELDS[1]);
+ assertHasNoLabel("barometer", Barometer_Provider.TABLES_FIELDS[1]);
+ assertHasNoLabel("gravity", Gravity_Provider.TABLES_FIELDS[1]);
+ assertHasNoLabel("gyroscope", Gyroscope_Provider.TABLES_FIELDS[1]);
+ assertHasNoLabel("light", Light_Provider.TABLES_FIELDS[1]);
+ assertHasNoLabel("linear_accelerometer",
+ Linear_Accelerometer_Provider.TABLES_FIELDS[1]);
+ assertHasNoLabel("magnetometer", Magnetometer_Provider.TABLES_FIELDS[1]);
+ assertHasNoLabel("proximity", Proximity_Provider.TABLES_FIELDS[1]);
+ assertHasNoLabel("rotation", Rotation_Provider.TABLES_FIELDS[1]);
+ assertHasNoLabel("temperature", Temperature_Provider.TABLES_FIELDS[1]);
+ }
+
+ @Test
+ public void semanticEventLabelsRemainAvailable() {
+ // These similarly named columns are not arbitrary annotations: Bluetooth groups a scan,
+ // while Locations and Wi-Fi record disabled/out-of-bounds sensor state.
+ assertTrue(Bluetooth_Provider.TABLES_FIELDS[1].contains("label text"));
+ assertTrue(Locations_Provider.TABLES_FIELDS[0].contains("label text"));
+ assertTrue(WiFi_Provider.TABLES_FIELDS[0].contains("label text"));
+ }
+}
diff --git a/aware-core/src/test/java/com/aware/ui/PermissionSequenceTest.java b/aware-core/src/test/java/com/aware/ui/PermissionSequenceTest.java
new file mode 100644
index 00000000..26ac9c17
--- /dev/null
+++ b/aware-core/src/test/java/com/aware/ui/PermissionSequenceTest.java
@@ -0,0 +1,236 @@
+package com.aware.ui;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+import org.junit.Test;
+
+/**
+ * Unit tests for PermissionSequence — the pure core of PermissionsHandler's request flow. They pin
+ * down the two behaviours a participant actually sees: enabling one permission of a group also
+ * satisfies its sibling (so the sibling is never prompted again), and every permission is asked for at
+ * most once and in order; and the guard behind the "Allow ..." dialog loop bug — a declined permission
+ * must never ask the caller service to restart, since that just relaunches the prompt forever.
+ *
+ * The OS is faked with a mutable set of granted permissions. Granting a group (e.g. FINE together
+ * with COARSE) is modelled by adding both to that set at once, exactly as the platform reports it.
+ */
+public class PermissionSequenceTest {
+
+ private static final String FINE = "android.permission.ACCESS_FINE_LOCATION";
+ private static final String COARSE = "android.permission.ACCESS_COARSE_LOCATION";
+ private static final String A = "perm.A";
+ private static final String B = "perm.B";
+ private static final String C = "perm.C";
+
+ /** A stand-in for the OS grant state: whatever has been "granted" reads back as granted. */
+ private static class FakeGrants implements PermissionSequence.GrantChecker {
+ private final Set granted = new HashSet<>();
+
+ FakeGrants(String... initiallyGranted) {
+ granted.addAll(Arrays.asList(initiallyGranted));
+ }
+
+ void grant(String... permissions) {
+ granted.addAll(Arrays.asList(permissions));
+ }
+
+ @Override
+ public boolean isGranted(String permission) {
+ return granted.contains(permission);
+ }
+ }
+
+ @Test
+ public void grantingLocation_skipsItsAlreadyGrantedSibling() {
+ // Location is one runtime group: the OS grants COARSE the moment FINE is allowed. After the
+ // participant allows the first location permission the sibling must never be prompted again.
+ FakeGrants grants = new FakeGrants();
+ PermissionSequence seq = new PermissionSequence(Arrays.asList(FINE, COARSE), grants);
+
+ assertEquals(FINE, seq.nextToRequest());
+ grants.grant(FINE, COARSE); // OS grants the whole group at once
+ seq.onResult(true);
+
+ assertNull("the sibling COARSE is already granted, so it is not prompted", seq.nextToRequest());
+ assertTrue(seq.isDone());
+ assertFalse(seq.anyDenied());
+ assertTrue(seq.shouldRestartService());
+ }
+
+ @Test
+ public void eachPermissionPromptedExactlyOnceInOrder() {
+ // "Every requested permission is called reasonably": A, then B, then C, each once, then done —
+ // no repeats and no permission left un-prompted.
+ FakeGrants grants = new FakeGrants();
+ PermissionSequence seq = new PermissionSequence(Arrays.asList(A, B, C), grants);
+
+ assertEquals(A, seq.nextToRequest());
+ grants.grant(A);
+ seq.onResult(true);
+
+ assertEquals(B, seq.nextToRequest());
+ grants.grant(B);
+ seq.onResult(true);
+
+ assertEquals(C, seq.nextToRequest());
+ grants.grant(C);
+ seq.onResult(true);
+
+ assertNull(seq.nextToRequest());
+ assertTrue(seq.shouldRestartService());
+ }
+
+ @Test
+ public void alreadyGrantedPermissions_areSkipped() {
+ // A and C are already held (e.g. granted for an earlier sensor); only the missing B is prompted.
+ FakeGrants grants = new FakeGrants(A, C);
+ PermissionSequence seq = new PermissionSequence(Arrays.asList(A, B, C), grants);
+
+ assertEquals(B, seq.nextToRequest());
+ grants.grant(B);
+ seq.onResult(true);
+
+ assertNull(seq.nextToRequest());
+ assertTrue(seq.shouldRestartService());
+ }
+
+ @Test
+ public void allPermissionsAlreadyGranted_restartsWithoutPrompting() {
+ // The "permissions are already fine, just (re)start the sensor" path: nothing to ask, and the
+ // service is allowed to start.
+ FakeGrants grants = new FakeGrants(A, B);
+ PermissionSequence seq = new PermissionSequence(Arrays.asList(A, B), grants);
+
+ assertNull(seq.nextToRequest());
+ assertTrue(seq.isDone());
+ assertFalse(seq.anyDenied());
+ assertTrue(seq.shouldRestartService());
+ }
+
+ @Test
+ public void denyingRequiredPermission_doesNotRestartService() {
+ // The loop bug: restarting the service after a denial makes it find the permission still missing
+ // and relaunch the handler — an unbreakable "Allow ..." dialog. A denial must leave it alone.
+ FakeGrants grants = new FakeGrants();
+ PermissionSequence seq = new PermissionSequence(Collections.singletonList(COARSE), grants);
+
+ assertEquals(COARSE, seq.nextToRequest());
+ seq.onResult(false); // denied on the system dialog
+
+ assertNull(seq.nextToRequest());
+ assertTrue(seq.anyDenied());
+ assertFalse(seq.shouldRestartService());
+ }
+
+ @Test
+ public void cancellingPermissionFlow_declinesAllRemainingWithoutRestart() {
+ PermissionSequence seq = new PermissionSequence(
+ Arrays.asList("location", "microphone"),
+ permission -> false);
+
+ assertEquals("location", seq.nextToRequest());
+ seq.cancelRemaining();
+
+ assertTrue(seq.isDone());
+ assertFalse(seq.shouldRestartService());
+ }
+
+ @Test
+ public void skippingWithNotNow_countsAsDenied_andAdvances() {
+ // "Not now" on the rationale skips that permission but still moves the sequence forward, and it
+ // counts as a denial so the service is not restarted.
+ FakeGrants grants = new FakeGrants();
+ PermissionSequence seq = new PermissionSequence(Arrays.asList(A, B), grants);
+
+ assertEquals(A, seq.nextToRequest());
+ seq.onSkipped();
+
+ assertEquals(B, seq.nextToRequest());
+ grants.grant(B);
+ seq.onResult(true);
+
+ assertNull(seq.nextToRequest());
+ assertTrue(seq.anyDenied());
+ assertFalse(seq.shouldRestartService());
+ }
+
+ @Test
+ public void grantingSomeButDenyingOthers_doesNotRestart() {
+ // A granted, B denied: because a required permission is still missing, the service is not asked
+ // to restart.
+ FakeGrants grants = new FakeGrants();
+ PermissionSequence seq = new PermissionSequence(Arrays.asList(A, B), grants);
+
+ assertEquals(A, seq.nextToRequest());
+ grants.grant(A);
+ seq.onResult(true);
+
+ assertEquals(B, seq.nextToRequest());
+ seq.onResult(false);
+
+ assertNull(seq.nextToRequest());
+ assertFalse(seq.shouldRestartService());
+ }
+
+ @Test
+ public void nextToRequest_isStableUntilResultRecorded() {
+ // Querying the next permission repeatedly (e.g. across a config change) must not skip ahead;
+ // only recording a result advances the sequence.
+ PermissionSequence seq = new PermissionSequence(Arrays.asList(A, B), new FakeGrants());
+
+ assertEquals(A, seq.nextToRequest());
+ assertEquals(A, seq.nextToRequest());
+ }
+
+ @Test
+ public void emptyPermissions_isDoneAndRestarts() {
+ PermissionSequence seq = new PermissionSequence(Collections.emptyList(), new FakeGrants());
+
+ assertNull(seq.nextToRequest());
+ assertTrue(seq.isDone());
+ assertTrue(seq.shouldRestartService());
+ }
+
+ @Test
+ public void nullPermissions_isTolerated() {
+ PermissionSequence seq = new PermissionSequence(null, new FakeGrants());
+
+ assertNull(seq.nextToRequest());
+ assertTrue(seq.isDone());
+ assertTrue(seq.shouldRestartService());
+ }
+
+ @Test
+ public void grantedResult_advances() {
+ // A grant just moves on, whatever the rationale flag happens to be.
+ assertEquals(PermissionSequence.ResultAction.ADVANCE,
+ PermissionSequence.actionAfterResult(true, false));
+ assertEquals(PermissionSequence.ResultAction.ADVANCE,
+ PermissionSequence.actionAfterResult(true, true));
+ }
+
+ @Test
+ public void softDenial_stillRepromptable_advances() {
+ // Denied but the OS will still show the dialog again (rationale allowed): no settings detour,
+ // the user can be re-prompted later.
+ assertEquals(PermissionSequence.ResultAction.ADVANCE,
+ PermissionSequence.actionAfterResult(false, true));
+ }
+
+ @Test
+ public void blockedDenial_promptsSettings() {
+ // Denied and the OS will not re-prompt (blocked / "don't ask again", or already blocked so the
+ // dialog never appeared): requesting again is a no-op, so the user must be routed to settings.
+ // This is the case where tapping "Continue" produced no system dialog.
+ assertEquals(PermissionSequence.ResultAction.PROMPT_SETTINGS,
+ PermissionSequence.actionAfterResult(false, false));
+ }
+}
diff --git a/aware-core/src/test/java/com/aware/utils/DatabaseHelperColumnsTest.java b/aware-core/src/test/java/com/aware/utils/DatabaseHelperColumnsTest.java
new file mode 100644
index 00000000..7e649edf
--- /dev/null
+++ b/aware-core/src/test/java/com/aware/utils/DatabaseHelperColumnsTest.java
@@ -0,0 +1,156 @@
+package com.aware.utils;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.aware.providers.Aware_Provider;
+
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Unit tests for the column list an upgrade carries data across.
+ *
+ * This list decides which columns survive a schema change. Reading it from the table definition is
+ * what allows a column to be dropped: anything the new definition omits stays behind with the old
+ * table. Getting it wrong is quiet and expensive — naming a column the new table lacks aborts the
+ * upgrade, which rolls back and leaves the database a version behind the code querying it.
+ */
+public class DatabaseHelperColumnsTest {
+
+ @Test
+ public void readsEveryColumnOfASimpleTable() {
+ List columns = DatabaseHelper.declaredColumns(
+ "_id integer primary key autoincrement,timestamp real default 0,device_id text default ''");
+ assertEquals(3, columns.size());
+ assertEquals("_id", columns.get(0));
+ assertEquals("timestamp", columns.get(1));
+ assertEquals("device_id", columns.get(2));
+ }
+
+ @Test
+ public void aTrailingUniqueConstraintIsNotAColumn() {
+ List columns = DatabaseHelper.declaredColumns(
+ "_id integer primary key autoincrement,device_id text default '',UNIQUE(device_id)");
+ assertEquals(2, columns.size());
+ assertFalse(columns.contains("UNIQUE(device_id)"));
+ assertFalse(columns.contains("UNIQUE"));
+ }
+
+ @Test
+ public void commasInsideAConstraintDoNotSplitIt() {
+ List columns = DatabaseHelper.declaredColumns(
+ "a text default '',b text default '',UNIQUE(a, b)");
+ assertEquals(2, columns.size());
+ assertTrue(columns.contains("a"));
+ assertTrue(columns.contains("b"));
+ }
+
+ @Test
+ public void aColumnTheNewDefinitionOmitsIsNotCarriedOver() {
+ // The intersection below is what an upgrade names in its carry-over INSERT. Naming a column
+ // the new table lacks aborts that INSERT, which rolls back the upgrade and leaves the
+ // database a version behind the code querying it.
+ String before = "_id integer primary key autoincrement,device_id text default '',"
+ + "brand text default '',model text default ''";
+ String after = "_id integer primary key autoincrement,device_id text default '',"
+ + "model text default ''";
+
+ List carried = DatabaseHelper.declaredColumns(before);
+ carried.retainAll(DatabaseHelper.declaredColumns(after));
+
+ assertEquals(3, carried.size());
+ assertTrue(carried.contains("model"));
+ assertFalse("a dropped column must not reach the carry-over", carried.contains("brand"));
+ }
+
+ @Test
+ public void configuredTrailingColumnCanBeDroppedWithoutCopyingRows() {
+ assertTrue(DatabaseHelper.isConfiguredTrailingColumnDrop(
+ Arrays.asList("_id", "timestamp", "device_id", "value", "label"),
+ Arrays.asList("_id", "timestamp", "device_id", "value"),
+ Arrays.asList("label")));
+ }
+
+ @Test
+ public void metadataOnlyDropRejectsMiddleOrUnexpectedColumns() {
+ assertFalse(DatabaseHelper.isConfiguredTrailingColumnDrop(
+ Arrays.asList("_id", "label", "value"),
+ Arrays.asList("_id", "value"),
+ Arrays.asList("label")));
+ assertFalse(DatabaseHelper.isConfiguredTrailingColumnDrop(
+ Arrays.asList("_id", "value", "accuracy"),
+ Arrays.asList("_id", "value"),
+ Arrays.asList("label")));
+ }
+
+ @Test
+ public void everyDeclaredTableYieldsColumns() {
+ for (int i = 0; i < Aware_Provider.TABLES_FIELDS.length; i++) {
+ assertTrue("table " + Aware_Provider.DATABASE_TABLES[i] + " declared no columns",
+ DatabaseHelper.declaredColumns(Aware_Provider.TABLES_FIELDS[i]).size() > 0);
+ }
+ }
+
+ @Test
+ public void everyColumnNameIsBareOfTypeAndDefault() {
+ for (String column : DatabaseHelper.declaredColumns(Aware_Provider.TABLES_FIELDS[0])) {
+ assertFalse(column + " carries more than a name", column.contains(" "));
+ }
+ }
+
+ // --- Which tables can carry the (timestamp, device_id) index ---
+ //
+ // Indexing a column a table does not declare throws, and inside an upgrade that aborts the
+ // whole migration transaction, leaving the schema a version behind the code querying it.
+
+ /** Mirrors the guard in DatabaseHelper.createTimeDeviceIndex. */
+ private static boolean indexable(String fields) {
+ List declared = DatabaseHelper.declaredColumns(fields);
+ return declared.contains("timestamp") && declared.contains("device_id");
+ }
+
+ @Test
+ public void tablesWithoutTimestampOrDeviceIdAreNotIndexed() {
+ // Named individually so that giving one of them a timestamp is a deliberate decision here
+ // rather than a silent flip.
+ for (String table : new String[]{"aware_settings", "aware_plugins", "aware_sync_markers"}) {
+ int i = indexOf(table);
+ assertFalse(table + " declares no timestamp/device_id and must not be indexed",
+ indexable(Aware_Provider.TABLES_FIELDS[i]));
+ }
+ }
+
+ @Test
+ public void tablesWithBothColumnsAreStillIndexed() {
+ // The guard must not cost the tables that do benefit from the index.
+ for (String table : new String[]{"aware_device", "aware_studies", "aware_log"}) {
+ int i = indexOf(table);
+ assertTrue(table + " declares both columns and should be indexed",
+ indexable(Aware_Provider.TABLES_FIELDS[i]));
+ }
+ }
+
+ @Test
+ public void noTableIsIndexedOnAColumnItDoesNotDeclare() {
+ // The general invariant, so a table added later cannot reintroduce the failure.
+ for (int i = 0; i < Aware_Provider.TABLES_FIELDS.length; i++) {
+ if (!indexable(Aware_Provider.TABLES_FIELDS[i])) continue;
+ List declared = DatabaseHelper.declaredColumns(Aware_Provider.TABLES_FIELDS[i]);
+ assertTrue(Aware_Provider.DATABASE_TABLES[i] + " missing timestamp",
+ declared.contains("timestamp"));
+ assertTrue(Aware_Provider.DATABASE_TABLES[i] + " missing device_id",
+ declared.contains("device_id"));
+ }
+ }
+
+ private static int indexOf(String table) {
+ for (int i = 0; i < Aware_Provider.DATABASE_TABLES.length; i++) {
+ if (Aware_Provider.DATABASE_TABLES[i].equals(table)) return i;
+ }
+ throw new IllegalArgumentException("No such declared table: " + table);
+ }
+}
diff --git a/aware-core/src/test/java/com/aware/utils/DatabaseTransactionTest.java b/aware-core/src/test/java/com/aware/utils/DatabaseTransactionTest.java
new file mode 100644
index 00000000..f68ca29d
--- /dev/null
+++ b/aware-core/src/test/java/com/aware/utils/DatabaseTransactionTest.java
@@ -0,0 +1,93 @@
+package com.aware.utils;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+
+public class DatabaseTransactionTest {
+
+ private static class FakeBackend implements DatabaseTransaction.Backend {
+ int begins;
+ int successes;
+ int ends;
+
+ @Override
+ public void begin() {
+ begins++;
+ }
+
+ @Override
+ public void setSuccessful() {
+ successes++;
+ }
+
+ @Override
+ public void end() {
+ ends++;
+ }
+ }
+
+ @Test
+ public void successfulScopeCommitsAndEndsOnce() {
+ FakeBackend backend = new FakeBackend();
+
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(backend)) {
+ transaction.commit();
+ assertEquals(1, backend.ends);
+ }
+
+ assertEquals(1, backend.begins);
+ assertEquals(1, backend.successes);
+ assertEquals(1, backend.ends);
+ }
+
+ @Test
+ public void exceptionStillEndsWithoutCommitting() {
+ FakeBackend backend = new FakeBackend();
+
+ try {
+ try (DatabaseTransaction ignored = DatabaseTransaction.begin(backend)) {
+ throw new IllegalStateException("write failed");
+ }
+ } catch (IllegalStateException expected) {
+ // The caller receives the original failure after close() releases the lock.
+ }
+
+ assertEquals(1, backend.begins);
+ assertEquals(0, backend.successes);
+ assertEquals(1, backend.ends);
+ }
+
+ @Test
+ public void commitFailureStillEnds() {
+ FakeBackend backend = new FakeBackend() {
+ @Override
+ public void setSuccessful() {
+ super.setSuccessful();
+ throw new IllegalStateException("commit failed");
+ }
+ };
+
+ try {
+ try (DatabaseTransaction transaction = DatabaseTransaction.begin(backend)) {
+ transaction.commit();
+ }
+ } catch (IllegalStateException expected) {
+ // close() still rolls the transaction out of SQLite's active state.
+ }
+
+ assertEquals(1, backend.successes);
+ assertEquals(1, backend.ends);
+ }
+
+ @Test
+ public void closeIsIdempotent() {
+ FakeBackend backend = new FakeBackend();
+ DatabaseTransaction transaction = DatabaseTransaction.begin(backend);
+
+ transaction.close();
+ transaction.close();
+
+ assertEquals(1, backend.ends);
+ }
+}
diff --git a/aware-core/src/test/java/com/aware/utils/DeviceFactsTest.java b/aware-core/src/test/java/com/aware/utils/DeviceFactsTest.java
new file mode 100644
index 00000000..0731db86
--- /dev/null
+++ b/aware-core/src/test/java/com/aware/utils/DeviceFactsTest.java
@@ -0,0 +1,135 @@
+package com.aware.utils;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.aware.providers.Aware_Provider.Aware_Device;
+
+import org.junit.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Unit tests for the comparison that decides whether the stored aware_device row still describes the
+ * device.
+ *
+ * A spurious "changed" verdict costs more here than a missed one: the row is rewritten with a fresh
+ * timestamp, and the sync carries every rewrite to the server as its own row, so noise here reads as
+ * device history that never happened. The null/empty equivalence is pinned in particular — a column
+ * the platform reports as null but SQLite stores as empty text would otherwise read as changed on
+ * every single check.
+ */
+public class DeviceFactsTest {
+
+ /** A snapshot with every compared column populated. */
+ private static Map snapshot() {
+ Map facts = new HashMap<>();
+ for (String column : DeviceFacts.COMPARED_COLUMNS) {
+ facts.put(column, "value-of-" + column);
+ }
+ return facts;
+ }
+
+ @Test
+ public void identicalSnapshotsAreUnchanged() {
+ assertTrue(DeviceFacts.unchanged(snapshot(), snapshot()));
+ }
+
+ @Test
+ public void noStoredRowCountsAsChanged() {
+ // A device with no row yet must get one.
+ assertFalse(DeviceFacts.unchanged(null, snapshot()));
+ }
+
+ @Test
+ public void everyComparedColumnCanTriggerARow() {
+ // Guards against a column being listed in COMPARED_COLUMNS but read under the wrong key: a
+ // fact that cannot trigger a row is a real device change that would go unrecorded.
+ for (String column : DeviceFacts.COMPARED_COLUMNS) {
+ Map current = snapshot();
+ current.put(column, "something-else");
+ assertFalse("a change to " + column + " should warrant a new row",
+ DeviceFacts.unchanged(snapshot(), current));
+ }
+ }
+
+ @Test
+ public void anAndroidUpgradeWarrantsARow() {
+ Map stored = snapshot();
+ stored.put(Aware_Device.RELEASE, "15");
+ stored.put(Aware_Device.SDK, "35");
+ Map current = snapshot();
+ current.put(Aware_Device.RELEASE, "16");
+ current.put(Aware_Device.SDK, "36");
+
+ assertFalse(DeviceFacts.unchanged(stored, current));
+ }
+
+ @Test
+ public void nullAndEmptyAreTheSameValue() {
+ Map stored = snapshot();
+ stored.put(Aware_Device.MANUFACTURER, null);
+ Map current = snapshot();
+ current.put(Aware_Device.MANUFACTURER, "");
+
+ assertTrue(DeviceFacts.unchanged(stored, current));
+ assertTrue(DeviceFacts.unchanged(current, stored));
+ }
+
+ @Test
+ public void bothNullIsUnchanged() {
+ Map stored = snapshot();
+ stored.put(Aware_Device.HARDWARE, null);
+ Map current = snapshot();
+ current.put(Aware_Device.HARDWARE, null);
+
+ assertTrue(DeviceFacts.unchanged(stored, current));
+ }
+
+ @Test
+ public void aMissingKeyAndAnEmptyValueAreTheSame() {
+ // The stored map is built by reading columns off a Cursor; a column absent from the table
+ // yields no entry at all rather than an empty one.
+ Map stored = snapshot();
+ stored.remove(Aware_Device.PRODUCT);
+ Map current = snapshot();
+ current.put(Aware_Device.PRODUCT, "");
+
+ assertTrue(DeviceFacts.unchanged(stored, current));
+ }
+
+ @Test
+ public void aColumnOutsideTheComparedSetDoesNotWarrantARewrite() {
+ // Only COMPARED_COLUMNS describe the device. A value carried alongside them in the same row
+ // — device_id, or anything added to the table later — has no say in the verdict.
+ Map stored = snapshot();
+ stored.put(Aware_Device.DEVICE_ID, "device-a");
+ Map current = snapshot();
+ current.put(Aware_Device.DEVICE_ID, "device-b");
+
+ assertTrue(DeviceFacts.unchanged(stored, current));
+ }
+
+ @Test
+ public void aNewTimestampAloneDoesNotWarrantARow() {
+ Map stored = snapshot();
+ stored.put(Aware_Device.TIMESTAMP, "1000");
+ Map current = snapshot();
+ current.put(Aware_Device.TIMESTAMP, "2000");
+
+ assertTrue(DeviceFacts.unchanged(stored, current));
+ }
+
+ @Test
+ public void deviceIdIsNotCompared() {
+ // device_id is the lookup key: the caller has already scoped the stored row to it, so
+ // comparing it could only ever mask a genuine fact change.
+ Map stored = snapshot();
+ stored.put(Aware_Device.DEVICE_ID, "device-a");
+ Map current = snapshot();
+ current.put(Aware_Device.DEVICE_ID, "device-b");
+
+ assertTrue(DeviceFacts.unchanged(stored, current));
+ }
+}
diff --git a/aware-core/src/test/java/com/aware/utils/DeviceIdTest.java b/aware-core/src/test/java/com/aware/utils/DeviceIdTest.java
new file mode 100644
index 00000000..02b86808
--- /dev/null
+++ b/aware-core/src/test/java/com/aware/utils/DeviceIdTest.java
@@ -0,0 +1,121 @@
+package com.aware.utils;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+/**
+ * Locks in which copy of the device UUID wins and which one gets repaired.
+ *
+ * Two failure modes drove this. Aware.reset() used to delete every row of aware_settings before
+ * writing device_id back, so a sensor inserting on another thread during that gap stamped a row with
+ * the empty string -- a row on the server belonging to no participant. And Aware.onCreate() reading
+ * that same empty table could not tell a lost row from a first run, so it minted a second UUID and
+ * split one participant across two identities, which is unrepairable after upload.
+ *
+ * The mirror in SharedPreferences survives a settings wipe, so resolve() can recover the value instead
+ * of the caller inventing one. Nothing here generates a UUID -- see DeviceId's class comment.
+ */
+public class DeviceIdTest {
+
+ private static final String UUID = "0e5a1f6c-3d2b-4a71-9c8e-5f3b7d914a20";
+ private static final String OTHER_UUID = "b71c4e02-9a8f-4d63-8e15-2c7a0b6f3d94";
+
+ // --- The settled case: both copies agree, nothing to do.
+
+ @Test
+ public void bothCopiesAgree_usesTheUuidAndRepairsNothing() {
+ DeviceId.Resolution resolution = DeviceId.resolve(UUID, UUID);
+
+ assertEquals(UUID, resolution.getDeviceId());
+ assertTrue(resolution.isResolved());
+ assertFalse(resolution.shouldHealSettings());
+ assertFalse(resolution.shouldHealMirror());
+ }
+
+ // --- Settings has it, the mirror does not: the upgrade path. Existing installs generated their
+ // UUID long before the mirror existed, so the first read has to populate it -- otherwise the
+ // recovery below has nothing to recover from.
+
+ @Test
+ public void mirrorMissing_usesSettingsAndBacksItUp() {
+ DeviceId.Resolution resolution = DeviceId.resolve(UUID, "");
+
+ assertEquals(UUID, resolution.getDeviceId());
+ assertTrue(resolution.shouldHealMirror());
+ assertFalse(resolution.shouldHealSettings());
+ }
+
+ @Test
+ public void mirrorDiverged_settingsWinsBecauseThatIsWhatTheServerAlreadySees() {
+ DeviceId.Resolution resolution = DeviceId.resolve(UUID, OTHER_UUID);
+
+ assertEquals(UUID, resolution.getDeviceId());
+ assertTrue(resolution.shouldHealMirror());
+ assertFalse(resolution.shouldHealSettings());
+ }
+
+ // --- The reset() window: settings lost the row, the mirror still has it.
+
+ @Test
+ public void settingsWiped_recoversFromTheMirrorAndRestoresTheSetting() {
+ DeviceId.Resolution resolution = DeviceId.resolve("", UUID);
+
+ assertEquals(UUID, resolution.getDeviceId());
+ assertTrue(resolution.isResolved());
+ assertTrue(resolution.shouldHealSettings());
+ assertFalse(resolution.shouldHealMirror());
+ }
+
+ @Test
+ public void settingsNull_isTreatedAsWipedRatherThanCrashing() {
+ assertEquals(UUID, DeviceId.resolve(null, UUID).getDeviceId());
+ }
+
+ // --- Blank is absent. The column's declared default is '', and a whitespace-only value orphans a
+ // row exactly as an empty one does.
+
+ @Test
+ public void blankSettingsFallsBackToTheMirror() {
+ DeviceId.Resolution resolution = DeviceId.resolve(" ", UUID);
+
+ assertEquals(UUID, resolution.getDeviceId());
+ assertTrue(resolution.shouldHealSettings());
+ }
+
+ @Test
+ public void surroundingWhitespaceIsNotTreatedAsADifferentUuid() {
+ DeviceId.Resolution resolution = DeviceId.resolve(" " + UUID + " ", UUID);
+
+ assertEquals(UUID, resolution.getDeviceId());
+ assertFalse("trimmed value equals the mirror, so the mirror is already correct",
+ resolution.shouldHealMirror());
+ }
+
+ // --- Neither copy has one: a genuine first run. Reporting unresolved is what keeps the decision to
+ // mint a UUID in Aware.onCreate() instead of spread across the read sites.
+
+ @Test
+ public void neitherCopyHasAUuid_reportsUnresolvedAndInventsNothing() {
+ DeviceId.Resolution resolution = DeviceId.resolve("", "");
+
+ assertEquals("", resolution.getDeviceId());
+ assertFalse(resolution.isResolved());
+ assertFalse(resolution.shouldHealSettings());
+ assertFalse(resolution.shouldHealMirror());
+ }
+
+ @Test
+ public void bothNull_reportsUnresolved() {
+ assertFalse(DeviceId.resolve(null, null).isResolved());
+ }
+
+ @Test
+ public void trimToEmpty_normalisesNullAndWhitespace() {
+ assertEquals("", DeviceId.trimToEmpty(null));
+ assertEquals("", DeviceId.trimToEmpty(" "));
+ assertEquals(UUID, DeviceId.trimToEmpty(" " + UUID + "\n"));
+ }
+}
diff --git a/aware-core/src/test/java/com/aware/utils/JdbcBreakerTest.java b/aware-core/src/test/java/com/aware/utils/JdbcBreakerTest.java
new file mode 100644
index 00000000..adda575c
--- /dev/null
+++ b/aware-core/src/test/java/com/aware/utils/JdbcBreakerTest.java
@@ -0,0 +1,137 @@
+package com.aware.utils;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import java.sql.SQLException;
+
+/**
+ * Covers the two bounds on the shared sync connection: the timeouts in its URL, and the cooldown
+ * that stops every remaining table re-proving the same connection failure.
+ *
+ * Both exist because the shared connection is a serialization point. Uploads for roughly 40 tables
+ * across 30 sync adapters take turns inside one synchronized method, so a single upload that never
+ * returns stalls all of them — and, because the call returns neither success nor failure, the sync
+ * adapter records nothing and no notification is raised.
+ */
+public class JdbcBreakerTest {
+
+ // --- Connection URL: the timeouts are what make a stalled upload finite.
+
+ @Test
+ public void syncUrlBoundsBothConnectAndRead() {
+ String url = Jdbc.syncConnectionUrl("10.0.0.1", "3306", "aware_android", "");
+
+ assertTrue(url, url.contains("connectTimeout=" + Jdbc.SYNC_CONNECT_TIMEOUT_MS));
+ assertTrue(url, url.contains("socketTimeout=" + Jdbc.SYNC_SOCKET_TIMEOUT_MS));
+ }
+
+ /** A timeout of 0 is the driver's default and means "wait forever" — the bug being fixed. */
+ @Test
+ public void neitherTimeoutIsUnbounded() {
+ assertTrue(Jdbc.SYNC_CONNECT_TIMEOUT_MS > 0);
+ assertTrue(Jdbc.SYNC_SOCKET_TIMEOUT_MS > 0);
+ assertFalse(Jdbc.syncConnectionUrl("h", "3306", "db", "").contains("Timeout=0"));
+ }
+
+ /**
+ * The socket timeout applies per read, so it has to leave room for a slow link; the connect
+ * timeout only covers establishing the socket and can be tighter.
+ */
+ @Test
+ public void theReadTimeoutIsTheMoreGenerousOfTheTwo() {
+ assertTrue(Jdbc.SYNC_SOCKET_TIMEOUT_MS >= Jdbc.SYNC_CONNECT_TIMEOUT_MS);
+ }
+
+ @Test
+ public void syncUrlKeepsBatchRewritingAndTheHostDetails() {
+ String url = Jdbc.syncConnectionUrl("db.example.org", "3307", "aware_android", "");
+
+ assertTrue(url, url.startsWith("jdbc:mysql://db.example.org:3307/aware_android?"));
+ assertTrue(url, url.contains("rewriteBatchedStatements=true"));
+ }
+
+ @Test
+ public void syncUrlAppendsTlsParametersAndToleratesTheirAbsence() {
+ assertTrue(Jdbc.syncConnectionUrl("h", "3306", "db", "&useSSL=true")
+ .endsWith("&useSSL=true"));
+ assertFalse(Jdbc.syncConnectionUrl("h", "3306", "db", null).contains("null"));
+ }
+
+ // --- Failure classification: which failures mean every other table is also about to fail.
+
+ @Test
+ public void communicationsFailuresAreConnectionLevel() {
+ assertTrue(Jdbc.isConnectionLevel(sqlState("08S01"))); // link failure, incl. socket timeout
+ assertTrue(Jdbc.isConnectionLevel(sqlState("08003"))); // connection does not exist
+ assertTrue(Jdbc.isConnectionLevel(sqlState("28000"))); // access denied
+ }
+
+ /**
+ * The case this classification exists to get right. An unknown column is what silenced the
+ * bluetooth table for two hours; treating it as a connection failure would have suppressed
+ * every other table's upload as well, turning one broken table into a total outage.
+ */
+ @Test
+ public void aRejectedStatementIsNotConnectionLevel() {
+ assertFalse(Jdbc.isConnectionLevel(sqlState("42S22"))); // unknown column
+ assertFalse(Jdbc.isConnectionLevel(sqlState("42S02"))); // unknown table
+ assertFalse(Jdbc.isConnectionLevel(sqlState("23000"))); // constraint violation
+ assertFalse(Jdbc.isConnectionLevel(sqlState("22007"))); // bad datetime value
+ }
+
+ /** Unrecognised failures stay statement-level: the cheaper mistake of the two. */
+ @Test
+ public void anUnknownFailureIsNotTreatedAsConnectionLevel() {
+ assertFalse(Jdbc.isConnectionLevel(sqlState(null)));
+ assertFalse(Jdbc.isConnectionLevel(new SQLException("no state at all")));
+ assertFalse(Jdbc.isConnectionLevel(null));
+ }
+
+ // --- Cooldown.
+
+ @Test
+ public void noFailureMeansUploadsProceed() {
+ assertFalse(Jdbc.breakerOpen(0, 5_000_000L, Jdbc.BREAKER_COOLDOWN_MS));
+ }
+
+ @Test
+ public void aFreshFailureSuppressesTheNextAttempt() {
+ long now = 5_000_000L;
+ assertTrue(Jdbc.breakerOpen(now, now, Jdbc.BREAKER_COOLDOWN_MS));
+ assertTrue(Jdbc.breakerOpen(now, now + 1, Jdbc.BREAKER_COOLDOWN_MS));
+ }
+
+ @Test
+ public void theCooldownEndsExactlyAtItsLength() {
+ long failedAt = 5_000_000L;
+ assertTrue(Jdbc.breakerOpen(
+ failedAt, failedAt + Jdbc.BREAKER_COOLDOWN_MS - 1, Jdbc.BREAKER_COOLDOWN_MS));
+ assertFalse(Jdbc.breakerOpen(
+ failedAt, failedAt + Jdbc.BREAKER_COOLDOWN_MS, Jdbc.BREAKER_COOLDOWN_MS));
+ assertFalse(Jdbc.breakerOpen(
+ failedAt, failedAt + Jdbc.BREAKER_COOLDOWN_MS + 1, Jdbc.BREAKER_COOLDOWN_MS));
+ }
+
+ /**
+ * The cooldown must stay well inside one sync interval (30 minutes by default), or it would be
+ * delaying scheduled retries rather than only collapsing one cycle's redundant attempts.
+ */
+ @Test
+ public void theCooldownIsShorterThanTheSyncInterval() {
+ assertTrue(Jdbc.BREAKER_COOLDOWN_MS < 30 * 60 * 1000L);
+ }
+
+ /** A clock that steps backwards must not wedge uploads open indefinitely. */
+ @Test
+ public void aBackwardsClockDoesNotHoldTheBreakerOpen() {
+ long failedAt = 5_000_000L;
+ assertFalse(Jdbc.breakerOpen(failedAt, failedAt - 1, Jdbc.BREAKER_COOLDOWN_MS));
+ }
+
+ private static SQLException sqlState(String state) {
+ return new SQLException("failure", state, 0);
+ }
+}
diff --git a/aware-core/src/test/java/com/aware/utils/JdbcClassifyTest.java b/aware-core/src/test/java/com/aware/utils/JdbcClassifyTest.java
new file mode 100644
index 00000000..ad3850a9
--- /dev/null
+++ b/aware-core/src/test/java/com/aware/utils/JdbcClassifyTest.java
@@ -0,0 +1,54 @@
+package com.aware.utils;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+
+import java.sql.SQLException;
+
+/**
+ * Unit test for {@link Jdbc#classify(SQLException)} — the pure classifier behind the study
+ * password re-authentication flow. It must reliably tell an access-denied (wrong password) error
+ * apart from a reachability failure, because only the former should ask the participant to
+ * re-enter the study password; an unreachable/transient failure must never trigger that prompt.
+ */
+public class JdbcClassifyTest {
+
+ @Test
+ public void sqlState28000IsAuthFailure() {
+ // MySQL access-denied is signalled with SQLState 28000.
+ SQLException e = new SQLException("Access denied for user", "28000");
+ assertEquals(Jdbc.ConnectionResult.AUTH_FAILED, Jdbc.classify(e));
+ }
+
+ @Test
+ public void vendorError1045IsAuthFailure() {
+ // MySQL vendor error 1045 = access denied, even if the SQLState differs.
+ SQLException e = new SQLException("Access denied for user", "HY000", 1045);
+ assertEquals(Jdbc.ConnectionResult.AUTH_FAILED, Jdbc.classify(e));
+ }
+
+ @Test
+ public void communicationsLinkFailureIsUnreachable() {
+ SQLException e = new SQLException("Communications link failure", "08S01");
+ assertEquals(Jdbc.ConnectionResult.UNREACHABLE, Jdbc.classify(e));
+ }
+
+ @Test
+ public void connectionRejectedIsUnreachable() {
+ SQLException e = new SQLException("Unable to connect", "08001");
+ assertEquals(Jdbc.ConnectionResult.UNREACHABLE, Jdbc.classify(e));
+ }
+
+ @Test
+ public void unknownExceptionDefaultsToUnreachable() {
+ // No SQLState, no vendor code: safe default is UNREACHABLE (never prompt on the unknown).
+ SQLException e = new SQLException("Something unexpected");
+ assertEquals(Jdbc.ConnectionResult.UNREACHABLE, Jdbc.classify(e));
+ }
+
+ @Test
+ public void nullDefaultsToUnreachable() {
+ assertEquals(Jdbc.ConnectionResult.UNREACHABLE, Jdbc.classify(null));
+ }
+}
diff --git a/aware-core/src/test/java/com/aware/utils/JdbcWarningsTest.java b/aware-core/src/test/java/com/aware/utils/JdbcWarningsTest.java
new file mode 100644
index 00000000..efb590a8
--- /dev/null
+++ b/aware-core/src/test/java/com/aware/utils/JdbcWarningsTest.java
@@ -0,0 +1,100 @@
+package com.aware.utils;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import java.sql.SQLWarning;
+
+/**
+ * Unit tests for the summary of SQL warnings raised by an upload batch.
+ *
+ * Every value is sent to MySQL as a string, including numeric and {@code double_*} columns, so the
+ * server converts each one implicitly. A server in strict mode raises an error on a bad conversion,
+ * but a non-strict server — or a MyISAM table, where strict mode degrades to warnings for multi-row
+ * inserts — accepts it as a warning and stores something other than what was sent. A discarded
+ * warning is therefore the difference between "1000 rows stored" and "1000 rows stored as
+ * something else", which is why the chain is read and reported.
+ */
+public class JdbcWarningsTest {
+
+ @Test
+ public void noWarningChainIsNotReported() {
+ // The overwhelmingly common case: nothing should be logged on a clean batch.
+ assertNull(Jdbc.describeWarnings(null));
+ }
+
+ @Test
+ public void aSingleWarningReportsItsStateCodeAndMessage() {
+ SQLWarning warning = new SQLWarning("Data truncated for column 'double_values_0'", "01000", 1265);
+
+ String summary = Jdbc.describeWarnings(warning);
+
+ assertNotNull(summary);
+ assertTrue(summary, summary.contains("1 warning(s)"));
+ assertTrue(summary, summary.contains("01000"));
+ assertTrue(summary, summary.contains("1265"));
+ assertTrue(summary, summary.contains("Data truncated for column 'double_values_0'"));
+ }
+
+ @Test
+ public void theWholeChainIsCounted() {
+ // MySQL reports one warning per offending row, so the count separates a single odd value
+ // from a column whose type no longer matches what the client sends.
+ SQLWarning head = new SQLWarning("first", "01000", 1265);
+ head.setNextWarning(new SQLWarning("second", "01000", 1265));
+ head.setNextWarning(new SQLWarning("third", "01000", 1265));
+
+ String summary = Jdbc.describeWarnings(head);
+
+ assertTrue(summary, summary.contains("3 warning(s)"));
+ }
+
+ @Test
+ public void theFirstWarningIsTheOneQuoted() {
+ SQLWarning head = new SQLWarning("the first one", "01004", 1264);
+ head.setNextWarning(new SQLWarning("a later one", "01000", 1265));
+
+ String summary = Jdbc.describeWarnings(head);
+
+ assertTrue(summary, summary.contains("the first one"));
+ assertTrue(summary, summary.contains("01004"));
+ }
+
+ @Test
+ public void aSelfReferencingChainTerminates() {
+ // A driver chaining a warning to itself must not hang the sync thread. There is no correct
+ // count to assert here, only that the call returns.
+ SQLWarning selfReferencing = new SQLWarning("loops", "01000", 1265) {
+ @Override
+ public SQLWarning getNextWarning() {
+ return this;
+ }
+ };
+
+ String summary = Jdbc.describeWarnings(selfReferencing);
+
+ assertNotNull(summary);
+ assertTrue(summary, summary.contains("loops"));
+ }
+
+ @Test
+ public void aChainLongerThanTheCapStillReportsTheFirstWarning() {
+ SQLWarning head = new SQLWarning("head", "01000", 1265);
+ SQLWarning tail = head;
+ for (int i = 0; i < 1500; i++) {
+ SQLWarning next = new SQLWarning("row " + i, "01000", 1265);
+ tail.setNextWarning(next);
+ tail = next;
+ }
+
+ String summary = Jdbc.describeWarnings(head);
+
+ assertTrue(summary, summary.contains("head"));
+ // Counting is bounded, so the reported number saturates rather than walking 1501 links.
+ assertEquals("1000 warning(s); first: [01000/1265] head", summary);
+ }
+}
diff --git a/aware-core/src/test/java/com/aware/utils/LogRedactorTest.java b/aware-core/src/test/java/com/aware/utils/LogRedactorTest.java
new file mode 100644
index 00000000..f3c44a64
--- /dev/null
+++ b/aware-core/src/test/java/com/aware/utils/LogRedactorTest.java
@@ -0,0 +1,94 @@
+package com.aware.utils;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+/**
+ * Regression test for {@link LogRedactor}. Locks in the fix for the beta-stability issue "Database
+ * passwords appear in logs": the study configuration embeds the database password, and that config
+ * is written to Logcat in several shapes (pretty JSON, compact JSON, ContentValues.toString(), and
+ * DatabaseUtils.dumpCursorToString()). The redactor must strip the password out of all of them
+ * while leaving non-sensitive fields readable for debugging.
+ */
+public class LogRedactorTest {
+
+ @Test
+ public void redactsCompactJsonPassword() {
+ String in = "{\"database_password\":\"s3cr3t\"}";
+ assertEquals("{\"database_password\":\"***\"}", LogRedactor.redact(in));
+ }
+
+ @Test
+ public void redactsPrettyPrintedPasswordWithSpacesAroundColon() {
+ String in = "\"database_password\" : \"s3cr3t\"";
+ assertEquals("\"database_password\" : \"***\"", LogRedactor.redact(in));
+ }
+
+ @Test
+ public void redactsPlainPasswordKey() {
+ String in = "{\"password\":\"hunter2\"}";
+ assertEquals("{\"password\":\"***\"}", LogRedactor.redact(in));
+ }
+
+ @Test
+ public void redactsRegardlessOfKeyCase() {
+ String in = "{\"Database_Password\":\"hunter2\"}";
+ assertEquals("{\"Database_Password\":\"***\"}", LogRedactor.redact(in));
+ }
+
+ @Test
+ public void keepsNonSensitiveFields() {
+ // The host/username/name must stay readable so a connection failure is still diagnosable.
+ String in = "{\"database_host\":\"db.example.org\",\"database_password\":\"s3cr3t\","
+ + "\"database_username\":\"researcher\"}";
+ String expected = "{\"database_host\":\"db.example.org\",\"database_password\":\"***\","
+ + "\"database_username\":\"researcher\"}";
+ assertEquals(expected, LogRedactor.redact(in));
+ }
+
+ @Test
+ public void redactsValueContainingEscapedQuote() {
+ // Value is a"b written with an escaped inner quote; must not stop the match early.
+ String in = "{\"password\":\"a\\\"b\"}";
+ assertEquals("{\"password\":\"***\"}", LogRedactor.redact(in));
+ }
+
+ @Test
+ public void redactsEveryOccurrence() {
+ String in = "{\"password\":\"one\"} ... {\"password\":\"two\"}";
+ assertEquals("{\"password\":\"***\"} ... {\"password\":\"***\"}", LogRedactor.redact(in));
+ }
+
+ @Test
+ public void redactsPasswordEmbeddedInContentValuesDump() {
+ // Mirrors ContentValues.toString(): the config JSON is one value inside a larger blob.
+ String in = "study_config=[{\"database\":{\"database_password\":\"s3cr3t\"}}] study_title=Demo";
+ String out = LogRedactor.redact(in);
+ assertFalse(out.contains("s3cr3t"));
+ assertTrue(out.contains("\"database_password\":\"***\""));
+ assertTrue(out.contains("study_title=Demo"));
+ }
+
+ @Test
+ public void leavesTextWithoutSecretsUnchanged() {
+ String in = "Establishing connection to remote database...";
+ assertEquals(in, LogRedactor.redact(in));
+ }
+
+ @Test
+ public void doesNotTouchBooleanConfigWithoutPasswordFlag() {
+ // "config_without_password" contains the word "password" but its value is an unquoted
+ // boolean, so there is no string value to redact and the flag stays visible.
+ String in = "{\"config_without_password\":false}";
+ assertEquals(in, LogRedactor.redact(in));
+ }
+
+ @Test
+ public void handlesNull() {
+ assertNull(LogRedactor.redact(null));
+ }
+}
diff --git a/aware-core/src/test/java/com/aware/utils/MysqlTlsTest.java b/aware-core/src/test/java/com/aware/utils/MysqlTlsTest.java
new file mode 100644
index 00000000..fe48b1af
--- /dev/null
+++ b/aware-core/src/test/java/com/aware/utils/MysqlTlsTest.java
@@ -0,0 +1,101 @@
+package com.aware.utils;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+/**
+ * Covers the parts of {@link MysqlTls} that decide what the driver is told, which is where a mistake
+ * would be quiet: a URL that parses but leaves out the verification flag connects perfectly well to
+ * any host that answers, so these assertions pin the exact parameters rather than a shape.
+ */
+public class MysqlTlsTest {
+
+ private static final String STORE = "/data/user/0/com.aware.phone/files/mysql_truststore_ab.p12";
+
+ @Test
+ public void parametersDemandCertificateVerification() {
+ assertTrue(MysqlTls.sslParameters(STORE).contains("verifyServerCertificate=true"));
+ }
+
+ @Test
+ public void parametersRequireTlsForTheConnection() {
+ String parameters = MysqlTls.sslParameters(STORE);
+ assertTrue(parameters.contains("useSSL=true"));
+ assertTrue(parameters.contains("requireSSL=true"));
+ }
+
+ @Test
+ public void parametersPointAtTheTrustStoreAsAFileUrl() {
+ assertTrue(MysqlTls.sslParameters(STORE).contains("trustCertificateKeyStoreUrl=file:" + STORE));
+ }
+
+ @Test
+ public void parametersNameTheKeystoreFormatAndPassword() {
+ String parameters = MysqlTls.sslParameters(STORE);
+ assertTrue(parameters.contains("trustCertificateKeyStoreType=PKCS12"));
+ assertTrue(parameters.contains("trustCertificateKeyStorePassword=" + MysqlTls.TRUST_STORE_PASSWORD));
+ }
+
+ @Test
+ public void parametersAppendToAUrlThatAlreadyHasAQuery() {
+ // The three call sites all format these onto a URL ending in a parameter, so the fragment
+ // opens with a separator rather than a '?'.
+ assertTrue(MysqlTls.sslParameters(STORE).startsWith("&"));
+ }
+
+ @Test
+ public void storeNameIsDerivedFromTheCertificateDigest() {
+ assertEquals("mysql_truststore_7ee0a4481f6b5227.p12",
+ MysqlTls.trustStoreName("7ee0a4481f6b5227d1de4a41424bacab0d8123394ac0d604c208dbe367dec1aa"));
+ }
+
+ @Test
+ public void adifferentAuthorityGetsADifferentStore() {
+ String one = MysqlTls.trustStoreName("1111111111111111aaaaaaaaaaaaaaaa");
+ String two = MysqlTls.trustStoreName("2222222222222222aaaaaaaaaaaaaaaa");
+ assertTrue(!one.equals(two));
+ }
+
+ @Test
+ public void storeNameIsAValidFileNameForPrivateStorage() {
+ String name = MysqlTls.trustStoreName("7ee0a4481f6b5227d1de4a41424bacab");
+ assertTrue(name.matches("[a-z0-9_]+\\.p12"));
+ }
+
+ @Test
+ public void aStudyWithoutAnAuthorityStillEncryptsTheConnection() {
+ // No authority means the server cannot be identified, but the traffic must not travel in the
+ // clear on that account — and the study's account may require TLS regardless.
+ String parameters = MysqlTls.unverifiedParameters();
+ assertTrue(parameters.contains("useSSL=true"));
+ assertTrue(parameters.contains("requireSSL=true"));
+ }
+
+ @Test
+ public void aStudyWithoutAnAuthorityDoesNotClaimToVerify() {
+ assertTrue(MysqlTls.unverifiedParameters().contains("verifyServerCertificate=false"));
+ }
+
+ @Test
+ public void theUnverifiedFragmentNamesNoTrustStore() {
+ // Naming a store while verification is off reads as though the store were being honoured.
+ assertFalse(MysqlTls.unverifiedParameters().contains("trustCertificateKeyStore"));
+ }
+
+ @Test
+ public void bothFragmentsAppendToAUrlThatAlreadyHasAQuery() {
+ assertTrue(MysqlTls.unverifiedParameters().startsWith("&"));
+ assertTrue(MysqlTls.sslParameters(STORE).startsWith("&"));
+ }
+
+ @Test
+ public void theTwoModesDisagreeOnlyOnVerification() {
+ // The difference between a study that publishes an authority and one that does not has to be
+ // verification alone; a fragment that also dropped useSSL would send data in the clear.
+ assertTrue(MysqlTls.sslParameters(STORE).contains("verifyServerCertificate=true"));
+ assertTrue(MysqlTls.unverifiedParameters().contains("verifyServerCertificate=false"));
+ }
+}
diff --git a/aware-core/src/test/java/com/aware/utils/SensorDiagnosticsTest.java b/aware-core/src/test/java/com/aware/utils/SensorDiagnosticsTest.java
new file mode 100644
index 00000000..030a943b
--- /dev/null
+++ b/aware-core/src/test/java/com/aware/utils/SensorDiagnosticsTest.java
@@ -0,0 +1,148 @@
+package com.aware.utils;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+/**
+ * Unit tests for SensorDiagnostics.reasonGivenState() — the pure reason-classification core behind
+ * the "sensor_status" lines written to aware_log so a researcher can see, through the sync that
+ * already exists, why a given sensor isn't collecting on a participant's phone: no hardware,
+ * missing permission, accessibility off, or location services off. Uses "status_wifi" (permission +
+ * location services gated) and "status_applications" (accessibility gated) as representative
+ * settings, and a made-up key with no Gate entry to cover the "hardware-only" sensors.
+ */
+public class SensorDiagnosticsTest {
+
+ @Test
+ public void noHardware_reportedRegardlessOfOtherState() {
+ // Hardware is checked first and short-circuits everything else — a sensor with no hardware
+ // is excluded no matter what its permission/accessibility/location state happens to be.
+ String reason = SensorDiagnostics.reasonGivenState(
+ "status_light", false, "android.permission.ACCESS_FINE_LOCATION", false, false);
+ assertEquals("No such sensor hardware on this device", reason);
+ }
+
+ @Test
+ public void unGatedSetting_withHardware_isNotExcluded() {
+ // "status_battery" (or any setting with no Gate entry, e.g. this made-up one) isn't gated by
+ // anything beyond hardware — with hardware available, nothing should exclude it.
+ String reason = SensorDiagnostics.reasonGivenState("status_battery", true, null, false, false);
+ assertEquals("", reason);
+ }
+
+ @Test
+ public void missingPermission_isReportedWithShortName() {
+ String reason = SensorDiagnostics.reasonGivenState(
+ "status_wifi", true, "android.permission.ACCESS_COARSE_LOCATION", true, true);
+ assertEquals("Missing permission: ACCESS_COARSE_LOCATION", reason);
+ }
+
+ @Test
+ public void wifi_locationServicesOff_reportedBeforePermission() {
+ // status_wifi needs both the permission and the OS Location toggle; location services being
+ // off is checked before the permission, since a missing permission is passed as non-null
+ // here too — this locks in that ordering rather than leaving it to accidentally match.
+ String reason = SensorDiagnostics.reasonGivenState(
+ "status_wifi", true, "android.permission.ACCESS_COARSE_LOCATION", true, false);
+ assertEquals("Location services are off", reason);
+ }
+
+ @Test
+ public void wifi_allSatisfied_isNotExcluded() {
+ String reason = SensorDiagnostics.reasonGivenState("status_wifi", true, null, true, true);
+ assertEquals("", reason);
+ }
+
+ @Test
+ public void accessibilityGatedSetting_off_isReported() {
+ String reason = SensorDiagnostics.reasonGivenState("status_applications", true, null, false, false);
+ assertEquals("Accessibility service is off", reason);
+ }
+
+ @Test
+ public void accessibilityGatedSetting_on_isNotExcluded() {
+ String reason = SensorDiagnostics.reasonGivenState("status_applications", true, null, true, false);
+ assertEquals("", reason);
+ }
+
+ @Test
+ public void reasonIsEmpty_meansNotExcluded() {
+ // Sanity check tying the reason string directly to how logSensorStatus() derives "excluded"
+ // (excluded = !reason.isEmpty()) — an empty reason must mean "not excluded", not just
+ // "no reason text".
+ String reason = SensorDiagnostics.reasonGivenState("status_battery", true, null, false, false);
+ assertTrue(reason.isEmpty());
+ }
+
+ @Test
+ public void sampledSensor_reportsCollectingOnlyInsideFrequencyWindow() {
+ assertEquals("collecting", SensorDiagnostics.stateGiven(
+ "", true, false, 1_000_000L, 880_000L, 120_000L));
+ assertEquals("delayed", SensorDiagnostics.stateGiven(
+ "", true, false, 1_000_001L, 880_000L, 120_000L));
+ }
+
+ @Test
+ public void eventDrivenSensor_waitsWithoutBeingDelayed() {
+ assertEquals("waiting_for_event", SensorDiagnostics.stateGiven(
+ "", true, true, 1_000_000L, 0L, 0L));
+ }
+
+ @Test
+ public void unavailableAndDisabledHaveExplicitStates() {
+ assertEquals("unavailable", SensorDiagnostics.stateGiven(
+ "No such sensor hardware on this device", true, false,
+ 1_000_000L, 0L, 120_000L));
+ assertEquals("disabled", SensorDiagnostics.stateGiven(
+ "", false, false, 1_000_000L, 999_999L, 120_000L));
+ assertEquals("disabled", SensorDiagnostics.stateGiven(
+ "Missing permission: ACCESS_FINE_LOCATION", false, false,
+ 1_000_000L, 999_999L, 120_000L));
+ }
+
+ // --- When a sensor last produced data ---
+ //
+ // Two records disagree once data has been uploaded: the local table is emptied by
+ // webservice_remove_data, while the upload bookmark keeps the delivered timestamp. Reading only
+ // the table reports a working sensor as never having collected, in the participant's status text
+ // and in the diagnostics uploaded to the researcher.
+
+ @Test
+ public void deliveredDataCountsWhenTheLocalRowsAreGone() {
+ // The bug: uploaded and cleaned up, so nothing local — but the database holds data up to
+ // 999_999, so the sensor plainly has collected.
+ assertEquals(999_999L, SensorDiagnostics.observedLatest(0L, 999_999L));
+ }
+
+ @Test
+ public void aWorkingSensorIsNeverReportedAsWaitingForItsFirstSample() {
+ // Ties the reconciliation to the decision it feeds: 0 means waiting_first_sample, so any
+ // evidence of delivery has to survive into that call.
+ long observed = SensorDiagnostics.observedLatest(0L, 999_999L);
+ assertEquals("collecting", SensorDiagnostics.stateGiven(
+ "", true, false, 1_000_000L, observed, 120_000L));
+ }
+
+ @Test
+ public void localRowsCountWhenNothingHasBeenDeliveredYet() {
+ // A fresh enrolment, or an upload outage: the local table is the only record.
+ assertEquals(999_999L, SensorDiagnostics.observedLatest(999_999L, 0L));
+ }
+
+ @Test
+ public void theLaterOfTheTwoWins() {
+ // Local rows are newer than the bookmark whenever data has arrived since the last upload.
+ assertEquals(999_999L, SensorDiagnostics.observedLatest(999_999L, 500_000L));
+ assertEquals(999_999L, SensorDiagnostics.observedLatest(500_000L, 999_999L));
+ }
+
+ @Test
+ public void noDataAnywhereIsStillNever() {
+ // The genuine case must survive: a sensor that really has not collected reports 0.
+ assertEquals(0L, SensorDiagnostics.observedLatest(0L, 0L));
+ assertEquals("waiting_first_sample", SensorDiagnostics.stateGiven(
+ "", true, false, 1_000_000L, 0L, 120_000L));
+ }
+}
diff --git a/aware-core/src/test/java/com/aware/utils/SensorFreshnessTest.java b/aware-core/src/test/java/com/aware/utils/SensorFreshnessTest.java
new file mode 100644
index 00000000..8c8f97a3
--- /dev/null
+++ b/aware-core/src/test/java/com/aware/utils/SensorFreshnessTest.java
@@ -0,0 +1,46 @@
+package com.aware.utils;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+public class SensorFreshnessTest {
+
+ @Test
+ public void fastSensor_usesTwoMinuteFloor() {
+ assertEquals(
+ 120_000L,
+ SensorFreshness.windowMs("20000", 200000, SensorFreshness.Unit.MICROSECONDS));
+ }
+
+ @Test
+ public void fiveMinuteSensor_usesThreeIntervals() {
+ assertEquals(
+ 15L * 60L * 1000L,
+ SensorFreshness.windowMs("300", 60, SensorFreshness.Unit.SECONDS));
+ }
+
+ @Test
+ public void extremeInterval_isCappedAtOneDay() {
+ assertEquals(
+ SensorFreshness.MAX_WINDOW_MS,
+ SensorFreshness.windowMs("1000000", 60, SensorFreshness.Unit.MINUTES));
+ }
+
+ @Test
+ public void invalidValue_usesDefault() {
+ assertEquals(
+ 3L * 60L * 1000L,
+ SensorFreshness.windowMs("invalid", 60, SensorFreshness.Unit.SECONDS));
+ }
+
+ @Test
+ public void freshness_includesDeadlineAndRejectsFutureOrMissingRows() {
+ assertTrue(SensorFreshness.isFresh(1_000_000L, 880_000L, 120_000L));
+ assertFalse(SensorFreshness.isFresh(1_000_001L, 880_000L, 120_000L));
+ assertFalse(SensorFreshness.isFresh(1_000_000L, 0L, 120_000L));
+ assertFalse(SensorFreshness.isFresh(1_000_000L, 1_000_001L, 120_000L));
+ }
+}
diff --git a/aware-core/src/test/java/com/aware/utils/SensorThresholdsTest.java b/aware-core/src/test/java/com/aware/utils/SensorThresholdsTest.java
new file mode 100644
index 00000000..a38a695a
--- /dev/null
+++ b/aware-core/src/test/java/com/aware/utils/SensorThresholdsTest.java
@@ -0,0 +1,177 @@
+package com.aware.utils;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import com.aware.Aware_Preferences;
+
+import org.junit.Test;
+
+/**
+ * Pins the unit and usable range of every sensitivity threshold, and the rejection of values past
+ * that range.
+ *
+ * A deployed study config set threshold_accelerometer to 120 m/s² and threshold_magnetometer to
+ * 1,000,000 µT. Those exceed anything the hardware produces, so every sample was filtered out and
+ * the seven threshold-filtered physical sensors held four rows between them for the whole study,
+ * while each reported itself as enabled. These tests are the guard against reintroducing that.
+ */
+public class SensorThresholdsTest {
+
+ private static final String[] ALL = {
+ Aware_Preferences.THRESHOLD_ACCELEROMETER,
+ Aware_Preferences.THRESHOLD_LINEAR_ACCELEROMETER,
+ Aware_Preferences.THRESHOLD_GRAVITY,
+ Aware_Preferences.THRESHOLD_GYROSCOPE,
+ Aware_Preferences.THRESHOLD_ROTATION,
+ Aware_Preferences.THRESHOLD_MAGNETOMETER,
+ Aware_Preferences.THRESHOLD_BAROMETER,
+ Aware_Preferences.THRESHOLD_LIGHT,
+ Aware_Preferences.THRESHOLD_TEMPERATURE,
+ Aware_Preferences.THRESHOLD_PROXIMITY,
+ };
+
+ @Test
+ public void everyThresholdSettingHasAUnitAndALimit() {
+ for (String key : ALL) {
+ SensorThresholds.Spec spec = SensorThresholds.of(key);
+ assertNotNull(key + " has no spec", spec);
+ assertFalse(key + " has no unit", spec.unit.isEmpty());
+ assertTrue(key + " has a non-positive limit", spec.limit > 0);
+ assertTrue(key + " has an odd axis count", spec.axes == 1 || spec.axes == 3);
+ }
+ }
+
+ @Test
+ public void nonThresholdSettingsHaveNoSpec() {
+ assertNull(SensorThresholds.of(Aware_Preferences.FREQUENCY_ACCELEROMETER));
+ assertNull(SensorThresholds.of("status_wifi"));
+ assertNull(SensorThresholds.of(null));
+ assertFalse(SensorThresholds.isThreshold("status_wifi"));
+ assertTrue(SensorThresholds.isThreshold(Aware_Preferences.THRESHOLD_LIGHT));
+ }
+
+ @Test
+ public void threeAxisSensorsAreTheMotionAndFieldOnes() {
+ assertEquals(3, SensorThresholds.of(Aware_Preferences.THRESHOLD_ACCELEROMETER).axes);
+ assertEquals(3, SensorThresholds.of(Aware_Preferences.THRESHOLD_GYROSCOPE).axes);
+ assertEquals(3, SensorThresholds.of(Aware_Preferences.THRESHOLD_MAGNETOMETER).axes);
+ assertEquals(1, SensorThresholds.of(Aware_Preferences.THRESHOLD_LIGHT).axes);
+ assertEquals(1, SensorThresholds.of(Aware_Preferences.THRESHOLD_BAROMETER).axes);
+ }
+
+ @Test
+ public void zeroIsAlwaysValidBecauseItDisablesFiltering() {
+ for (String key : ALL) {
+ assertTrue(key + " rejected 0", SensorThresholds.isWithinRange(key, 0));
+ }
+ }
+
+ @Test
+ public void negativeValuesAreRejected() {
+ for (String key : ALL) {
+ assertFalse(key + " accepted a negative", SensorThresholds.isWithinRange(key, -1));
+ }
+ }
+
+ @Test
+ public void aValueAtTheLimitIsStillAccepted() {
+ for (String key : ALL) {
+ double limit = SensorThresholds.of(key).limit;
+ assertTrue(key + " rejected its own limit",
+ SensorThresholds.isWithinRange(key, limit));
+ assertFalse(key + " accepted just past its limit",
+ SensorThresholds.isWithinRange(key, limit + 0.001));
+ }
+ }
+
+ /** The values that were live in a deployed study config. */
+ @Test
+ public void theDeployedStudysThresholdsAreRejected() {
+ assertFalse(SensorThresholds.isWithinRange(
+ Aware_Preferences.THRESHOLD_ACCELEROMETER, 120));
+ assertFalse(SensorThresholds.isWithinRange(
+ Aware_Preferences.THRESHOLD_LINEAR_ACCELEROMETER, 100));
+ assertFalse(SensorThresholds.isWithinRange(
+ Aware_Preferences.THRESHOLD_GRAVITY, 10));
+ assertFalse(SensorThresholds.isWithinRange(
+ Aware_Preferences.THRESHOLD_GYROSCOPE, 10));
+ assertFalse(SensorThresholds.isWithinRange(
+ Aware_Preferences.THRESHOLD_ROTATION, 10));
+ assertFalse(SensorThresholds.isWithinRange(
+ Aware_Preferences.THRESHOLD_MAGNETOMETER, 1000000));
+ assertFalse(SensorThresholds.isWithinRange(
+ Aware_Preferences.THRESHOLD_PROXIMITY, 10));
+ assertFalse(SensorThresholds.isWithinRange(
+ Aware_Preferences.THRESHOLD_BAROMETER, 10));
+ assertFalse(SensorThresholds.isWithinRange(
+ Aware_Preferences.THRESHOLD_TEMPERATURE, 100));
+ }
+
+ /**
+ * Light is the exception. Illuminance really does swing by hundreds of lux, so the deployed
+ * 100 was not impossible - only coarser than any tier worth recommending, since it discards
+ * the whole evening and night-time range.
+ */
+ @Test
+ public void theDeployedLightThresholdIsCoarseButNotRejected() {
+ assertTrue(SensorThresholds.isWithinRange(Aware_Preferences.THRESHOLD_LIGHT, 100));
+ }
+
+ @Test
+ public void theRecommendedPresetsAreAllAccepted() {
+ assertTrue(SensorThresholds.isWithinRange(
+ Aware_Preferences.THRESHOLD_ACCELEROMETER, 0.05));
+ assertTrue(SensorThresholds.isWithinRange(
+ Aware_Preferences.THRESHOLD_ACCELEROMETER, 1.0));
+ assertTrue(SensorThresholds.isWithinRange(Aware_Preferences.THRESHOLD_GYROSCOPE, 0.01));
+ assertTrue(SensorThresholds.isWithinRange(Aware_Preferences.THRESHOLD_ROTATION, 0.005));
+ assertTrue(SensorThresholds.isWithinRange(Aware_Preferences.THRESHOLD_MAGNETOMETER, 10));
+ assertTrue(SensorThresholds.isWithinRange(Aware_Preferences.THRESHOLD_BAROMETER, 0.4));
+ assertTrue(SensorThresholds.isWithinRange(Aware_Preferences.THRESHOLD_LIGHT, 50));
+ assertTrue(SensorThresholds.isWithinRange(Aware_Preferences.THRESHOLD_TEMPERATURE, 0.5));
+ }
+
+ @Test
+ public void explainStatesTheUnitAndWhatTheValueDoes() {
+ String text = SensorThresholds.explain(Aware_Preferences.THRESHOLD_ACCELEROMETER, 0.3);
+ assertTrue(text, text.contains("0.3"));
+ assertTrue(text, text.contains("m/s²"));
+ assertTrue(text, text.contains("all three axes"));
+ }
+
+ @Test
+ public void explainOmitsTheAxisRuleForSingleValueSensors() {
+ String text = SensorThresholds.explain(Aware_Preferences.THRESHOLD_LIGHT, 10);
+ assertTrue(text, text.contains("lux"));
+ assertFalse(text, text.contains("all three axes"));
+ }
+
+ @Test
+ public void explainCallsOutAnOutOfRangeValue() {
+ String text = SensorThresholds.explain(Aware_Preferences.THRESHOLD_ACCELEROMETER, 120);
+ assertTrue(text, text.contains("120"));
+ assertTrue(text, text.contains("20"));
+ assertTrue(text, text.contains("records nothing"));
+ }
+
+ @Test
+ public void explainDescribesZeroAsNoFiltering() {
+ String text = SensorThresholds.explain(Aware_Preferences.THRESHOLD_GRAVITY, 0);
+ assertTrue(text, text.contains("every sample"));
+ }
+
+ @Test
+ public void explainRejectsANegative() {
+ assertEquals("Enter 0 or more.",
+ SensorThresholds.explain(Aware_Preferences.THRESHOLD_GRAVITY, -0.5));
+ }
+
+ @Test
+ public void explainIsEmptyForANonThresholdSetting() {
+ assertEquals("", SensorThresholds.explain("status_wifi", 1));
+ }
+}
diff --git a/aware-core/src/test/java/com/aware/utils/StudyConfigValidationTest.java b/aware-core/src/test/java/com/aware/utils/StudyConfigValidationTest.java
new file mode 100644
index 00000000..06c7813c
--- /dev/null
+++ b/aware-core/src/test/java/com/aware/utils/StudyConfigValidationTest.java
@@ -0,0 +1,198 @@
+package com.aware.utils;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.junit.Test;
+
+/**
+ * Unit tests for the pure half of study-config validation: the schema and password-presence checks
+ * that run before any database connection is attempted.
+ *
+ * These exist because the join screen's participant-facing message depends on telling the failures
+ * apart — "the researcher's config is broken" (nothing the participant can do), "you still need to
+ * type the study password", "the password is wrong", and "the database is unreachable" used to
+ * collapse into one boolean, so a down server was reported as "Password not correct". The
+ * connection-dependent half (AUTH_FAILED vs UNREACHABLE) is covered by {@link JdbcClassifyTest}.
+ */
+public class StudyConfigValidationTest {
+
+ /** A config that passes every check made without touching the network. */
+ private static JSONObject validConfig() throws JSONException {
+ JSONObject config = new JSONObject();
+ for (String key : new String[]{"questions", "schedules", "sensors", "study_info"}) {
+ config.put(key, new JSONObject());
+ }
+ config.put("database", database());
+ return config;
+ }
+
+ private static JSONObject database() throws JSONException {
+ JSONObject db = new JSONObject();
+ db.put("database_host", "db.example.org");
+ db.put("database_port", "3306");
+ db.put("database_name", "study");
+ db.put("database_username", "participant");
+ db.put("database_password", "from-config");
+ return db;
+ }
+
+ @Test
+ public void completeConfigIsMissingNothing() throws JSONException {
+ assertNull(StudyUtils.firstMissingRequirement(validConfig()));
+ }
+
+ @Test
+ public void nullConfigReportsTheConfigItself() {
+ assertEquals("study configuration", StudyUtils.firstMissingRequirement(null));
+ }
+
+ @Test
+ public void emptyConfigReportsAMissingTopLevelKey() {
+ // Which key is named first is REQUIRED_STUDY_CONFIG_KEYS' business; what matters is that an
+ // empty config is rejected by name rather than reaching a connection attempt.
+ assertNotNull(StudyUtils.firstMissingRequirement(new JSONObject()));
+ }
+
+ @Test
+ public void missingTopLevelKeyIsReportedByName() throws JSONException {
+ JSONObject config = validConfig();
+ config.remove("sensors");
+ assertEquals("sensors", StudyUtils.firstMissingRequirement(config));
+ }
+
+ @Test
+ public void databaseThatIsNotAnObjectIsRejected() throws JSONException {
+ JSONObject config = validConfig();
+ config.put("database", "jdbc:mysql://db.example.org:3306/study");
+ assertEquals("database", StudyUtils.firstMissingRequirement(config));
+ }
+
+ @Test
+ public void missingDatabaseFieldIsReportedByName() throws JSONException {
+ // Caught here rather than left to fail as a connection error, so "config is broken" stays
+ // distinguishable from "server is down".
+ for (String field : new String[]{"database_host", "database_port", "database_name",
+ "database_username"}) {
+ JSONObject config = validConfig();
+ config.getJSONObject("database").remove(field);
+ assertEquals(field, StudyUtils.firstMissingRequirement(config));
+ }
+ }
+
+ @Test
+ public void emptyDatabaseFieldCountsAsMissing() throws JSONException {
+ JSONObject config = validConfig();
+ config.getJSONObject("database").put("database_host", "");
+ assertEquals("database_host", StudyUtils.firstMissingRequirement(config));
+ }
+
+ @Test
+ public void databasePasswordIsNotARequiredField() throws JSONException {
+ // A password-join study ships no database_password at all; requiring it here would reject
+ // every config_without_password=true study before the participant could type anything.
+ JSONObject config = validConfig();
+ config.getJSONObject("database").remove("database_password");
+ assertNull(StudyUtils.firstMissingRequirement(config));
+ }
+
+ @Test
+ public void configPasswordIsNotRequiredFromTheParticipant() throws JSONException {
+ // config_without_password absent → the config carries its own password.
+ assertFalse(StudyUtils.requiresParticipantPassword(validConfig()));
+ }
+
+ @Test
+ public void explicitFalseDoesNotRequireAParticipantPassword() throws JSONException {
+ JSONObject config = validConfig();
+ config.getJSONObject("database").put("config_without_password", false);
+ assertFalse(StudyUtils.requiresParticipantPassword(config));
+ }
+
+ @Test
+ public void passwordJoinStudyRequiresAParticipantPassword() throws JSONException {
+ JSONObject config = validConfig();
+ config.getJSONObject("database").put("config_without_password", true);
+ assertTrue(StudyUtils.requiresParticipantPassword(config));
+ }
+
+ @Test
+ public void nullConfigNeverRequiresAPassword() {
+ // Guards the re-auth path: an unfetched config must not be read as "ask the participant".
+ assertFalse(StudyUtils.requiresParticipantPassword(null));
+ }
+
+ @Test
+ public void configWithoutDatabaseNeverRequiresAPassword() {
+ assertFalse(StudyUtils.requiresParticipantPassword(new JSONObject()));
+ }
+
+ // --- Which outcomes still let a downloaded config be applied ---
+ //
+ // Config retrieval and upload connectivity are separate operations, so a database outage must
+ // not report a valid downloaded config as broken, nor hold every phone on a stale config for
+ // the length of the outage.
+
+ @Test
+ public void aValidConfigIsApplied() {
+ assertTrue(StudyUtils.configIsApplicable(StudyUtils.StudyConfigValidation.OK));
+ }
+
+ @Test
+ public void anUnreachableDatabaseDoesNotBlockApplyingTheConfig() {
+ assertTrue(StudyUtils.configIsApplicable(StudyUtils.StudyConfigValidation.UNREACHABLE));
+ }
+
+ @Test
+ public void aBrokenConfigIsNotApplied() {
+ assertFalse(StudyUtils.configIsApplicable(StudyUtils.StudyConfigValidation.INVALID_CONFIG));
+ }
+
+ @Test
+ public void aRejectedPasswordDoesNotApplyTheConfig() {
+ // The credentials in the config are the ones the upload will use; adopting a config whose
+ // password is actively refused would replace a working stored password with a dead one.
+ assertFalse(StudyUtils.configIsApplicable(StudyUtils.StudyConfigValidation.AUTH_FAILED));
+ assertFalse(StudyUtils.configIsApplicable(StudyUtils.StudyConfigValidation.PASSWORD_REQUIRED));
+ }
+
+ @Test
+ public void everyOutcomeIsClassifiedAsApplicableOrNot() {
+ // A new enum value must be a deliberate decision here rather than silently falling into
+ // "not applicable" and blocking config sync for a reason nobody chose.
+ for (StudyUtils.StudyConfigValidation value : StudyUtils.StudyConfigValidation.values()) {
+ boolean known = value == StudyUtils.StudyConfigValidation.OK
+ || value == StudyUtils.StudyConfigValidation.UNREACHABLE
+ || value == StudyUtils.StudyConfigValidation.INVALID_CONFIG
+ || value == StudyUtils.StudyConfigValidation.AUTH_FAILED
+ || value == StudyUtils.StudyConfigValidation.PASSWORD_REQUIRED;
+ assertTrue("Unclassified validation outcome: " + value, known);
+ }
+ }
+
+ // --- Which outcomes send the participant back to the password prompt ---
+
+ @Test
+ public void aRejectedOrMissingPasswordPromptsForReauthentication() {
+ assertTrue(StudyUtils.needsParticipantReauth(StudyUtils.StudyConfigValidation.AUTH_FAILED));
+ assertTrue(StudyUtils.needsParticipantReauth(StudyUtils.StudyConfigValidation.PASSWORD_REQUIRED));
+ }
+
+ @Test
+ public void anOutageDoesNotPromptForReauthentication() {
+ // Getting UNREACHABLE wrong here asks the participant to re-type a password that was never
+ // the problem.
+ assertFalse(StudyUtils.needsParticipantReauth(StudyUtils.StudyConfigValidation.UNREACHABLE));
+ }
+
+ @Test
+ public void aBrokenConfigDoesNotPromptForReauthentication() {
+ assertFalse(StudyUtils.needsParticipantReauth(StudyUtils.StudyConfigValidation.INVALID_CONFIG));
+ assertFalse(StudyUtils.needsParticipantReauth(StudyUtils.StudyConfigValidation.OK));
+ }
+}
diff --git a/aware-core/src/test/java/com/aware/utils/StudyDataflowTest.java b/aware-core/src/test/java/com/aware/utils/StudyDataflowTest.java
new file mode 100644
index 00000000..51dbf8a1
--- /dev/null
+++ b/aware-core/src/test/java/com/aware/utils/StudyDataflowTest.java
@@ -0,0 +1,144 @@
+package com.aware.utils;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.junit.Test;
+
+/**
+ * Unit tests for reading a study's dataflow out of its configuration, and for the
+ * schema check that depends on it.
+ *
+ * A study whose data goes through the webservice ships no database block at all,
+ * deliberately: the phone never contacts MySQL on that path, and the config is
+ * served from a public URL, so an address and an account there would be a
+ * credential handed to every participant for nothing. That makes "database" a
+ * requirement of the direct path rather than of every config — and getting this
+ * wrong is not subtle, it rejects the config outright and the participant cannot
+ * join at all.
+ *
+ * The inferred case matters as much as the declared one. The explicit `dataflow`
+ * field is new, so every config written before it — including the copy every
+ * already-enrolled phone holds — has to be read from `status_webservice` instead.
+ */
+public class StudyDataflowTest {
+
+ private static JSONObject settings(String setting, String value) throws JSONException {
+ JSONObject entry = new JSONObject();
+ entry.put("setting", setting);
+ entry.put("value", value);
+ return entry;
+ }
+
+ /** A webservice config: no database block, a study URL, the channel on. */
+ private static JSONObject webserviceConfig() throws JSONException {
+ JSONObject config = new JSONObject();
+ for (String key : new String[]{"questions", "schedules", "study_info"}) {
+ config.put(key, new JSONObject());
+ }
+ config.put("dataflow", "webservice");
+ config.put("sensors", new JSONArray()
+ .put(settings("status_webservice", "true"))
+ .put(settings("webservice_server", "https://study.example.org/1/KEY")));
+ return config;
+ }
+
+ private static JSONObject directConfig() throws JSONException {
+ JSONObject config = new JSONObject();
+ for (String key : new String[]{"questions", "schedules", "study_info"}) {
+ config.put(key, new JSONObject());
+ }
+ JSONObject db = new JSONObject();
+ db.put("database_host", "db.example.org");
+ db.put("database_port", "3306");
+ db.put("database_name", "study");
+ db.put("database_username", "participant");
+ config.put("database", db);
+ config.put("dataflow", "direct");
+ config.put("sensors", new JSONArray().put(settings("status_webservice", "false")));
+ return config;
+ }
+
+ @Test
+ public void readsTheDeclaredDataflow() throws JSONException {
+ assertTrue(StudyUtils.usesWebservice(webserviceConfig()));
+ assertFalse(StudyUtils.usesWebservice(directConfig()));
+ }
+
+ @Test
+ public void fallsBackToTheChannelSettingWhenNoFieldIsDeclared() throws JSONException {
+ JSONObject config = webserviceConfig();
+ config.remove("dataflow");
+
+ assertTrue("a config predating the field is every enrolled phone's copy",
+ StudyUtils.usesWebservice(config));
+ }
+
+ @Test
+ public void theDeclaredFieldWinsOverTheChannelSetting() throws JSONException {
+ JSONObject config = directConfig();
+ config.put("sensors", new JSONArray().put(settings("status_webservice", "true")));
+
+ assertFalse(StudyUtils.usesWebservice(config));
+ }
+
+ @Test
+ public void aConfigWithNeitherSignalReadsAsDirect() throws JSONException {
+ JSONObject config = new JSONObject();
+ config.put("sensors", new JSONArray());
+
+ assertFalse(StudyUtils.usesWebservice(config));
+ }
+
+ @Test
+ public void aNullConfigReadsAsDirect() {
+ assertFalse(StudyUtils.usesWebservice(null));
+ }
+
+ @Test
+ public void aWebserviceConfigNeedsNoDatabaseBlock() throws JSONException {
+ assertNull("a credential-less config must be joinable",
+ StudyUtils.firstMissingRequirement(webserviceConfig()));
+ }
+
+ @Test
+ public void aWebserviceConfigStillNeedsItsStudyUrl() throws JSONException {
+ JSONObject config = webserviceConfig();
+ config.put("sensors", new JSONArray().put(settings("status_webservice", "true")));
+
+ assertEquals("webservice_server", StudyUtils.firstMissingRequirement(config));
+ }
+
+ @Test
+ public void aDirectConfigStillNeedsItsDatabaseBlock() throws JSONException {
+ JSONObject config = directConfig();
+ config.remove("database");
+
+ assertEquals("database", StudyUtils.firstMissingRequirement(config));
+ }
+
+ @Test
+ public void aDirectConfigStillNeedsEveryDatabaseField() throws JSONException {
+ JSONObject config = directConfig();
+ config.getJSONObject("database").put("database_host", "");
+
+ assertEquals("database_host", StudyUtils.firstMissingRequirement(config));
+ }
+
+ @Test
+ public void readsANamedSettingOutOfTheSensorsList() throws JSONException {
+ assertEquals("https://study.example.org/1/KEY",
+ StudyUtils.settingValue(webserviceConfig(), "webservice_server"));
+ }
+
+ @Test
+ public void anAbsentSettingReadsAsEmptyRatherThanNull() throws JSONException {
+ assertEquals("", StudyUtils.settingValue(webserviceConfig(), "not_a_setting"));
+ assertEquals("", StudyUtils.settingValue(null, "status_webservice"));
+ }
+}
diff --git a/aware-core/src/test/java/com/aware/utils/StudyUtilsTest.java b/aware-core/src/test/java/com/aware/utils/StudyUtilsTest.java
new file mode 100644
index 00000000..c61e0d41
--- /dev/null
+++ b/aware-core/src/test/java/com/aware/utils/StudyUtilsTest.java
@@ -0,0 +1,431 @@
+package com.aware.utils;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.junit.Test;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Regression test for StudyUtils.jsonEquals(). Locks in the fix that switched the comparison from
+ * JSONAssert's LENIENT mode to NON_EXTENSIBLE: LENIENT treats arrays as a one-directional subset
+ * check, so a config edit that only *adds* an entry to the "sensors" array (rather than toggling
+ * an existing entry's value) compared as equal to the old config and was silently ignored by
+ * syncStudyConfig() — a real bug that shipped and was only caught by on-device testing.
+ */
+public class StudyUtilsTest {
+
+ private static JSONObject configWithSensors(JSONObject... sensors) throws JSONException {
+ JSONArray sensorsArray = new JSONArray();
+ for (JSONObject sensor : sensors) sensorsArray.put(sensor);
+ JSONObject config = new JSONObject();
+ config.put("sensors", sensorsArray);
+ return config;
+ }
+
+ private static JSONObject sensor(String setting, boolean value) throws JSONException {
+ return sensorValue(setting, value);
+ }
+
+ private static JSONObject sensorValue(String setting, Object value) throws JSONException {
+ JSONObject sensor = new JSONObject();
+ sensor.put("setting", setting);
+ sensor.put("value", value);
+ return sensor;
+ }
+
+ @Test
+ public void editableSync_skipsAutomaticUpdates() {
+ assertTrue(StudyUtils.shouldSkipAutomaticConfigSync(true, false));
+ }
+
+ @Test
+ public void editableSync_manualCheckAppliesServerUpdate() {
+ assertFalse(StudyUtils.shouldSkipAutomaticConfigSync(true, true));
+ }
+
+ @Test
+ public void lockedSync_keepsAutomaticUpdates() {
+ assertFalse(StudyUtils.shouldSkipAutomaticConfigSync(false, false));
+ }
+
+ @Test
+ public void editableManualUpdate_requiresPreviewBeforeApplying() {
+ assertTrue(StudyUtils.shouldPreviewManualConfigUpdate(
+ true, true, false, false));
+ }
+
+ @Test
+ public void editableManualUpdate_matchingApprovalCanApply() {
+ assertFalse(StudyUtils.shouldPreviewManualConfigUpdate(
+ true, true, false, true));
+ }
+
+ @Test
+ public void lockedManualUpdateDoesNotRequireParticipantApproval() {
+ assertFalse(StudyUtils.shouldPreviewManualConfigUpdate(
+ false, true, false, false));
+ }
+
+ @Test
+ public void unchangedManualUpdateNeedsNoApproval() {
+ assertFalse(StudyUtils.shouldPreviewManualConfigUpdate(
+ true, true, true, false));
+ }
+
+ @Test
+ public void editableSettingUpdate_preservesTypesAndAddsNewSettings() throws JSONException {
+ JSONObject config = configWithSensors(
+ sensor("status_accelerometer", true),
+ sensorValue("frequency_accelerometer", 20000));
+
+ JSONObject changedStatus =
+ StudyUtils.withSensorSetting(config, "status_accelerometer", "false");
+ JSONObject changedFrequency =
+ StudyUtils.withSensorSetting(changedStatus, "frequency_accelerometer", "400000");
+ JSONObject added =
+ StudyUtils.withSensorSetting(changedFrequency, "frequency_light", "2.5");
+
+ JSONArray sensors = added.getJSONArray("sensors");
+ assertFalse(sensors.getJSONObject(0).getBoolean("value"));
+ assertEquals(400000, sensors.getJSONObject(1).getInt("value"));
+ assertEquals(2.5, sensors.getJSONObject(2).getDouble("value"), 0.0);
+ }
+
+ @Test
+ public void additiveOnlySensorChange_isNotEqual() throws JSONException {
+ JSONObject before = configWithSensors(sensor("status_location_gps", true));
+ JSONObject after = configWithSensors(sensor("status_location_gps", true), sensor("status_wifi", true));
+
+ // This is the exact regression: under the old LENIENT mode, "after" being a superset of
+ // "before" compared as equal, so the newly-added sensor was never detected or applied.
+ assertFalse(StudyUtils.jsonEquals(before, after));
+ }
+
+ @Test
+ public void removedSensor_isNotEqual() throws JSONException {
+ JSONObject before = configWithSensors(sensor("status_location_gps", true), sensor("status_wifi", true));
+ JSONObject after = configWithSensors(sensor("status_location_gps", true));
+
+ assertFalse(StudyUtils.jsonEquals(before, after));
+ }
+
+ @Test
+ public void identicalConfigs_areEqual() throws JSONException {
+ JSONObject before = configWithSensors(sensor("status_location_gps", true), sensor("status_wifi", false));
+ JSONObject after = configWithSensors(sensor("status_location_gps", true), sensor("status_wifi", false));
+
+ assertTrue(StudyUtils.jsonEquals(before, after));
+ }
+
+ /**
+ * Regression test for the config-sync gate bug: syncStudyConfig() used to compare only the
+ * stored config blob against the freshly-fetched server config (jsonEquals) and return early
+ * the moment those matched — never checking whether the device's *live* aware_settings still
+ * agreed. A drift (e.g. from Aware.reset() or an interrupted apply, config text unchanged)
+ * therefore went undetected forever. driftSignature() is the fix's pure comparison core: given
+ * a config and a map of what's actually live, it reports every status_* mismatch.
+ */
+ private static final Set NO_HARDWARE_EXCLUSIONS = Collections.emptySet();
+
+ @Test
+ public void driftSignature_matchingLiveSettings_isEmpty() throws JSONException {
+ JSONObject config = configWithSensors(sensor("status_location_gps", true), sensor("status_wifi", false));
+ Map live = new HashMap<>();
+ live.put("status_location_gps", "true");
+ live.put("status_wifi", "false");
+
+ assertEquals("", StudyUtils.driftSignature(config, live, NO_HARDWARE_EXCLUSIONS));
+ }
+
+ @Test
+ public void driftSignature_liveSettingOff_configSaysOn_isDetected() throws JSONException {
+ // The exact failure mode this exists to catch: config still says the sensor should be on,
+ // but the live setting somehow reads off (e.g. Aware.reset() ran without a full re-apply).
+ JSONObject config = configWithSensors(sensor("status_location_gps", true));
+ Map live = new HashMap<>();
+ live.put("status_location_gps", "false");
+
+ String signature = StudyUtils.driftSignature(config, live, NO_HARDWARE_EXCLUSIONS);
+ assertFalse(signature.isEmpty());
+ assertTrue(signature.contains("status_location_gps"));
+ }
+
+ @Test
+ public void driftSignature_missingLiveValue_treatedAsMismatch() throws JSONException {
+ // A setting the config expects "true" for but that was never applied at all (not present
+ // in the live map) must count as drift too, not be silently skipped.
+ JSONObject config = configWithSensors(sensor("status_wifi", true));
+ Map live = new HashMap<>();
+
+ assertFalse(StudyUtils.driftSignature(config, live, NO_HARDWARE_EXCLUSIONS).isEmpty());
+ }
+
+ @Test
+ public void driftSignature_nonStatusSettingMismatch_isIgnored() throws JSONException {
+ // Only status_* (on/off) settings are in scope — a frequency/threshold mismatch doesn't
+ // mean a sensor is silently not collecting, just that its granularity differs, so it
+ // shouldn't trigger the self-heal reapply path.
+ JSONObject config = configWithSensors(sensor("frequency_light", true));
+ Map live = new HashMap<>();
+ live.put("frequency_light", "false");
+
+ assertEquals("", StudyUtils.driftSignature(config, live, NO_HARDWARE_EXCLUSIONS));
+ }
+
+ @Test
+ public void driftSignature_isStableRegardlessOfSensorOrder() throws JSONException {
+ // The signature is compared across polls (and persisted) to decide whether a drift is
+ // "the same one we already tried to fix" — if two configs describing the same drift in a
+ // different sensor order produced different signatures, the backoff guard would never
+ // recognize a repeat and would reapply on every single poll.
+ JSONObject configA = configWithSensors(sensor("status_location_gps", true), sensor("status_wifi", true));
+ JSONObject configB = configWithSensors(sensor("status_wifi", true), sensor("status_location_gps", true));
+ Map live = new HashMap<>(); // both off live, both configs say on
+
+ assertEquals(
+ StudyUtils.driftSignature(configA, live, NO_HARDWARE_EXCLUSIONS),
+ StudyUtils.driftSignature(configB, live, NO_HARDWARE_EXCLUSIONS));
+ }
+
+ /**
+ * Regression test for the hardware-exclusion refinement: a status_* setting for hardware this
+ * device doesn't have (e.g. status_temperature with no ambient temperature sensor) must never
+ * be reported as drift, no matter what its live value is — it's a permanent, known fact about
+ * the device, not something the 1-hour reconcile backoff should have to keep suppressing.
+ */
+ @Test
+ public void driftSignature_hardwareUnavailableSetting_neverReportedAsDrift() throws JSONException {
+ JSONObject config = configWithSensors(sensor("status_temperature", true));
+ Map live = new HashMap<>();
+ live.put("status_temperature", "false"); // would otherwise be a clear mismatch
+
+ Set hardwareUnavailable = new HashSet<>();
+ hardwareUnavailable.add("status_temperature");
+
+ assertEquals("", StudyUtils.driftSignature(config, live, hardwareUnavailable));
+ }
+
+ @Test
+ public void driftSignature_hardwareExclusion_onlyAppliesToNamedSetting() throws JSONException {
+ // A hardware exclusion for one sensor shouldn't hide drift in an unrelated sensor.
+ JSONObject config = configWithSensors(sensor("status_temperature", true), sensor("status_wifi", true));
+ Map live = new HashMap<>();
+ live.put("status_temperature", "false");
+ live.put("status_wifi", "false");
+
+ Set hardwareUnavailable = new HashSet<>();
+ hardwareUnavailable.add("status_temperature");
+
+ String signature = StudyUtils.driftSignature(config, live, hardwareUnavailable);
+ assertFalse(signature.isEmpty());
+ assertTrue(signature.contains("status_wifi"));
+ assertFalse(signature.contains("status_temperature"));
+ }
+
+ @Test
+ public void processorAvailability_isBlockedFromAndroidNougatOnward() {
+ assertTrue(SensorAvailability.isPlatformSupported(
+ "status_processor", android.os.Build.VERSION_CODES.M));
+ assertFalse(SensorAvailability.isPlatformSupported(
+ "status_processor", android.os.Build.VERSION_CODES.N));
+ assertFalse(SensorAvailability.isPlatformSupported(
+ "status_processor", 30));
+ }
+
+ @Test
+ public void consentRequiring_picksEnabledPermissionAndAccessibilitySensors() throws JSONException {
+ JSONObject config = configWithSensors(
+ sensor("status_location_gps", true), // runtime permission
+ sensor("status_applications", true), // accessibility service
+ sensor("status_battery", true)); // no gate
+
+ Set result = StudyUtils.consentRequiringEnabledSettings(new JSONArray().put(config));
+
+ assertTrue(result.contains("status_location_gps"));
+ assertTrue(result.contains("status_applications"));
+ assertFalse(result.contains("status_battery"));
+ }
+
+ @Test
+ public void consentRequiring_ignoresDisabledSensors() throws JSONException {
+ JSONObject config = configWithSensors(
+ sensor("status_location_gps", false),
+ sensor("status_battery", true));
+
+ assertTrue(StudyUtils.consentRequiringEnabledSettings(new JSONArray().put(config)).isEmpty());
+ }
+
+ @Test
+ public void consentRequiring_baseOnlyConfig_isEmpty() throws JSONException {
+ JSONObject config = configWithSensors(
+ sensor("status_battery", true),
+ sensor("status_screen", true),
+ sensor("status_accelerometer", true));
+
+ assertTrue(StudyUtils.consentRequiringEnabledSettings(new JSONArray().put(config)).isEmpty());
+ }
+
+ @Test
+ public void consentRequiring_ambientNoiseNeedsMicrophoneConsent() throws JSONException {
+ JSONObject config = configWithSensors(
+ sensor("status_plugin_ambient_noise", true));
+
+ assertTrue(StudyUtils.consentRequiringEnabledSettings(new JSONArray().put(config))
+ .contains("status_plugin_ambient_noise"));
+ }
+
+ @Test
+ public void consentRequiring_openWeatherNeedsLocationConsent() throws JSONException {
+ JSONObject config = configWithSensors(
+ sensor("status_plugin_openweather", true));
+
+ assertTrue(StudyUtils.consentRequiringEnabledSettings(new JSONArray().put(config))
+ .contains("status_plugin_openweather"));
+ }
+
+ @Test
+ public void groupedConsent_decliningApplicationsAlsoDeclinesInstallations() {
+ Set declined = new HashSet<>();
+ declined.add("status_applications");
+
+ Set expanded = StudyUtils.expandGroupedConsentDeclines(declined);
+
+ assertTrue(expanded.contains("status_applications"));
+ assertTrue(expanded.contains("status_installations"));
+ }
+
+ @Test
+ public void groupedConsent_doesNotTreatStandaloneInstallationsAsAccessibilityGated() {
+ Set declined = new HashSet<>();
+ declined.add("status_battery");
+
+ Set expanded = StudyUtils.expandGroupedConsentDeclines(declined);
+
+ assertFalse(expanded.contains("status_installations"));
+ }
+
+ /**
+ * Regression test for the editable-mode preview loop: a manual "check for study updates" compared
+ * the raw config blobs, so a server config that enabled a sensor this device physically lacks
+ * (e.g. status_gyroscope on a phone with no gyroscope) never compared equal to the participant's
+ * kept config. "Keep my settings" could never reconcile it — the sensor can't be turned on — so
+ * the preview dialog reappeared on every check. configsEqualIgnoringSensors() is the pure core of
+ * the fix: two configs compare equal once the unactionable (unavailable-hardware) sensors are set
+ * aside, so a difference confined to them stops re-triggering the preview.
+ */
+ private static Set ignoring(String... settings) {
+ Set set = new HashSet<>();
+ Collections.addAll(set, settings);
+ return set;
+ }
+
+ @Test
+ public void configsEqualIgnoringSensors_differOnlyByIgnoredSensor_areEqual() throws JSONException {
+ JSONObject local = configWithSensors(sensor("status_wifi", true), sensor("status_gyroscope", false));
+ JSONObject server = configWithSensors(sensor("status_wifi", true), sensor("status_gyroscope", true));
+
+ assertFalse(StudyUtils.jsonEquals(local, server)); // the raw blobs still differ
+ assertTrue(StudyUtils.configsEqualIgnoringSensors(local, server, ignoring("status_gyroscope")));
+ }
+
+ @Test
+ public void configsEqualIgnoringSensors_ignoredSensorAddedByServer_areEqual() throws JSONException {
+ // The additive case: the server introduces a brand-new (unavailable) sensor entry the local
+ // config never had. Dropping it from both must still compare equal.
+ JSONObject local = configWithSensors(sensor("status_wifi", true));
+ JSONObject server = configWithSensors(sensor("status_wifi", true), sensor("status_gyroscope", true));
+
+ assertTrue(StudyUtils.configsEqualIgnoringSensors(local, server, ignoring("status_gyroscope")));
+ }
+
+ @Test
+ public void configsEqualIgnoringSensors_availableSensorAlsoDiffers_areNotEqual() throws JSONException {
+ // If an actionable (available) sensor also changed, the configs must NOT be treated as equal —
+ // the participant still needs the preview for that change.
+ JSONObject local = configWithSensors(sensor("status_wifi", false), sensor("status_gyroscope", false));
+ JSONObject server = configWithSensors(sensor("status_wifi", true), sensor("status_gyroscope", true));
+
+ assertFalse(StudyUtils.configsEqualIgnoringSensors(local, server, ignoring("status_gyroscope")));
+ }
+
+ @Test
+ public void configsEqualIgnoringSensors_emptyIgnoreSet_matchesJsonEquals() throws JSONException {
+ JSONObject local = configWithSensors(sensor("status_wifi", true));
+ JSONObject server = configWithSensors(sensor("status_wifi", false));
+
+ assertFalse(StudyUtils.configsEqualIgnoringSensors(local, server, NO_HARDWARE_EXCLUSIONS));
+ }
+
+ @Test
+ public void configsEqualIgnoringSensors_identicalConfigs_areEqual() throws JSONException {
+ JSONObject a = configWithSensors(sensor("status_wifi", true), sensor("status_gyroscope", true));
+ JSONObject b = configWithSensors(sensor("status_wifi", true), sensor("status_gyroscope", true));
+
+ assertTrue(StudyUtils.configsEqualIgnoringSensors(a, b, ignoring("status_gyroscope")));
+ }
+
+ /**
+ * Regression test for the "you're locked" over-notification: disabling editable mode told every
+ * participant their edit access changed even when it hadn't. enable_config_update absent means the
+ * default (researcher-controlled) state, so enableConfigUpdateChanged() must treat absent the same
+ * as false — only a real flip of effective editability returns a (non-null) new value.
+ */
+ private static JSONObject configWithEditable(Boolean editable) throws JSONException {
+ if (editable == null) return configWithSensors(sensor("status_wifi", true));
+ return configWithSensors(sensor("status_wifi", true),
+ sensor("enable_config_update", editable));
+ }
+
+ @Test
+ public void enableConfigUpdate_absentToFalse_isNotAChange() throws JSONException {
+ // The exact bug: a config that never spelled out enable_config_update, then sets it false.
+ // Effective state (locked) is unchanged, so nothing should notify the participant.
+ assertNull(StudyUtils.enableConfigUpdateChanged(configWithEditable(null), configWithEditable(false)));
+ }
+
+ @Test
+ public void enableConfigUpdate_falseToAbsent_isNotAChange() throws JSONException {
+ assertNull(StudyUtils.enableConfigUpdateChanged(configWithEditable(false), configWithEditable(null)));
+ }
+
+ @Test
+ public void enableConfigUpdate_falseToFalse_isNotAChange() throws JSONException {
+ assertNull(StudyUtils.enableConfigUpdateChanged(configWithEditable(false), configWithEditable(false)));
+ }
+
+ @Test
+ public void enableConfigUpdate_trueToTrue_isNotAChange() throws JSONException {
+ assertNull(StudyUtils.enableConfigUpdateChanged(configWithEditable(true), configWithEditable(true)));
+ }
+
+ @Test
+ public void enableConfigUpdate_editableDisabled_reportsFalse() throws JSONException {
+ // A genuine true → false flip: the participant should be informed, once.
+ assertEquals(Boolean.FALSE,
+ StudyUtils.enableConfigUpdateChanged(configWithEditable(true), configWithEditable(false)));
+ }
+
+ @Test
+ public void enableConfigUpdate_editableEnabled_reportsTrue() throws JSONException {
+ assertEquals(Boolean.TRUE,
+ StudyUtils.enableConfigUpdateChanged(configWithEditable(false), configWithEditable(true)));
+ }
+
+ @Test
+ public void enableConfigUpdate_absentToTrue_reportsTrue() throws JSONException {
+ // Locked-by-default → editable is a real change and should notify.
+ assertEquals(Boolean.TRUE,
+ StudyUtils.enableConfigUpdateChanged(configWithEditable(null), configWithEditable(true)));
+ }
+}
diff --git a/aware-core/src/test/java/com/aware/utils/SyncBatchBudgetTest.java b/aware-core/src/test/java/com/aware/utils/SyncBatchBudgetTest.java
new file mode 100644
index 00000000..3d16d77b
--- /dev/null
+++ b/aware-core/src/test/java/com/aware/utils/SyncBatchBudgetTest.java
@@ -0,0 +1,131 @@
+package com.aware.utils;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+/**
+ * Unit tests for the payload budget that bounds an upload batch by bytes rather than by row count
+ * alone.
+ *
+ * The row-count cap is payload-blind, so a screenshot backlog produced batches of gigabytes: too
+ * large for the phone's heap to hold as JSON and past the server's max_allowed_packet. Such a batch
+ * is retried unchanged forever — the sync marker never advances, local cleanup never runs, and the
+ * next batch is larger still — so the properties pinned here are the ones that decide whether a
+ * backlog drains or wedges.
+ */
+public class SyncBatchBudgetTest {
+
+ private static final long CAP = 1000;
+
+ @Test
+ public void aRowFittingInsideTheBudgetIsTaken() {
+ assertFalse(SyncBatchBudget.holdForNextBatch(5, 400, 100, CAP));
+ }
+
+ @Test
+ public void aRowExactlyFillingTheBudgetIsTaken() {
+ // Reaching the cap is fine; only passing it is not.
+ assertFalse(SyncBatchBudget.holdForNextBatch(5, 900, 100, CAP));
+ }
+
+ @Test
+ public void aRowPassingTheBudgetIsHeldBack() {
+ assertTrue(SyncBatchBudget.holdForNextBatch(5, 901, 100, CAP));
+ }
+
+ @Test
+ public void theFirstRowIsAlwaysTakenHoweverLarge() {
+ // The wedge guard. A batch of zero rows reports nothing uploaded, so the marker never
+ // advances and the same row is offered again on every sync, forever. Taking it means an
+ // unsendable row fails against the server once instead of stalling the table for good.
+ assertFalse(SyncBatchBudget.holdForNextBatch(0, 0, CAP * 1000, CAP));
+ }
+
+ @Test
+ public void anEmptyBatchNeverHoldsBack() {
+ assertFalse(SyncBatchBudget.holdForNextBatch(0, 0, 1, CAP));
+ assertFalse(SyncBatchBudget.holdForNextBatch(0, 0, 0, CAP));
+ }
+
+ @Test
+ public void columnBytesCountsTheNameTheValueAndTheOverhead() {
+ assertEquals("ts".length() + SyncBatchBudget.COLUMN_OVERHEAD_BYTES + 12,
+ SyncBatchBudget.columnBytes("ts", 12));
+ }
+
+ @Test
+ public void columnBytesIsDominatedByALargeValue() {
+ // A base64 screenshot is hundreds of thousands of characters; the column name is noise beside
+ // it, and the estimate must not lose it.
+ long imageBytes = 400_000;
+ assertTrue(SyncBatchBudget.columnBytes("image_data", (int) imageBytes) >= imageBytes);
+ }
+
+ @Test
+ public void aSensorTableStillGetsLargeRowBatches() {
+ // An accelerometer row: timestamp, device_id and three double columns. Ten thousand of them
+ // must stay comfortably inside the budget, or the byte cap would have quietly shrunk the
+ // batches of the tables that were never the problem.
+ long rowBytes = SyncBatchBudget.columnBytes("timestamp", SyncBatchBudget.NUMERIC_VALUE_BYTES)
+ + SyncBatchBudget.columnBytes("device_id", 36)
+ + SyncBatchBudget.columnBytes("double_values_0", SyncBatchBudget.NUMERIC_VALUE_BYTES)
+ + SyncBatchBudget.columnBytes("double_values_1", SyncBatchBudget.NUMERIC_VALUE_BYTES)
+ + SyncBatchBudget.columnBytes("double_values_2", SyncBatchBudget.NUMERIC_VALUE_BYTES)
+ + SyncBatchBudget.columnBytes("accuracy", SyncBatchBudget.NUMERIC_VALUE_BYTES)
+ + SyncBatchBudget.columnBytes("label", 0);
+
+ assertFalse("10,000 accelerometer rows should not hit the payload cap",
+ SyncBatchBudget.holdForNextBatch(9_999, rowBytes * 9_999, rowBytes,
+ SyncBatchBudget.MAX_PAYLOAD_BYTES));
+ }
+
+ @Test
+ public void aScreenshotBacklogIsSplitIntoManyBatches() {
+ // Walks the accumulation the way syncBatch does, over more screenshots than a single batch
+ // can hold, and checks every batch stays inside the budget and every row is eventually
+ // taken. This is the case that used to build one multi-gigabyte batch.
+ long rowBytes = SyncBatchBudget.columnBytes("image_data", 500_000)
+ + SyncBatchBudget.columnBytes("timestamp", SyncBatchBudget.NUMERIC_VALUE_BYTES)
+ + SyncBatchBudget.columnBytes("device_id", 36);
+
+ int pending = 200;
+ int taken = 0;
+ int batches = 0;
+ while (taken < pending) {
+ int rowsInBatch = 0;
+ long bytesInBatch = 0;
+ while (taken < pending && !SyncBatchBudget.holdForNextBatch(
+ rowsInBatch, bytesInBatch, rowBytes, SyncBatchBudget.MAX_PAYLOAD_BYTES)) {
+ rowsInBatch++;
+ bytesInBatch += rowBytes;
+ taken++;
+ }
+ batches++;
+ assertTrue("batch " + batches + " carried " + bytesInBatch + " bytes",
+ bytesInBatch <= SyncBatchBudget.MAX_PAYLOAD_BYTES);
+ assertTrue("a batch must make progress", rowsInBatch > 0);
+ }
+
+ assertEquals("every pending row is eventually taken", pending, taken);
+ assertTrue("a 200-screenshot backlog needs more than one batch", batches > 1);
+ }
+
+ @Test
+ public void aSingleRowLargerThanTheCapDoesNotStall() {
+ // 20 MB of base64 in one row: over the 8 MB budget, so it can only ever go alone.
+ long rowBytes = 20L * 1024 * 1024;
+
+ int rowsInBatch = 0;
+ long bytesInBatch = 0;
+ assertFalse(SyncBatchBudget.holdForNextBatch(rowsInBatch, bytesInBatch, rowBytes,
+ SyncBatchBudget.MAX_PAYLOAD_BYTES));
+ rowsInBatch++;
+ bytesInBatch += rowBytes;
+ // And it does not drag a second row along with it.
+ assertTrue(SyncBatchBudget.holdForNextBatch(rowsInBatch, bytesInBatch, 1,
+ SyncBatchBudget.MAX_PAYLOAD_BYTES));
+ }
+}
diff --git a/aware-core/src/test/java/com/aware/utils/SyncCursorTest.java b/aware-core/src/test/java/com/aware/utils/SyncCursorTest.java
new file mode 100644
index 00000000..b8dd852e
--- /dev/null
+++ b/aware-core/src/test/java/com/aware/utils/SyncCursorTest.java
@@ -0,0 +1,173 @@
+package com.aware.utils;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+/**
+ * Unit tests for the position an upload resumes from and the rows the next batch takes.
+ *
+ * The properties pinned here are the ones that decide whether a table drains losslessly, and they
+ * differ by table shape.
+ *
+ * A table whose rows are complete when stored is paged by row id. A capture timestamp is shared by
+ * rows a high-frequency sensor writes in the same millisecond and moves backwards when a sensor
+ * flushes a buffer, so resuming on it leaves rows behind; the row id has neither property.
+ *
+ * A table whose rows finish after they are stored is paged by the instant they finished, with the
+ * row id separating rows that finished in the same millisecond. Resuming such a table on the row id
+ * would strand every row that finished after the cursor passed it — a session that outlived the ones
+ * started after it, or a prompt answered hours later.
+ *
+ * Reading either shape by offset shifts the window whenever a row is inserted mid-run, so neither
+ * carries one.
+ */
+public class SyncCursorTest {
+
+ private static final String[] SENSOR = {"_id", "timestamp", "device_id", "double_values_0"};
+ private static final String[] SESSION = {"_id", "timestamp", "device_id", "double_end_timestamp"};
+ private static final String[] ESM = {"_id", "timestamp", "double_esm_user_answer_timestamp"};
+
+ // --- Shape routing -----------------------------------------------------
+
+ @Test
+ public void aSensorTableGatesNothingAndIsPagedByRowId() {
+ assertNull(SyncCursor.completionColumn(SENSOR));
+ assertFalse(SyncCursor.pagesByCompletion(SENSOR));
+ assertEquals(SyncCursor.ROW_ID, SyncCursor.orderColumn(SENSOR));
+ }
+
+ @Test
+ public void aSessionTableIsPagedByTheInstantASessionClosed() {
+ assertEquals(SyncCursor.SESSION_END, SyncCursor.completionColumn(SESSION));
+ assertTrue(SyncCursor.pagesByCompletion(SESSION));
+ assertEquals(SyncCursor.SESSION_END, SyncCursor.orderColumn(SESSION));
+ }
+
+ @Test
+ public void anEsmTableIsPagedByTheInstantAPromptWasAnswered() {
+ assertEquals(SyncCursor.ESM_ANSWER, SyncCursor.completionColumn(ESM));
+ assertTrue(SyncCursor.pagesByCompletion(ESM));
+ assertEquals(SyncCursor.ESM_ANSWER, SyncCursor.orderColumn(ESM));
+ }
+
+ // --- Tables paged by row id -------------------------------------------
+
+ @Test
+ public void aBatchResumesOnTheRowAfterTheCursor() {
+ assertEquals("_id > 4200", SyncCursor.selection(SENSOR, 0, 4200, null));
+ }
+
+ @Test
+ public void theCursorIsNeverTheCaptureTimestamp() {
+ // Rows a sensor writes inside one millisecond share a timestamp, so a timestamp predicate
+ // hides every row but the last of that millisecond. The selection names the row id only.
+ String selection = SyncCursor.selection(SENSOR, 1787764838289L, 4200, null);
+
+ assertTrue(selection.startsWith(SyncCursor.ROW_ID + " >"));
+ assertFalse(selection.contains("timestamp >"));
+ }
+
+ @Test
+ public void anUnsyncedTableIsOfferedFromItsFirstRow() {
+ // SQLite assigns row ids from 1, so a cursor of 0 admits the whole table.
+ assertEquals("_id > 0", SyncCursor.selection(SENSOR, 0, 0, null));
+ }
+
+ @Test
+ public void theStudyClauseIsCarriedIntoTheSelection() {
+ assertEquals("_id > 7 AND timestamp >= 1000",
+ SyncCursor.selection(SENSOR, 0, 7, " AND timestamp >= 1000"));
+ }
+
+ @Test
+ public void aBatchIsReadInInsertionOrderWithoutAnOffset() {
+ // An offset counts rows from the start of the result set, so a row inserted between two
+ // batches of one run shifts every later window and a row is skipped. The cursor in the
+ // selection already names where to resume, so the read carries a limit and no offset.
+ String order = SyncCursor.order(SENSOR, 1000);
+
+ assertEquals("_id ASC LIMIT 1000", order);
+ assertFalse(order.contains(","));
+ }
+
+ // --- Tables paged by completion ---------------------------------------
+
+ @Test
+ public void aSessionTableResumesOnTheInstantItReachedWithARowIdTiebreak() {
+ assertEquals("double_end_timestamp != 0 AND (double_end_timestamp > 100"
+ + " OR (double_end_timestamp = 100 AND _id > 5))",
+ SyncCursor.selection(SESSION, 100, 5, null));
+ }
+
+ @Test
+ public void anEsmTableResumesOnTheAnswerInstantItReached() {
+ assertEquals("double_esm_user_answer_timestamp != 0"
+ + " AND (double_esm_user_answer_timestamp > 100"
+ + " OR (double_esm_user_answer_timestamp = 100 AND _id > 5))",
+ SyncCursor.selection(ESM, 100, 5, null));
+ }
+
+ @Test
+ public void aSessionFinishingAfterTheCursorIsStillOffered() {
+ // The case a row-id cursor strands: a session stored at row 2 that outlived the sessions
+ // stored after it, so it closes once the cursor already stands on row 5. It is admitted on
+ // the completion axis, where it lies past the cursor, and its lower row id does not exclude
+ // it — the row id appears only inside the equal-instant tiebreak.
+ String selection = SyncCursor.selection(SESSION, 100, 5, null);
+
+ assertTrue(selection.contains("double_end_timestamp > 100"));
+ assertFalse(selection.startsWith("_id >"));
+ assertEquals("_id > 5))", selection.substring(selection.length() - 9));
+ }
+
+ @Test
+ public void anUnfinishedRowIsNeverOffered() {
+ assertTrue(SyncCursor.selection(SESSION, 100, 5, null)
+ .startsWith("double_end_timestamp != 0"));
+ assertTrue(SyncCursor.selection(ESM, 100, 5, null)
+ .startsWith("double_esm_user_answer_timestamp != 0"));
+ }
+
+ @Test
+ public void aCompletionTableIsReadInCompletionOrderWithARowIdTiebreak() {
+ assertEquals("double_end_timestamp ASC, _id ASC LIMIT 500",
+ SyncCursor.order(SESSION, 500));
+ assertEquals("double_esm_user_answer_timestamp ASC, _id ASC LIMIT 500",
+ SyncCursor.order(ESM, 500));
+ }
+
+ @Test
+ public void theStudyClauseIsCarriedIntoACompletionSelection() {
+ assertTrue(SyncCursor.selection(SESSION, 100, 5, " AND timestamp >= 1000")
+ .endsWith(" AND timestamp >= 1000"));
+ }
+
+ // --- Seeding from a timestamp marker ----------------------------------
+
+ @Test
+ public void aTimestampMarkerIsTranslatedIntoARowOnce() {
+ assertTrue(SyncCursor.needsSeeding(SENSOR, 0, 1787764838289L));
+ }
+
+ @Test
+ public void aTableAlreadyHoldingACursorIsLeftAlone() {
+ assertFalse(SyncCursor.needsSeeding(SENSOR, 4200, 1787764838289L));
+ }
+
+ @Test
+ public void aTableThatNeverSyncedNeedsNoSeeding() {
+ assertFalse(SyncCursor.needsSeeding(SENSOR, 0, 0));
+ }
+
+ @Test
+ public void aCompletionTableIsNotSeededFromACaptureTimestamp() {
+ // Capture order and completion order are different axes, so a marker recorded on the first
+ // names no position on the second. Such a table opens at the start of the completion axis.
+ assertFalse(SyncCursor.needsSeeding(SESSION, 0, 1787764838289L));
+ assertFalse(SyncCursor.needsSeeding(ESM, 0, 1787764838289L));
+ }
+}
diff --git a/aware-core/src/test/java/com/aware/utils/UploadHealthTest.java b/aware-core/src/test/java/com/aware/utils/UploadHealthTest.java
new file mode 100644
index 00000000..1f484f52
--- /dev/null
+++ b/aware-core/src/test/java/com/aware/utils/UploadHealthTest.java
@@ -0,0 +1,211 @@
+package com.aware.utils;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Unit tests for the two decisions upload health makes on its own: when a delivery failure is worth
+ * interrupting the participant for, and what the app tells them in the meantime.
+ *
+ * Both are pure, so they can be exercised without a device or a real clock. The recording
+ * side needs a Context and is covered by the device checks in BETA_BLOCKERS.md instead.
+ */
+public class UploadHealthTest {
+
+ private static final long HOUR = 60 * 60 * 1000L;
+ private static final List NONE = Collections.emptyList();
+ private static final List ONE_TABLE = Collections.singletonList("accelerometer");
+
+ // --- When to notify ---
+
+ @Test
+ public void healthyDeliveryNeverNotifies() {
+ assertFalse(UploadHealth.shouldNotify(0L, false));
+ }
+
+ @Test
+ public void aFailingDeliveryNotifiesImmediately() {
+ // No waiting period: a phone that has stopped reaching the study should say so on the first
+ // refused batch, not after the participant has lost hours of delivery.
+ assertTrue(UploadHealth.shouldNotify(1L, false));
+ assertTrue(UploadHealth.shouldNotify(100 * HOUR, false));
+ }
+
+ @Test
+ public void theSameOutageIsNotNotifiedTwice() {
+ // Every table reports the same outage on every sync tick, and re-posting the same
+ // notification id alerts again each time. Without this the participant is buzzed once per
+ // table per minute — the spam this design exists to avoid.
+ assertFalse(UploadHealth.shouldNotify(100 * HOUR, true));
+ }
+
+ @Test
+ public void aNegativeOutageStartIsTreatedAsHealthy() {
+ // Defensive: a corrupt or backwards-clock value must not read as an active outage.
+ assertFalse(UploadHealth.shouldNotify(-1L, false));
+ }
+
+ // --- What the app shows ---
+
+ @Test
+ public void aNewEnrolmentSaysNothingHasBeenDeliveredYet() {
+ // Distinct from "fallen behind": on a fresh join there is no gap to explain.
+ assertEquals("Nothing delivered yet", UploadHealth.statusLine(null, NONE, 0));
+ }
+
+ @Test
+ public void healthyDeliveryReportsHowFarItReached() {
+ assertEquals("Delivered up to 5 minutes ago",
+ UploadHealth.statusLine("5 minutes ago", NONE, 0));
+ }
+
+ @Test
+ public void aFailingUploadSaysSoAndPromisesTheDataIsKept() {
+ String line = UploadHealth.statusLine("2 days ago", ONE_TABLE, 480);
+ assertTrue(line.contains("not delivering right now"));
+ assertTrue(line.contains("480 records waiting"));
+ // The reassurance matters as much as the warning: the participant should not conclude their
+ // data is being lost, because it is not.
+ assertTrue(line.contains("kept on the device"));
+ }
+
+ @Test
+ public void aPendingCountOfOneReadsAsSingular() {
+ assertTrue(UploadHealth.statusLine("1 hour ago", ONE_TABLE, 1).contains("1 record waiting"));
+ }
+
+ @Test
+ public void anUnknownPendingCountIsOmittedRatherThanShownAsZero() {
+ String line = UploadHealth.statusLine("1 hour ago", ONE_TABLE, 0);
+ assertFalse("0 records waiting would misreport an unknown count", line.contains("0 record"));
+ assertTrue(line.contains("not delivering right now"));
+ }
+
+ @Test
+ public void aFailingUploadWithNoPriorDeliveryStillExplainsItself() {
+ String line = UploadHealth.statusLine(null, ONE_TABLE, 12);
+ assertTrue(line.startsWith("Nothing delivered yet"));
+ assertTrue(line.contains("not delivering right now"));
+ }
+
+ // --- Per-table outage state ---
+ //
+ // These cover the behaviour that let a broken table report itself healthy. Roughly 30 sync
+ // adapters run in parallel, each calling recordSuccess/recordFailure for its own table, so the
+ // question is what one table's success does to another table's recorded outage.
+
+ /**
+ * The regression test for the bug. bluetooth fails, then accelerometer succeeds — which is
+ * exactly what happened in the field for two hours. bluetooth must still be recorded as failing.
+ */
+ @Test
+ public void oneTablesSuccessDoesNotClearAnotherTablesOutage() {
+ Map outages = UploadHealth.withFailure(
+ UploadHealth.parseOutages(""), "bluetooth", 1000L);
+ assertTrue(outages.containsKey("bluetooth"));
+
+ outages = UploadHealth.withSuccess(outages, "accelerometer");
+
+ assertTrue("accelerometer's success erased bluetooth's outage",
+ outages.containsKey("bluetooth"));
+ assertEquals(1000L, (long) outages.get("bluetooth"));
+ }
+
+ @Test
+ public void aTablesOwnSuccessClearsItsOwnOutage() {
+ Map outages = UploadHealth.withFailure(
+ UploadHealth.parseOutages(""), "bluetooth", 1000L);
+ assertTrue(UploadHealth.withSuccess(outages, "bluetooth").isEmpty());
+ }
+
+ @Test
+ public void severalTablesCanBeFailingAtOnce() {
+ Map outages = UploadHealth.parseOutages("");
+ outages = UploadHealth.withFailure(outages, "bluetooth", 1000L);
+ outages = UploadHealth.withFailure(outages, "locations", 2000L);
+
+ assertEquals(2, outages.size());
+ assertEquals(1000L, UploadHealth.earliestOutage(outages));
+ }
+
+ /** A table that keeps failing must keep its original start time, not have it pushed forward. */
+ @Test
+ public void arepeatedFailureKeepsTheOriginalStartTime() {
+ Map outages = UploadHealth.withFailure(
+ UploadHealth.parseOutages(""), "bluetooth", 1000L);
+ outages = UploadHealth.withFailure(outages, "bluetooth", 9999L);
+
+ assertEquals(1000L, (long) outages.get("bluetooth"));
+ }
+
+ @Test
+ public void noFailingTablesMeansNoOutageStart() {
+ assertEquals(0L, UploadHealth.earliestOutage(UploadHealth.parseOutages("")));
+ }
+
+ @Test
+ public void outagesSurviveASaveAndReload() {
+ Map outages = UploadHealth.parseOutages("");
+ outages = UploadHealth.withFailure(outages, "locations", 2000L);
+ outages = UploadHealth.withFailure(outages, "bluetooth", 1000L);
+
+ assertEquals(outages, UploadHealth.parseOutages(UploadHealth.formatOutages(outages)));
+ }
+
+ @Test
+ public void aMalformedStoredValueIsSkippedRatherThanGuessed() {
+ assertTrue(UploadHealth.parseOutages("garbage").isEmpty());
+ assertTrue(UploadHealth.parseOutages("bluetooth:").isEmpty());
+ assertTrue(UploadHealth.parseOutages(":1000").isEmpty());
+ assertTrue(UploadHealth.parseOutages("bluetooth:notanumber").isEmpty());
+ assertTrue(UploadHealth.parseOutages("bluetooth:0").isEmpty());
+ assertTrue(UploadHealth.parseOutages(null).isEmpty());
+ // A good entry alongside a bad one is still kept.
+ assertEquals(1, UploadHealth.parseOutages("garbage,locations:2000").size());
+ }
+
+ // --- Naming the failing tables ---
+ //
+ // "not delivering" on its own reads as a whole-study outage. The common case is one sensor.
+
+ @Test
+ public void oneFailingTableIsNamedInTheSingular() {
+ String line = UploadHealth.statusLine("2 minutes ago",
+ Collections.singletonList("bluetooth"), 0);
+ assertTrue(line, line.contains("bluetooth is not delivering"));
+ }
+
+ @Test
+ public void severalFailingTablesAreListedInThePlural() {
+ String line = UploadHealth.statusLine("2 minutes ago",
+ Arrays.asList("bluetooth", "locations"), 0);
+ assertTrue(line, line.contains("bluetooth and locations are not delivering"));
+ }
+
+ @Test
+ public void threeFailingTablesReadAsAList() {
+ assertEquals("a, b and c",
+ UploadHealth.describeFailing(Arrays.asList("a", "b", "c")));
+ }
+
+ @Test
+ public void noFailingTablesSaysNothingAboutDelivery() {
+ String line = UploadHealth.statusLine("2 minutes ago", NONE, 0);
+ assertFalse(line, line.contains("not delivering"));
+ assertEquals("", UploadHealth.describeFailing(NONE));
+ assertEquals("", UploadHealth.describeFailing(null));
+ }
+
+ @Test
+ public void aNullFailingListIsTreatedAsHealthy() {
+ assertFalse(UploadHealth.statusLine("2 minutes ago", null, 0).contains("not delivering"));
+ }
+}
diff --git a/aware-core/src/test/java/com/aware/utils/UtcTimeTest.java b/aware-core/src/test/java/com/aware/utils/UtcTimeTest.java
new file mode 100644
index 00000000..6629cf7a
--- /dev/null
+++ b/aware-core/src/test/java/com/aware/utils/UtcTimeTest.java
@@ -0,0 +1,119 @@
+package com.aware.utils;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+
+import org.junit.After;
+import org.junit.Test;
+
+import java.util.Calendar;
+import java.util.TimeZone;
+
+/**
+ * Locks in that the text-valued date/time fields are written in UTC, matching the epoch-millisecond
+ * {@code timestamp} column every sensor already uses. Before this, ESM_Date wrote
+ * "yyyy-MM-dd Z" and ESM_DateTime wrote "yyyy-MM-dd HH:mm:ss Z" in the device's default timezone, so
+ * one study's answers arrived on whatever offset each participant's phone happened to be set to.
+ *
+ * The Calendars here carry explicit timezones and the default timezone is restored after each test,
+ * so the assertions hold on any machine and in any CI region.
+ */
+public class UtcTimeTest {
+
+ private static final TimeZone DEFAULT_TIMEZONE = TimeZone.getDefault();
+
+ /** Europe/Zurich is UTC+2 in August, so a local wall clock runs ahead of UTC. */
+ private static final TimeZone AHEAD_OF_UTC = TimeZone.getTimeZone("Europe/Zurich");
+
+ /** America/New_York is UTC-4 in August, so a local wall clock runs behind UTC. */
+ private static final TimeZone BEHIND_UTC = TimeZone.getTimeZone("America/New_York");
+
+ @After
+ public void restoreDefaultTimezone() {
+ TimeZone.setDefault(DEFAULT_TIMEZONE);
+ }
+
+ private static Calendar pickedIn(TimeZone zone, int year, int month, int day, int hour, int minute) {
+ Calendar calendar = Calendar.getInstance(zone);
+ calendar.clear();
+ calendar.set(year, month, day, hour, minute, 0);
+ return calendar;
+ }
+
+ // --- instant(): the shared formatter.
+
+ @Test
+ public void instant_formatsEpochMillisAsAnIsoUtcInstant() {
+ assertEquals("2026-08-17T14:30:00Z", UtcTime.instant(1786977000000L));
+ assertEquals("1970-01-01T00:00:00Z", UtcTime.instant(0L));
+ }
+
+ @Test
+ public void instant_ignoresTheDeviceTimezone() {
+ TimeZone.setDefault(AHEAD_OF_UTC);
+ String ahead = UtcTime.instant(1786977000000L);
+
+ TimeZone.setDefault(BEHIND_UTC);
+ String behind = UtcTime.instant(1786977000000L);
+
+ assertEquals("2026-08-17T14:30:00Z", ahead);
+ assertEquals(ahead, behind);
+ }
+
+ // --- pickedDateTime(): ESM_DateTime. The participant picks a local wall clock; the recorded value
+ // is the absolute instant that names, which is what makes it convertible to any timezone later.
+
+ @Test
+ public void pickedDateTime_convertsALocalWallClockToItsUtcInstant() {
+ assertEquals("2026-08-17T12:30:00Z",
+ UtcTime.pickedDateTime(pickedIn(AHEAD_OF_UTC, 2026, Calendar.AUGUST, 17, 14, 30)));
+ }
+
+ @Test
+ public void pickedDateTime_rollsTheDayWhenTheInstantFallsOnTheNextUtcDay() {
+ assertEquals("2026-08-18T02:00:00Z",
+ UtcTime.pickedDateTime(pickedIn(BEHIND_UTC, 2026, Calendar.AUGUST, 17, 22, 0)));
+ }
+
+ @Test
+ public void pickedDateTime_dropsTheSecondsLeftOverFromWhenTheDialogOpened() {
+ Calendar picked = pickedIn(AHEAD_OF_UTC, 2026, Calendar.AUGUST, 17, 14, 30);
+ picked.set(Calendar.SECOND, 47); // the pickers only offer minute resolution -- these came
+ picked.set(Calendar.MILLISECOND, 321); // from Calendar.getInstance() at dialog creation
+
+ assertEquals("2026-08-17T12:30:00Z", UtcTime.pickedDateTime(picked));
+ }
+
+ // --- pickedDate(): ESM_Date. A date-only answer names a day, not an instant, so the picked day is
+ // anchored at midnight UTC. Converting the underlying instant instead would report the
+ // neighbouring day whenever the participant's local offset crosses midnight.
+
+ @Test
+ public void pickedDate_keepsThePickedDayForAParticipantAheadOfUtc() {
+ assertEquals("2026-08-17T00:00:00Z",
+ UtcTime.pickedDate(pickedIn(AHEAD_OF_UTC, 2026, Calendar.AUGUST, 17, 0, 30)));
+ }
+
+ @Test
+ public void pickedDate_keepsThePickedDayForAParticipantBehindUtc() {
+ assertEquals("2026-08-17T00:00:00Z",
+ UtcTime.pickedDate(pickedIn(BEHIND_UTC, 2026, Calendar.AUGUST, 17, 23, 30)));
+ }
+
+ @Test
+ public void pickedDate_ignoresTheTimeOfDayEntirely() {
+ assertEquals(
+ UtcTime.pickedDate(pickedIn(AHEAD_OF_UTC, 2026, Calendar.AUGUST, 17, 0, 0)),
+ UtcTime.pickedDate(pickedIn(AHEAD_OF_UTC, 2026, Calendar.AUGUST, 17, 23, 59)));
+ }
+
+ // --- fileStamp(): ScreenShot filenames, which cannot carry a colon.
+
+ @Test
+ public void fileStamp_isTheSameInstantWithoutFilesystemReservedCharacters() {
+ String stamp = UtcTime.fileStamp(1786977000000L);
+
+ assertEquals("20260817T143000Z", stamp);
+ assertFalse(stamp.contains(":"));
+ }
+}
diff --git a/aware-phone/build.gradle b/aware-phone/build.gradle
index f58e7d86..61df7c4b 100755
--- a/aware-phone/build.gradle
+++ b/aware-phone/build.gradle
@@ -7,6 +7,10 @@ android {
compileSdkVersion compile_sdk
buildToolsVersion build_tools
+ testOptions {
+ unitTests.returnDefaultValues = true
+ }
+
defaultConfig {
applicationId "com.aware.phone"
versionCode version_code
@@ -126,6 +130,8 @@ dependencies {
api(project(":com.aware.plugin.openweather"))
implementation 'com.android.support:support-v4:28.0.0'
implementation 'androidx.annotation:annotation:1.2.0'
+
+ testImplementation 'junit:junit:4.13.2'
}
repositories {
diff --git a/aware-phone/src/main/AndroidManifest.xml b/aware-phone/src/main/AndroidManifest.xml
index 2bf7f463..ab18925c 100755
--- a/aware-phone/src/main/AndroidManifest.xml
+++ b/aware-phone/src/main/AndroidManifest.xml
@@ -109,6 +109,13 @@
android:label="Take Note"
android:exported="false">
+
+
@@ -144,15 +151,15 @@
android:icon="@drawable/ic_launcher_settings"
android:label="Write to AWARE's Context data"
android:protectionLevel="signature" />
-
-
-
-
-
-
-
-
-
+
+
+
@@ -162,20 +169,21 @@
android:required="false" />
+
+
-
-
+
diff --git a/aware-phone/src/main/java/com/aware/phone/Aware_Client.java b/aware-phone/src/main/java/com/aware/phone/Aware_Client.java
index 5db3cabb..d10e567b 100755
--- a/aware-phone/src/main/java/com/aware/phone/Aware_Client.java
+++ b/aware-phone/src/main/java/com/aware/phone/Aware_Client.java
@@ -85,18 +85,19 @@ protected void onCreate(Bundle savedInstanceState) {
listSensorType.put(sensors.get(i).getType(), true);
}
- REQUIRED_PERMISSIONS.add(Manifest.permission.WRITE_EXTERNAL_STORAGE);
- REQUIRED_PERMISSIONS.add(Manifest.permission.ACCESS_WIFI_STATE);
-// REQUIRED_PERMISSIONS.add(Manifest.permission.CAMERA);
- REQUIRED_PERMISSIONS.add(Manifest.permission.BLUETOOTH);
- REQUIRED_PERMISSIONS.add(Manifest.permission.BLUETOOTH_ADMIN);
- REQUIRED_PERMISSIONS.add(Manifest.permission.ACCESS_COARSE_LOCATION);
- REQUIRED_PERMISSIONS.add(Manifest.permission.ACCESS_FINE_LOCATION);
- REQUIRED_PERMISSIONS.add(Manifest.permission.READ_PHONE_STATE);
- REQUIRED_PERMISSIONS.add(Manifest.permission.GET_ACCOUNTS);
+ // Core sync framework (account creation + SyncAdapters). GET_ACCOUNTS is only
+ // needed below API 26 -- see the matching comment in ui/Aware_Client.java.
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
+ REQUIRED_PERMISSIONS.add(Manifest.permission.GET_ACCOUNTS);
REQUIRED_PERMISSIONS.add(Manifest.permission.WRITE_SYNC_SETTINGS);
REQUIRED_PERMISSIONS.add(Manifest.permission.READ_SYNC_SETTINGS);
REQUIRED_PERMISSIONS.add(Manifest.permission.READ_SYNC_STATS);
+
+ // Core storage (local database, data export, certificates)
+ REQUIRED_PERMISSIONS.add(Manifest.permission.WRITE_EXTERNAL_STORAGE);
+ REQUIRED_PERMISSIONS.add(Manifest.permission.READ_EXTERNAL_STORAGE);
+
+ // Background survival, can ask enabling additional Accesibility settings
REQUIRED_PERMISSIONS.add(Manifest.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) REQUIRED_PERMISSIONS.add(Manifest.permission.FOREGROUND_SERVICE);
@@ -104,7 +105,7 @@ protected void onCreate(Bundle savedInstanceState) {
boolean PERMISSIONS_OK = true;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
for (String p : REQUIRED_PERMISSIONS) {
- if (PermissionChecker.checkSelfPermission(this, p) != PermissionChecker.PERMISSION_GRANTED) {
+ if (PermissionChecker.checkSelfPermission(this, p) != PackageManager.PERMISSION_GRANTED) {
PERMISSIONS_OK = false;
break;
}
@@ -187,6 +188,10 @@ else if (entry instanceof Integer)
}
private class SettingsSync extends AsyncTask {
+ // A category can contain several status_* preferences. Recalculate its icon only once per
+ // sync pass instead of rebinding the same parent row for every child.
+ private final Set refreshedSensorParents = new HashSet<>();
+
@Override
protected Void doInBackground(Preference... params) {
for (Preference pref : params) {
@@ -242,6 +247,7 @@ protected void onProgressUpdate(Preference... values) {
if (PreferenceScreen.class.isInstance(getPreferenceParent(pref))) {
PreferenceScreen parent = (PreferenceScreen) getPreferenceParent(pref);
+ if (!refreshedSensorParents.add(parent.getKey())) return;
ListAdapter children = parent.getRootAdapter();
boolean is_active = false;
for (int i = 0; i < children.getCount(); i++) {
@@ -265,7 +271,6 @@ protected void onProgressUpdate(Preference... values) {
if (category_icon != null) {
category_icon.setColorFilter(new PorterDuffColorFilter(ContextCompat.getColor(getApplicationContext(), R.color.accent), PorterDuff.Mode.SRC_IN));
parent.setIcon(category_icon);
- onContentChanged();
}
} catch (NoSuchFieldException | IllegalAccessException e) {
}
@@ -278,7 +283,6 @@ protected void onProgressUpdate(Preference... values) {
if (category_icon != null) {
category_icon.clearColorFilter();
parent.setIcon(category_icon);
- onContentChanged();
}
} catch (NoSuchFieldException | IllegalAccessException e) {
}
@@ -295,7 +299,7 @@ protected void onResume() {
permissions_ok = true;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
for (String p : REQUIRED_PERMISSIONS) {
- if (PermissionChecker.checkSelfPermission(this, p) != PermissionChecker.PERMISSION_GRANTED) {
+ if (PermissionChecker.checkSelfPermission(this, p) != PackageManager.PERMISSION_GRANTED) {
permissions_ok = false;
break;
}
@@ -313,7 +317,7 @@ protected void onResume() {
} else {
- if (prefs.getAll().isEmpty() && Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID).length() == 0) {
+ if (prefs.getAll().isEmpty() && Aware.getDeviceID(getApplicationContext()).length() == 0) {
PreferenceManager.setDefaultValues(getApplicationContext(), "com.aware.phone", Context.MODE_PRIVATE, com.aware.R.xml.aware_preferences, true);
prefs.edit().commit();
} else {
@@ -322,19 +326,25 @@ protected void onResume() {
Map defaults = prefs.getAll();
for (Map.Entry entry : defaults.entrySet()) {
+ // Skip webservice_server: see Aware.onStartCommand()'s copy of this loop for why —
+ // the cached "com.aware.phone" SharedPreferences default is a stale placeholder URL,
+ // not a real study join URL.
+ if (entry.getKey().equals(Aware_Preferences.WEBSERVICE_SERVER)) continue;
if (Aware.getSetting(getApplicationContext(), entry.getKey(), "com.aware.phone").length() == 0) {
Aware.setSetting(getApplicationContext(), entry.getKey(), entry.getValue(), "com.aware.phone"); //default AWARE settings
}
}
- if (Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID).length() == 0) {
+ if (Aware.getDeviceID(getApplicationContext()).length() == 0) {
UUID uuid = UUID.randomUUID();
Aware.setSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID, uuid.toString(), "com.aware.phone");
}
- if (Aware.getSetting(getApplicationContext(), Aware_Preferences.WEBSERVICE_SERVER).length() == 0) {
- Aware.setSetting(getApplicationContext(), Aware_Preferences.WEBSERVICE_SERVER, "https://api.awareframework.com/index.php");
- }
+ // Deliberately no "if empty, default to the public AWARE demo server" fallback here
+ // anymore — see Aware.onStartCommand() for why. This legacy Activity (com.aware.phone.
+ // Aware_Client, distinct from com.aware.phone.ui.Aware_Client) is still reachable via the
+ // android.support.PARENT_ACTIVITY meta-data on Aware_QRCode/Plugins_Manager/Aware_Join_Study
+ // in AndroidManifest.xml, so it still runs and can still poison this setting.
Set keys = optionalSensors.keySet();
for (String optionalSensor : keys) {
@@ -454,7 +464,7 @@ protected Boolean doInBackground(Void... params) {
//Ping AWARE's server with getApplicationContext() device's information for framework's statistics log
Hashtable device_ping = new Hashtable<>();
- device_ping.put(Aware_Preferences.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ device_ping.put(Aware_Preferences.DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
device_ping.put("ping", String.valueOf(System.currentTimeMillis()));
device_ping.put("platform", "android");
try {
@@ -475,4 +485,4 @@ protected Boolean doInBackground(Void... params) {
return true;
}
}
-}
\ No newline at end of file
+}
diff --git a/aware-phone/src/main/java/com/aware/phone/ui/Aware_Client.java b/aware-phone/src/main/java/com/aware/phone/ui/Aware_Client.java
index 7c08d6a6..2207c00a 100644
--- a/aware-phone/src/main/java/com/aware/phone/ui/Aware_Client.java
+++ b/aware-phone/src/main/java/com/aware/phone/ui/Aware_Client.java
@@ -3,15 +3,19 @@
import android.Manifest;
import android.app.ActivityManager;
import android.app.Dialog;
+import android.app.AlertDialog;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
+import android.content.ComponentName;
+import android.content.ContentValues;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.content.pm.PackageInfo;
+import android.database.Cursor;
import android.content.pm.PackageManager;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffColorFilter;
@@ -25,6 +29,7 @@
import android.os.Bundle;
import android.os.Handler;
import android.os.PowerManager;
+import android.os.SystemClock;
import android.preference.CheckBoxPreference;
import android.preference.EditTextPreference;
import android.preference.ListPreference;
@@ -35,9 +40,13 @@
import android.preference.PreferenceScreen;
import android.provider.Settings;
import android.text.TextUtils;
+import android.text.format.DateUtils;
import android.util.Log;
+import android.text.InputType;
+import android.view.View;
import android.view.ViewGroup;
import android.widget.ListAdapter;
+import android.widget.EditText;
import android.widget.Toast;
import com.aware.Applications;
@@ -45,8 +54,18 @@
import com.aware.Aware_Preferences;
import com.aware.Notes;
import com.aware.phone.R;
+import com.aware.phone.ui.dialogs.JoinStudyDialog;
+import com.aware.phone.ui.dialogs.QuitStudyDialog;
+import com.aware.phone.ui.prefs.SensorCollection;
+import com.aware.phone.ui.prefs.StudyCard;
import com.aware.phone.ui.prefs.TakeNotesPref;
+import com.aware.phone.utils.AwareUtil;
+import com.aware.providers.Aware_Provider;
import com.aware.ui.PermissionsHandler;
+import com.aware.utils.SensorAvailability;
+import com.aware.utils.Jdbc;
+import com.aware.utils.StudyUtils;
+import com.aware.utils.UploadHealth;
import com.aware.ScreenShot;
import org.json.JSONArray;
@@ -54,14 +73,20 @@
import org.json.JSONObject;
import java.lang.reflect.Field;
+import java.text.DateFormat;
import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.HashSet;
import java.util.Hashtable;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import androidx.appcompat.widget.Toolbar;
+import androidx.core.app.ActivityCompat;
import androidx.core.app.NotificationCompat;
import androidx.core.content.ContextCompat;
import androidx.core.content.PermissionChecker;
@@ -94,12 +119,19 @@ public class Aware_Client extends Aware_Activity {
private static final Hashtable optionalSensors = new Hashtable<>();
private final Aware.AndroidPackageMonitor packageMonitor = new Aware.AndroidPackageMonitor();
private TakeNotesPref originalTakeNotesPref = null;
+ // Generated "previously joined studies" rows (device mode); tracked so we can refresh them.
+ private final ArrayList studyHistoryPrefs = new ArrayList<>();
+ // Keep the originally inflated sensor screens even when locked mode temporarily removes them.
+ // This lets a study-config update add/remove only affected rows without recreating the Activity.
+ private final Map sensorPreferenceScreens = new LinkedHashMap<>();
private BroadcastReceiver screenshotServiceStoppedReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if (ScreenShot.ACTION_SCREENSHOT_SERVICE_STOPPED.equals(intent.getAction())) {
- checkAndStartScreenshotService();
+ // A stopped/invalid MediaProjection cannot safely be restarted with the old token.
+ // Re-entering here created a stop -> broadcast -> restart loop.
+ Log.w(TAG, "Screenshot capture stopped; waiting for a visible user-initiated restart");
}
}
};
@@ -113,6 +145,528 @@ public void onReceive(Context context, Intent intent) {
}
};
+ // Rebuild the screen when a study config update is applied, so newly enabled/disabled sensors
+ // appear immediately after "Sync config" — no re-join needed.
+ private BroadcastReceiver studyConfigUpdatedReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (Aware.ACTION_AWARE_STUDY_CONFIG_UPDATE_AVAILABLE.equals(intent.getAction())) {
+ showStudyConfigUpdatePreview(
+ intent.getStringArrayListExtra(Aware.EXTRA_SENSORS_ADDED),
+ intent.getStringArrayListExtra(Aware.EXTRA_SENSORS_REMOVED),
+ intent.getBooleanExtra(
+ Aware.EXTRA_CONFIG_UPDATE_ALLOWED_CHANGED, false)
+ ? intent.getBooleanExtra(
+ Aware.EXTRA_CONFIG_UPDATE_ALLOWED_NEW_VALUE, false)
+ : null);
+ } else if (Aware.ACTION_AWARE_STUDY_CONFIG_UPDATED.equals(intent.getAction())) {
+ ArrayList added = intent.getStringArrayListExtra(Aware.EXTRA_SENSORS_ADDED);
+ ArrayList removed = intent.getStringArrayListExtra(Aware.EXTRA_SENSORS_REMOVED);
+ Boolean configUpdateAllowedNewValue = intent.getBooleanExtra(Aware.EXTRA_CONFIG_UPDATE_ALLOWED_CHANGED, false)
+ ? intent.getBooleanExtra(Aware.EXTRA_CONFIG_UPDATE_ALLOWED_NEW_VALUE, false) : null;
+ boolean manual = intent.getBooleanExtra(Aware.EXTRA_CONFIG_UPDATE_MANUAL, false);
+ // Don't clear the pending notice here: this receiver stays registered (and keeps
+ // receiving broadcasts) even while the Activity is merely stopped/backgrounded, not
+ // just while visible — so a dialog "shown" here may never actually be seen. Only
+ // notifyStudyConfigUpdated()'s own dismiss handler, which only fires once the
+ // participant has actually interacted with a visible dialog, clears it.
+ notifyStudyConfigUpdated(added, removed, configUpdateAllowedNewValue, manual);
+ }
+ }
+ };
+
+ private boolean studyConfigPreviewOpen = false;
+
+ private void showStudyConfigUpdatePreview(
+ ArrayList added,
+ ArrayList removed,
+ Boolean configUpdateAllowedNewValue) {
+ if (studyConfigPreviewOpen || isFinishing()) return;
+ studyConfigPreviewOpen = true;
+ dismissOpenSubPrefDialogIfAny();
+
+ StringBuilder message = new StringBuilder(
+ "The server has a different sensor configuration.\n"
+ + "Review the changes before replacing your current settings.");
+ if (added != null && !added.isEmpty()) {
+ message.append("\n\nServer sensors to activate:\n• ")
+ .append(TextUtils.join("\n• ", added));
+ }
+ if (removed != null && !removed.isEmpty()) {
+ message.append("\n\nYour active sensors to deactivate:\n• ")
+ .append(TextUtils.join("\n• ", removed));
+ }
+ if ((added == null || added.isEmpty()) && (removed == null || removed.isEmpty())) {
+ message.append("\n\nThe update changes sensor frequencies or other study settings.");
+ }
+ if (configUpdateAllowedNewValue != null) {
+ message.append(configUpdateAllowedNewValue
+ ? "\n\nAfter this update you can adjust the sensor settings for this study yourself."
+ : "\n\nAfter this update, the researcher manages the sensor settings for this study.");
+ }
+ message.append("\n\nAgreeing replaces your local sensor configuration. "
+ + "If new sensors need permission, you will review consent next.");
+
+ new AlertDialog.Builder(this)
+ .setTitle("Study update available")
+ .setMessage(message.toString())
+ .setPositiveButton("Agree and update", new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ Intent approved = new Intent(Aware.ACTION_AWARE_SYNC_CONFIG);
+ approved.putExtra(Aware.SYNC_CONFIG_EXTRA_TOAST, true);
+ approved.putExtra(Aware.SYNC_CONFIG_EXTRA_MANUAL, true);
+ approved.putExtra(Aware.SYNC_CONFIG_EXTRA_APPROVED, true);
+ sendBroadcast(approved);
+ }
+ })
+ .setNegativeButton("Keep my settings", new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ keepParticipantStudySettings();
+ }
+ })
+ .setNeutralButton("Leave study", new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ Aware.setSetting(
+ getApplicationContext(),
+ Aware_Preferences.PENDING_STUDY_CONFIG_APPROVAL,
+ "");
+ new QuitStudyDialog(Aware_Client.this).showDialog();
+ }
+ })
+ .setOnCancelListener(new DialogInterface.OnCancelListener() {
+ @Override
+ public void onCancel(DialogInterface dialog) {
+ keepParticipantStudySettings();
+ }
+ })
+ .setOnDismissListener(new DialogInterface.OnDismissListener() {
+ @Override
+ public void onDismiss(DialogInterface dialog) {
+ studyConfigPreviewOpen = false;
+ }
+ })
+ .show();
+ }
+
+ private void keepParticipantStudySettings() {
+ Aware.setSetting(
+ getApplicationContext(),
+ Aware_Preferences.PENDING_STUDY_CONFIG_APPROVAL,
+ "");
+ Aware.logStudyCompliance(
+ getApplicationContext(),
+ "participant declined server config update and kept local settings");
+ Toast.makeText(
+ getApplicationContext(),
+ "Your current sensor settings were kept.",
+ Toast.LENGTH_SHORT).show();
+ }
+
+ private void showPendingStudyConfigApprovalIfAny() {
+ if (!Aware.isStudy(getApplicationContext()) || isStudySettingsLocked()) return;
+ String pending = Aware.getSetting(
+ getApplicationContext(),
+ Aware_Preferences.PENDING_STUDY_CONFIG_APPROVAL);
+ if (pending == null || pending.trim().length() == 0) return;
+ try {
+ JSONObject local = Aware.getActiveStudyConfig(getApplicationContext());
+ JSONObject server = new JSONObject(pending);
+ Set localSensors = activeSensorNames(local);
+ Set serverSensors = activeSensorNames(server);
+
+ ArrayList added = new ArrayList<>();
+ for (String sensor : serverSensors) {
+ if (!localSensors.contains(sensor)) added.add(sensor);
+ }
+ ArrayList removed = new ArrayList<>();
+ for (String sensor : localSensors) {
+ if (!serverSensors.contains(sensor)) removed.add(sensor);
+ }
+ showStudyConfigUpdatePreview(
+ added,
+ removed,
+ editableModeValueChanged(local, server));
+ } catch (JSONException e) {
+ Aware.setSetting(
+ getApplicationContext(),
+ Aware_Preferences.PENDING_STUDY_CONFIG_APPROVAL,
+ "");
+ }
+ }
+
+ private Set activeSensorNames(JSONObject config) {
+ Set active = new HashSet<>();
+ JSONArray sensors = config == null ? null : config.optJSONArray("sensors");
+ if (sensors == null) return active;
+ for (int i = 0; i < sensors.length(); i++) {
+ JSONObject sensor = sensors.optJSONObject(i);
+ if (sensor == null) continue;
+ String setting = sensor.optString("setting", "");
+ // Skip sensors whose hardware this device lacks: the participant can never turn them on,
+ // so they must not appear in the "activate/deactivate" preview as an actionable change.
+ // Mirrors StudyUtils, which now excludes the same sensors from the update decision.
+ if (setting.startsWith("status_")
+ && !SensorAvailability.isHardwareAvailable(getApplicationContext(), setting)) {
+ continue;
+ }
+ if (setting.startsWith("status_") && sensor.optBoolean("value", false)) {
+ active.add(setting.substring("status_".length()).replace('_', ' '));
+ }
+ }
+ return active;
+ }
+
+ private static Boolean editableModeValueChanged(
+ JSONObject localConfig, JSONObject serverConfig) {
+ Boolean local = sensorBooleanValue(
+ localConfig, Aware_Preferences.ENABLE_CONFIG_UPDATE);
+ Boolean server = sensorBooleanValue(
+ serverConfig, Aware_Preferences.ENABLE_CONFIG_UPDATE);
+ return server == null || server.equals(local) ? null : server;
+ }
+
+ private static Boolean sensorBooleanValue(JSONObject config, String settingName) {
+ JSONArray sensors = config == null ? null : config.optJSONArray("sensors");
+ if (sensors == null) return null;
+ for (int i = 0; i < sensors.length(); i++) {
+ JSONObject sensor = sensors.optJSONObject(i);
+ if (sensor != null
+ && settingName.equals(sensor.optString("setting", ""))) {
+ return sensor.optBoolean("value", false);
+ }
+ }
+ return null;
+ }
+
+ /**
+ * If a study config update was applied while no UI was around to receive the live broadcast
+ * (the sync runs on its own schedule regardless of whether the app is open), show it now.
+ */
+ private void showPendingStudyUpdateNoticeIfAny() {
+ String pending = Aware.getSetting(getApplicationContext(), Aware_Preferences.PENDING_STUDY_UPDATE_NOTICE);
+ if (pending == null || pending.trim().length() == 0) return;
+
+ try {
+ JSONObject notice = new JSONObject(pending);
+ ArrayList added = new ArrayList<>();
+ JSONArray addedJson = notice.optJSONArray("added");
+ if (addedJson != null) {
+ for (int i = 0; i < addedJson.length(); i++) added.add(addedJson.getString(i));
+ }
+ ArrayList removed = new ArrayList<>();
+ JSONArray removedJson = notice.optJSONArray("removed");
+ if (removedJson != null) {
+ for (int i = 0; i < removedJson.length(); i++) removed.add(removedJson.getString(i));
+ }
+ Boolean configUpdateAllowedNewValue = notice.optBoolean("cfgChanged", false)
+ ? notice.optBoolean("cfgNewValue", false) : null;
+ notifyStudyConfigUpdated(
+ added, removed, configUpdateAllowedNewValue,
+ notice.optBoolean("manual", false));
+ } catch (JSONException e) {
+ e.printStackTrace();
+ Aware.setSetting(getApplicationContext(), Aware_Preferences.PENDING_STUDY_UPDATE_NOTICE, "");
+ }
+ }
+
+ /** Guards against stacking the re-auth dialog on repeated onResume() calls. */
+ private boolean reauthDialogShowing = false;
+
+ /** Live trigger: shows the re-auth prompt as soon as a background sync detects a rotated password. */
+ private final BroadcastReceiver reauthRequiredReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ showPendingReauthIfAny();
+ }
+ };
+
+ /**
+ * If a password-join study's stored database password was rejected during a background sync
+ * (the researcher rotated it), prompt the participant to re-enter it now. Background sync sets
+ * {@link Aware_Preferences#PENDING_STUDY_REAUTH} but cannot prompt, so the request waits here
+ * until the app is open. A successful re-auth resumes collection with no re-join.
+ */
+ private void showPendingReauthIfAny() {
+ String studyUrl = Aware.getSetting(getApplicationContext(), Aware_Preferences.PENDING_STUDY_REAUTH);
+ if (studyUrl == null || studyUrl.trim().length() == 0) return;
+ if (reauthDialogShowing) return;
+
+ final EditText input = new EditText(this);
+ input.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
+ input.setHint("Study password");
+
+ reauthDialogShowing = true;
+ final AlertDialog dialog = new AlertDialog.Builder(this)
+ .setTitle("Study password required")
+ .setMessage("This study now requires you to enter its password to keep contributing "
+ + "data. Please enter the password provided by the researcher.\n\n"
+ + "If you tap Later, data collection is paused until you enter the password. "
+ + "You'll be asked again the next time the study updates or you open the app. "
+ + "You can also leave the study at any time.")
+ .setView(input)
+ .setCancelable(false)
+ .setPositiveButton("Submit", null) // overridden in onShow so a wrong password keeps the dialog open
+ .setNegativeButton("Later", new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface d, int which) {
+ reauthDialogShowing = false;
+ d.dismiss();
+ Toast.makeText(Aware_Client.this,
+ "Data collection paused until you enter the study password.",
+ Toast.LENGTH_LONG).show();
+ }
+ })
+ .create();
+
+ dialog.setOnShowListener(new DialogInterface.OnShowListener() {
+ @Override
+ public void onShow(DialogInterface d) {
+ dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ String entered = input.getText().toString();
+ if (entered.length() == 0) {
+ input.setError("Enter a password");
+ return;
+ }
+ new ReauthTask(dialog, entered).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ }
+ });
+ }
+ });
+ dialog.show();
+ }
+
+ /** Verifies a participant-entered study password off the main thread and reports the outcome. */
+ private class ReauthTask extends AsyncTask {
+ private final AlertDialog dialog;
+ private final String password;
+
+ ReauthTask(AlertDialog dialog, String password) {
+ this.dialog = dialog;
+ this.password = password;
+ }
+
+ @Override
+ protected void onPreExecute() {
+ dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false);
+ }
+
+ @Override
+ protected Jdbc.ConnectionResult doInBackground(Void... voids) {
+ return StudyUtils.reauthenticateStudy(getApplicationContext(), password);
+ }
+
+ @Override
+ protected void onPostExecute(Jdbc.ConnectionResult result) {
+ if (isFinishing()) {
+ reauthDialogShowing = false;
+ return;
+ }
+ if (result == Jdbc.ConnectionResult.OK) {
+ reauthDialogShowing = false;
+ dialog.dismiss();
+ Toast.makeText(Aware_Client.this, "Study password updated.", Toast.LENGTH_LONG).show();
+ } else if (result == Jdbc.ConnectionResult.AUTH_FAILED) {
+ dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(true);
+ Toast.makeText(Aware_Client.this, "Password still incorrect.", Toast.LENGTH_LONG).show();
+ } else {
+ dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(true);
+ Toast.makeText(Aware_Client.this, "Can't reach the server. Try again later.",
+ Toast.LENGTH_LONG).show();
+ }
+ }
+ }
+
+ /** Tracks the currently-open nested PreferenceScreen dialog (e.g. "AWARE Study"). */
+ private Dialog openSubPrefDialog = null;
+
+ private void dismissOpenSubPrefDialogIfAny() {
+ if (openSubPrefDialog != null && openSubPrefDialog.isShowing()) {
+ openSubPrefDialog.dismiss();
+ }
+ openSubPrefDialog = null;
+ }
+
+ /**
+ * Tells the participant the study changed — sensors added/removed, and/or whether they can now
+ * edit their own settings — then applies a targeted sensor-list diff. If nothing curated changed
+ * (e.g. a threshold/frequency tweak the participant doesn't need to know about), there's nothing
+ * to show and nothing worth refreshing.
+ */
+ private void notifyStudyConfigUpdated(ArrayList added, ArrayList removed,
+ Boolean configUpdateAllowedNewValue,
+ boolean manual) {
+ boolean hasChanges = (added != null && !added.isEmpty()) || (removed != null && !removed.isEmpty())
+ || configUpdateAllowedNewValue != null;
+ if (!hasChanges || isFinishing()) {
+ return;
+ }
+
+ dismissOpenSubPrefDialogIfAny();
+
+ // Some newly-added sensors need the participant's permission before they can collect and were
+ // held off until agreed. Offer a "Review" action when any are pending, and word the "added"
+ // line so it doesn't claim those are already collecting.
+ JSONObject activeConfig = Aware.getActiveStudyConfig(getApplicationContext());
+ JSONArray activeConfigs = new JSONArray();
+ if (activeConfig != null) activeConfigs.put(activeConfig);
+ final boolean hasHeld = SensorCollection.hasHeldConsents(getApplicationContext(), activeConfigs);
+
+ // An explicit check is the participant's request to adopt the server configuration now.
+ // If that introduces sensors requiring consent, continue directly into the consent screen
+ // instead of making them acknowledge one dialog merely to open the next one.
+ if (manual && hasHeld) {
+ Aware.setSetting(
+ getApplicationContext(), Aware_Preferences.PENDING_STUDY_UPDATE_NOTICE, "");
+ refreshSensorPreferencesForCurrentMode();
+ Toast.makeText(
+ getApplicationContext(),
+ "Study updated. Review the permissions required by its sensors.",
+ Toast.LENGTH_LONG).show();
+ Intent consent = new Intent(getApplicationContext(), SensorConsentActivity.class);
+ consent.putExtra(SensorConsentActivity.EXTRA_UPDATE_MODE, true);
+ startActivity(consent);
+ return;
+ }
+ if (manual) {
+ // The participant already approved these exact changes in the preview dialog.
+ // Refresh the list without asking them to acknowledge the same update a second time.
+ Aware.setSetting(
+ getApplicationContext(), Aware_Preferences.PENDING_STUDY_UPDATE_NOTICE, "");
+ refreshSensorPreferencesForCurrentMode();
+ return;
+ }
+
+ StringBuilder msg = new StringBuilder("The study was updated by the researcher.\n");
+ if (added != null && !added.isEmpty()) {
+ msg.append("\nAdded to the study:\n• ").append(TextUtils.join("\n• ", added));
+ }
+ if (removed != null && !removed.isEmpty()) {
+ msg.append("\n\nNo longer collecting:\n• ").append(TextUtils.join("\n• ", removed));
+ }
+ if (configUpdateAllowedNewValue != null) {
+ msg.append("\n\n").append(configUpdateAllowedNewValue
+ ? "You can now adjust the sensor settings for this study yourself."
+ : "The researcher now manages the sensor settings for this study.");
+ }
+ if (hasHeld) {
+ msg.append("\n\nSome added sensors need your permission before they can collect. Review them now?");
+ }
+
+ AlertDialog.Builder builder = new AlertDialog.Builder(this)
+ .setTitle("Study updated")
+ .setMessage(msg.toString())
+ .setOnDismissListener(new DialogInterface.OnDismissListener() {
+ @Override
+ public void onDismiss(DialogInterface dialog) {
+ // Only clear here — once the participant has actually seen and dismissed a
+ // visible dialog — not eagerly when merely attempting to show one (see the
+ // comment on studyConfigUpdatedReceiver for why that was unsafe).
+ Aware.setSetting(getApplicationContext(), Aware_Preferences.PENDING_STUDY_UPDATE_NOTICE, "");
+ // Add/remove only affected sensor rows; keep the Activity and surrounding UI.
+ if (!isFinishing()) refreshSensorPreferencesForCurrentMode();
+ }
+ });
+ if (hasHeld) {
+ builder.setPositiveButton("Review", new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ Intent consent = new Intent(getApplicationContext(), SensorConsentActivity.class);
+ consent.putExtra(SensorConsentActivity.EXTRA_UPDATE_MODE, true);
+ consent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
+ startActivity(consent);
+ }
+ });
+ builder.setNegativeButton("Not now", null);
+ } else {
+ builder.setPositiveButton("OK", null);
+ }
+ builder.show();
+ }
+
+ /** Saves the full XML-defined sensor list before locked-mode filtering removes any rows. */
+ private void cacheSensorPreferenceScreens() {
+ sensorPreferenceScreens.clear();
+ Preference sensors = findPreference("sensors");
+ if (!(sensors instanceof PreferenceCategory)) return;
+ PreferenceCategory category = (PreferenceCategory) sensors;
+ for (int i = 0; i < category.getPreferenceCount(); i++) {
+ Preference child = category.getPreference(i);
+ if (child instanceof PreferenceScreen && SensorCollection.isSensor(child.getKey())) {
+ sensorPreferenceScreens.put(child.getKey(), (PreferenceScreen) child);
+ }
+ }
+ }
+
+ /**
+ * Reconciles cached sensor screens with the current mode/config, relying on PreferenceGroup's
+ * own hierarchy notifications. Then refreshes only status preferences in rows that are visible.
+ */
+ private void refreshSensorPreferencesForCurrentMode() {
+ Preference sensors = findPreference("sensors");
+ if (!(sensors instanceof PreferenceCategory)) return;
+ PreferenceCategory category = (PreferenceCategory) sensors;
+ JSONObject config = Aware.getActiveStudyConfig(getApplicationContext());
+ boolean showAll = !isStudySettingsLocked();
+ ArrayList visibleStatuses = new ArrayList<>();
+
+ for (PreferenceScreen screen : sensorPreferenceScreens.values()) {
+ boolean shouldShow = showAll || isSensorActiveInConfig(screen, config);
+ boolean attached = getPreferenceParent(screen) == category;
+ if (shouldShow && !attached) {
+ category.addPreference(screen);
+ } else if (!shouldShow && attached) {
+ category.removePreference(screen);
+ }
+ if (shouldShow) {
+ collectStatusPreferences(screen, visibleStatuses);
+ }
+ }
+
+ if (!visibleStatuses.isEmpty()) {
+ new SettingsSync().executeOnExecutor(
+ AsyncTask.THREAD_POOL_EXECUTOR,
+ visibleStatuses.toArray(new Preference[visibleStatuses.size()]));
+ }
+ }
+
+ private static void collectStatusPreferences(
+ PreferenceGroup group, ArrayList destination) {
+ for (int i = 0; i < group.getPreferenceCount(); i++) {
+ Preference child = group.getPreference(i);
+ if (child.getKey() != null && child.getKey().startsWith("status_")) {
+ destination.add(child);
+ }
+ if (child instanceof PreferenceGroup) {
+ collectStatusPreferences((PreferenceGroup) child, destination);
+ }
+ }
+ }
+
+ private static boolean isSensorActiveInConfig(PreferenceScreen screen, JSONObject config) {
+ if (config == null) return false;
+ HashSet statusKeys = new HashSet<>();
+ ArrayList statuses = new ArrayList<>();
+ collectStatusPreferences(screen, statuses);
+ for (Preference status : statuses) statusKeys.add(status.getKey());
+
+ JSONArray sensors = config.optJSONArray("sensors");
+ if (sensors == null) return false;
+ for (int i = 0; i < sensors.length(); i++) {
+ JSONObject sensor = sensors.optJSONObject(i);
+ if (sensor != null
+ && sensor.optBoolean("value", false)
+ && statusKeys.contains(sensor.optString("setting"))) {
+ return true;
+ }
+ }
+ return false;
+ }
+
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
@@ -127,12 +681,16 @@ protected void onCreate(Bundle savedInstanceState) {
if (Aware.isStudy(getApplicationContext())) {
setContentView(R.layout.activity_aware_study);
addPreferencesFromResource(R.xml.pref_aware_light);
+ cacheSensorPreferenceScreens();
// Initialize plugin navigation
setupPluginNavigation();
} else {
setContentView(R.layout.activity_aware);
addPreferencesFromResource(R.xml.pref_aware_device);
+
+ // Device mode: list previously joined studies below "Join a study".
+ populateStudyHistory();
}
// hideUnusedPreferences();
@@ -156,28 +714,32 @@ protected void onCreate(Bundle savedInstanceState) {
listSensorType.put(sensors.get(i).getType(), true);
}
- REQUIRED_PERMISSIONS.add(Manifest.permission.WRITE_EXTERNAL_STORAGE);
- REQUIRED_PERMISSIONS.add(Manifest.permission.ACCESS_WIFI_STATE);
-
-// REQUIRED_PERMISSIONS.add(Manifest.permission.CAMERA);
- REQUIRED_PERMISSIONS.add(Manifest.permission.BLUETOOTH);
- REQUIRED_PERMISSIONS.add(Manifest.permission.BLUETOOTH_ADMIN);
- REQUIRED_PERMISSIONS.add(Manifest.permission.ACCESS_COARSE_LOCATION);
- REQUIRED_PERMISSIONS.add(Manifest.permission.ACCESS_FINE_LOCATION);
- REQUIRED_PERMISSIONS.add(Manifest.permission.READ_PHONE_STATE);
- REQUIRED_PERMISSIONS.add(Manifest.permission.GET_ACCOUNTS);
+ // Only permissions the AWARE core itself needs to start are requested up front.
+ // Sensor-specific permissions (location, phone state, bluetooth scanning, etc.) are requested on demand by each sensor's Service (see Aware_Sensor.onStartCommand)
+
+ // Core sync framework (account creation + SyncAdapters). GET_ACCOUNTS is only
+ // needed below API 26: from Android 8.0 onward, Account Visibility lets an app
+ // see/manage an account it created itself (ours, via Aware_Accounts' own
+ // AbstractAccountAuthenticator) without this permission -- requesting it anyway
+ // is what put a Contacts-labelled prompt in front of participants for no reason.
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
+ REQUIRED_PERMISSIONS.add(Manifest.permission.GET_ACCOUNTS);
REQUIRED_PERMISSIONS.add(Manifest.permission.WRITE_SYNC_SETTINGS);
REQUIRED_PERMISSIONS.add(Manifest.permission.READ_SYNC_SETTINGS);
REQUIRED_PERMISSIONS.add(Manifest.permission.READ_SYNC_STATS);
- REQUIRED_PERMISSIONS.add(Manifest.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
+
+ // Core storage (local database, data export, certificates)
REQUIRED_PERMISSIONS.add(Manifest.permission.WRITE_EXTERNAL_STORAGE);
REQUIRED_PERMISSIONS.add(Manifest.permission.READ_EXTERNAL_STORAGE);
+ // Background survival, can ask enabling additional Accesibility settings
+ REQUIRED_PERMISSIONS.add(Manifest.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
+
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) REQUIRED_PERMISSIONS.add(Manifest.permission.FOREGROUND_SERVICE);
boolean PERMISSIONS_OK = true;
for (String p : REQUIRED_PERMISSIONS) {
- if (PermissionChecker.checkSelfPermission(this, p) != PermissionChecker.PERMISSION_GRANTED) {
+ if (PermissionChecker.checkSelfPermission(this, p) != PackageManager.PERMISSION_GRANTED) {
PERMISSIONS_OK = false;
break;
}
@@ -193,14 +755,24 @@ protected void onCreate(Bundle savedInstanceState) {
awarePackages.addDataScheme("package");
registerReceiver(packageMonitor, awarePackages);
- Intent whitelisting = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
- whitelisting.setData(Uri.parse("package:" + getPackageName()));
- startActivity(whitelisting);
+ PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE);
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.LOLLIPOP_MR1
+ || !powerManager.isIgnoringBatteryOptimizations(getPackageName())) {
+ Intent whitelisting = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
+ whitelisting.setData(Uri.parse("package:" + getPackageName()));
+ startActivity(whitelisting);
+ }
// Register the broadcast receiver
registerReceiver(screenshotServiceStoppedReceiver, new IntentFilter(ScreenShot.ACTION_SCREENSHOT_SERVICE_STOPPED));
registerReceiver(screenshotStatusReceiver, new IntentFilter(ScreenShot.ACTION_SCREENSHOT_STATUS));
registerReceiver(noteStatusReceiver, new IntentFilter(Notes.ACTION_NOTE_STATUS));
+ IntentFilter studyConfigUpdates =
+ new IntentFilter(Aware.ACTION_AWARE_STUDY_CONFIG_UPDATED);
+ studyConfigUpdates.addAction(Aware.ACTION_AWARE_STUDY_CONFIG_UPDATE_AVAILABLE);
+ registerReceiver(studyConfigUpdatedReceiver, studyConfigUpdates);
+ registerReceiver(reauthRequiredReceiver,
+ new IntentFilter(Aware.ACTION_AWARE_STUDY_REAUTH_REQUIRED));
checkAndStartScreenshotService();
checkAndStartPlugin();
}
@@ -233,7 +805,20 @@ public boolean onPreferenceClick(Preference preference) {
@Override
public boolean onPreferenceTreeClick(PreferenceScreen preferenceScreen, final Preference preference) {
+ // In a study, tapping a sensor shows its data-collection status instead of the locked settings
+ // — unless the researcher opted in to participant edits via enable_config_update.
+ if (isStudySettingsLocked()
+ && preference instanceof PreferenceScreen
+ && SensorCollection.isSensor(preference.getKey())) {
+ showSensorCollectionDialog((PreferenceScreen) preference);
+ return true;
+ }
if (preference instanceof PreferenceScreen) {
+ // Editable mode opens the sensor's settings screen; surface the same collection status the
+ // locked view shows in a dialog as a row at the top of that screen.
+ if (SensorCollection.isSensor(preference.getKey())) {
+ showSensorStatusRow((PreferenceScreen) preference);
+ }
Dialog subpref = ((PreferenceScreen) preference).getDialog();
ViewGroup root = (ViewGroup) subpref.findViewById(android.R.id.content).getParent();
Toolbar toolbar = new Toolbar(this);
@@ -242,9 +827,11 @@ public boolean onPreferenceTreeClick(PreferenceScreen preferenceScreen, final Pr
toolbar.setTitle(preference.getTitle());
root.addView(toolbar, 0); //add to the top
+ openSubPrefDialog = subpref;
subpref.setOnDismissListener(new DialogInterface.OnDismissListener() {
@Override
public void onDismiss(DialogInterface dialog) {
+ if (openSubPrefDialog == dialog) openSubPrefDialog = null;
new SettingsSync().execute(preference);
}
});
@@ -252,8 +839,261 @@ public void onDismiss(DialogInterface dialog) {
return super.onPreferenceTreeClick(preferenceScreen, preference);
}
+ /**
+ * A millisecond timestamp as time elapsed since it, or {@code absent} when there is no such
+ * timestamp. Android time formatting lives here so the status-text helpers stay pure.
+ *
+ * @param absent what to render for a missing timestamp; null lets the caller's own wording apply
+ */
+ private static CharSequence relativeTimeOr(long timestampMs, String absent) {
+ if (timestampMs <= 0) return absent;
+ return DateUtils.getRelativeTimeSpanString(
+ timestampMs, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS);
+ }
+
+ /** Shows whether the given sensor is currently collecting data, and if not, why + what to do. */
+ private void showSensorCollectionDialog(PreferenceScreen sensor) {
+ boolean accessibilityOn = isAccessibilityServiceEnabled(this, Applications.class);
+ SensorCollection.Status status =
+ SensorCollection.getStatus(getApplicationContext(), sensor.getKey(), accessibilityOn);
+
+ JSONObject activeConfig = Aware.getActiveStudyConfig(getApplicationContext());
+ final JSONArray activeConfigs = new JSONArray();
+ if (activeConfig != null) activeConfigs.put(activeConfig);
+ final List heldForCategory =
+ SensorCollection.heldConsentsForCategory(
+ getApplicationContext(), activeConfigs, sensor.getKey());
+
+ StringBuilder msg = new StringBuilder(SensorCollection.statusSummary(
+ status,
+ relativeTimeOr(status.lastDataMs, "never"),
+ relativeTimeOr(SensorCollection.lastDeliveredMs(
+ getApplicationContext(), sensor.getKey()), null)));
+
+ AlertDialog.Builder builder = new AlertDialog.Builder(this)
+ .setTitle(sensor.getTitle())
+ .setMessage(msg.toString());
+
+ // A category can already be collecting while one of its other consent choices is still off
+ // (Applications contains both app usage and masked keyboard text). Keep re-consent reachable
+ // in that case instead of hiding it merely because the category has recent application data.
+ final SensorCollection.ConsentItem consent = SensorCollection.consentItemForCategory(sensor.getKey());
+ if (!heldForCategory.isEmpty()) {
+ msg.append("\n\nWaiting for your consent: ");
+ List heldLabels = new ArrayList<>();
+ for (SensorCollection.ConsentItem held : heldForCategory) heldLabels.add(held.label);
+ msg.append(TextUtils.join(", ", heldLabels));
+ builder.setMessage(msg.toString());
+ builder.setPositiveButton("Review consent", new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ Intent review = new Intent(getApplicationContext(), SensorConsentActivity.class);
+ review.putExtra(SensorConsentActivity.EXTRA_UPDATE_MODE, true);
+ startActivity(review);
+ }
+ });
+ builder.setNegativeButton("Close", null);
+ } else if (!status.collecting && consent != null) {
+ builder.setPositiveButton("Enable", new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ enableConsentSensor(consent);
+ }
+ });
+ builder.setNegativeButton("Close", null);
+ } else {
+ builder.setPositiveButton("OK", null);
+ }
+ builder.show();
+ }
+
+ /**
+ * Inserts (or refreshes) a non-selectable row at the top of a sensor's settings screen showing the
+ * same collecting / why / last-data information the locked view presents in a dialog, so the
+ * participant sees a sensor's live status while editing it.
+ */
+ private void showSensorStatusRow(PreferenceScreen screen) {
+ String rowKey = screen.getKey() + "_collection_status";
+ Preference row = screen.findPreference(rowKey);
+ if (row == null) {
+ row = new Preference(this);
+ row.setKey(rowKey);
+ row.setSelectable(false);
+ row.setOrder(-1); // above the sensor's own settings
+ screen.addPreference(row);
+ }
+
+ boolean accessibilityOn = isAccessibilityServiceEnabled(this, Applications.class);
+ SensorCollection.Status status =
+ SensorCollection.getStatus(getApplicationContext(), screen.getKey(), accessibilityOn);
+ row.setTitle(SensorCollection.statusHeadline(status));
+ row.setSummary(SensorCollection.statusDetail(
+ status.reason,
+ relativeTimeOr(status.lastDataMs, "never"),
+ relativeTimeOr(SensorCollection.lastDeliveredMs(
+ getApplicationContext(), screen.getKey()), null),
+ status.fixHint));
+
+ // A physical sensor that does not exist can never collect, so its Activate checkbox must not
+ // imply otherwise. Keep the screen open for the explanatory status row and other information,
+ // but disable only its activation control and clear any stale enabled value.
+ if (!SensorCollection.isHardwareAvailable(getApplicationContext(), screen.getKey())) {
+ Preference activation = screen.findPreference("status_" + screen.getKey());
+ if (activation instanceof CheckBoxPreference) {
+ CheckBoxPreference checkbox = (CheckBoxPreference) activation;
+ checkbox.setEnabled(false);
+ checkbox.setSummary("Unavailable on this device");
+ if (checkbox.isChecked()) {
+ revertingUnavailablePreference = true;
+ try {
+ Aware.setSetting(getApplicationContext(), activation.getKey(), false);
+ checkbox.setChecked(false);
+ } finally {
+ revertingUnavailablePreference = false;
+ }
+ Aware.startAWARE(getApplicationContext());
+ }
+ }
+ }
+ }
+
+ private static final int RC_ENABLE_SENSOR = 47001;
+
+ // The consent group whose permission request is in flight from enableConsentSensor(), so the
+ // result callback can follow up (e.g. nudge for background location once Location is granted).
+ private String pendingEnableConsentKey;
+
+ /**
+ * Participant-initiated enable of a study sensor they hadn't consented to: undo the decline, turn
+ * on the sub-settings the study actually wants, start collection, and route to whatever grant is
+ * still missing (runtime permission dialog, or the accessibility / Location-services screens).
+ */
+ private void enableConsentSensor(SensorCollection.ConsentItem consent) {
+ // Un-decline this consent group so the config sync won't force it back off.
+ Set declined = new HashSet<>(Arrays.asList(
+ Aware.getSetting(getApplicationContext(), Aware_Preferences.STUDY_DECLINED_SENSORS).split(",")));
+ List controlled = SensorCollection.controlledSettings(consent);
+ declined.removeAll(controlled);
+ declined.remove("");
+ Aware.setSetting(getApplicationContext(), Aware_Preferences.STUDY_DECLINED_SENSORS,
+ TextUtils.join(",", declined));
+
+ // Turn on only the sub-settings the study config enables (fall back to the whole group if the
+ // config can't be read), so enabling "Calls & messages" doesn't switch on more than the study wants.
+ List toEnable = SensorCollection.configEnabledSettings(
+ Aware.getActiveStudyConfig(getApplicationContext()),
+ controlled.toArray(new String[controlled.size()]));
+ if (toEnable.isEmpty()) toEnable = Arrays.asList(consent.statusSettings);
+ for (String setting : toEnable) {
+ Aware.setSetting(getApplicationContext(), setting, true);
+ }
+
+ JSONArray configs = new JSONArray();
+ JSONObject activeConfig = Aware.getActiveStudyConfig(getApplicationContext());
+ if (activeConfig != null) configs.put(activeConfig);
+ Aware.logStudyCompliance(getApplicationContext(),
+ "consent updated: " + SensorCollection.consentStateSummary(configs, declined));
+
+ Aware.startAWARE(getApplicationContext());
+
+ promptGrantsFor(consent);
+ }
+
+ /**
+ * Route the participant to whatever grant a just-enabled sensor still needs — the accessibility
+ * service for app/keyboard/screen sensors, or its runtime permissions — skipping anything already
+ * in place. Shared by the study "Enable" path and by a direct checkbox toggle in editable mode, so
+ * enabling a sensor always surfaces the same consent prompts. No-op for sensors that need neither.
+ */
+ private void promptGrantsFor(SensorCollection.ConsentItem consent) {
+ if (consent == null) return;
+ if (consent.needsAccessibility) {
+ // The accessibility service is a single shared toggle, so if it's already on (enabled for
+ // another accessibility sensor) there's nothing to send them to.
+ if (!SensorCollection.isAccessibilityServiceEnabled(getApplicationContext())) {
+ enableAccessibilityService();
+ }
+ } else if (consent.permissions.length > 0) {
+ pendingEnableConsentKey = consent.key;
+ ActivityCompat.requestPermissions(this, consent.permissions, RC_ENABLE_SENSOR);
+ }
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ if (requestCode == RC_ENABLE_SENSOR) {
+ // Whatever was granted, (re)start AWARE so the just-enabled sensor comes up now.
+ Aware.startAWARE(getApplicationContext());
+ // Foreground location alone stops logging when AWARE isn't open — nudge for "Allow all
+ // the time" so location collected from here is complete, just like the consent screen does.
+ if ("locations".equals(pendingEnableConsentKey)
+ && !SensorCollection.hasBackgroundLocation(getApplicationContext())) {
+ promptAlwaysLocation();
+ }
+ pendingEnableConsentKey = null;
+ }
+ }
+
+ private void promptAlwaysLocation() {
+ new AlertDialog.Builder(this)
+ .setTitle("Set location to \"Allow all the time\"")
+ .setMessage("To record the places you visit continuously — even when AWARE isn't open — " +
+ "set this app's Location permission to \"Allow all the time\". With only " +
+ "\"While using the app\", location is recorded just while AWARE is open, so the data " +
+ "will be incomplete.")
+ .setPositiveButton("Open settings", new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ startActivity(new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
+ Uri.parse("package:" + getPackageName())));
+ }
+ })
+ .setNegativeButton("Not now", null)
+ .show();
+ }
+
+ // Guards the revert below from re-triggering itself: reverting a preference re-persists it,
+ // which fires this listener again for the same key.
+ private boolean revertingStudyPreference = false;
+ private boolean revertingUnavailablePreference = false;
+
+ /**
+ * Settings stay researcher-controlled while enrolled in a study, unless the researcher opted
+ * in to participant edits via the study config's enable_config_update setting.
+ */
+ private boolean isStudySettingsLocked() {
+ boolean inStudy = Aware.isStudy(getApplicationContext());
+ boolean editsAllowed = Boolean.valueOf(Aware.getSetting(getApplicationContext(), Aware_Preferences.ENABLE_CONFIG_UPDATE));
+ return inStudy && !editsAllowed;
+ }
+
@Override
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
+ if (revertingUnavailablePreference) return;
+
+ // onPreferenceTreeClick only stops navigating INTO a sensor's screen — it doesn't stop a
+ // change from taking effect once a checkbox is visible and tapped. Enforce it here too, at
+ // the point the value actually gets written, so a participant can never actually flip a
+ // researcher-controlled setting while in a study (unless enable_config_update allows it).
+ if (!revertingStudyPreference && isStudySettingsLocked()) {
+ revertingStudyPreference = true;
+ try {
+ String currentValue = Aware.getSetting(getApplicationContext(), key);
+ Preference pref = findPreference(key);
+ if (CheckBoxPreference.class.isInstance(pref)) {
+ ((CheckBoxPreference) pref).setChecked(currentValue.equals("true"));
+ } else if (EditTextPreference.class.isInstance(pref)) {
+ ((EditTextPreference) pref).setText(currentValue);
+ } else if (ListPreference.class.isInstance(pref)) {
+ ((ListPreference) pref).setValue(currentValue);
+ }
+ } finally {
+ revertingStudyPreference = false;
+ }
+ return;
+ }
+
String value = "";
Map keys = sharedPreferences.getAll();
if (keys.containsKey(key)) {
@@ -266,8 +1106,38 @@ else if (entry instanceof Integer)
value = String.valueOf(sharedPreferences.getInt(key, 0));
}
+ // Defense in depth for unavailable physical sensors. The editable screen disables the
+ // checkbox, but reject the write as well in case another preference/UI path attempts it.
+ if ("true".equals(value)
+ && key.startsWith("status_")
+ && !SensorAvailability.isHardwareAvailable(getApplicationContext(), key)) {
+ revertingUnavailablePreference = true;
+ try {
+ Aware.setSetting(getApplicationContext(), key, false);
+ Preference unavailable = findPreference(key);
+ if (unavailable instanceof CheckBoxPreference) {
+ ((CheckBoxPreference) unavailable).setChecked(false);
+ }
+ } finally {
+ revertingUnavailablePreference = false;
+ }
+ Aware.startAWARE(getApplicationContext());
+ Toast.makeText(getApplicationContext(),
+ "This sensor is unavailable on this device", Toast.LENGTH_SHORT).show();
+ return;
+ }
+
Aware.setSetting(getApplicationContext(), key, value);
Preference pref = findPreference(key);
+
+ // In editable study mode, the participant's sensor choices are the effective study
+ // configuration rather than temporary drift from the server JSON. Persist the typed value
+ // into the active study row and append a compliance event so the researcher sees the config
+ // that actually produced this device's uploaded data.
+ if (isPreferenceInsideSensorScreen(pref)) {
+ StudyUtils.persistEditableSensorSetting(getApplicationContext(), key, value);
+ }
+
if (CheckBoxPreference.class.isInstance(pref)) {
CheckBoxPreference check = (CheckBoxPreference) findPreference(key);
check.setChecked(Aware.getSetting(getApplicationContext(), key).equals("true"));
@@ -277,6 +1147,13 @@ else if (entry instanceof Integer)
//Start/Stop sensor
Aware.startAWARE(getApplicationContext());
+
+ // Turning a sensor on directly (editable mode) still needs its grants: the accessibility
+ // service for app/keyboard/screen sensors, or runtime permissions. Prompt the same way the
+ // study "Enable" path does, so the consent dialogs show up here too.
+ if (value.equals("true")) {
+ promptGrantsFor(SensorCollection.consentItemForSetting(key));
+ }
}
if (EditTextPreference.class.isInstance(pref)) {
EditTextPreference text = (EditTextPreference) findPreference(key);
@@ -300,6 +1177,17 @@ else if (entry instanceof Integer)
}
+ private boolean isPreferenceInsideSensorScreen(Preference preference) {
+ Preference current = preference;
+ while (current != null) {
+ PreferenceGroup parent = getPreferenceParent(current);
+ if (parent == null) return false;
+ if (SensorCollection.isSensor(parent.getKey())) return true;
+ current = parent;
+ }
+ return false;
+ }
+
private void handleScreenshotPreferenceChange(String key, String value) {
if (key.equals(Aware_Preferences.STATUS_SCREENSHOT)) {
if (value.equals("true")) {
@@ -317,6 +1205,10 @@ private void handleScreenshotPreferenceChange(String key, String value) {
}
private class SettingsSync extends AsyncTask {
+ // Several status_* preferences can belong to the same sensor screen. Refresh that parent
+ // only once per sync pass; each individual checkbox/value is still reconciled above.
+ private final Set refreshedSensorParents = new HashSet<>();
+
@Override
protected Void doInBackground(Preference... params) {
for (Preference pref : params) {
@@ -372,12 +1264,20 @@ protected void onProgressUpdate(Preference... values) {
if (PreferenceScreen.class.isInstance(getPreferenceParent(pref))) {
PreferenceScreen parent = (PreferenceScreen) getPreferenceParent(pref);
+ if (!refreshedSensorParents.add(parent.getKey())) return;
+ boolean inStudy = Aware.isStudy(getApplicationContext());
boolean prefEnabled = Boolean.valueOf(Aware.getSetting(Aware_Client.this, Aware_Preferences.ENABLE_CONFIG_UPDATE));
- parent.setEnabled(prefEnabled); // enabled/disabled based on config
+ // In a study the settings stay researcher-controlled, but keep the row tappable so
+ // the participant can open the data-collection status dialog (click is intercepted
+ // in onPreferenceTreeClick, so the editable sub-screen never opens).
+ parent.setEnabled(inStudy || prefEnabled);
+ boolean shouldDisableView = !inStudy;
+ if (parent.getShouldDisableView() != shouldDisableView) {
+ parent.setShouldDisableView(shouldDisableView);
+ }
ListAdapter children = parent.getRootAdapter();
- boolean isActive = false;
ArrayList sensorStatuses = new ArrayList();
for (int i = 0; i < children.getCount(); i++) {
Object obj = children.getItem(i);
@@ -385,17 +1285,14 @@ protected void onProgressUpdate(Preference... values) {
CheckBoxPreference child = (CheckBoxPreference) obj;
if (child.getKey().contains("status_")) {
sensorStatuses.add(child.getKey());
- if (child.isChecked()) {
- isActive = true;
- break;
- }
}
}
}
// Check if any of the status settings of a sensor (parent pref) is active in the study config
- JSONObject studyConfig = Aware.getStudyConfig(getApplicationContext(), Aware.getSetting(getApplicationContext(), Aware_Preferences.WEBSERVICE_SERVER));
+ JSONObject studyConfig = Aware.getActiveStudyConfig(getApplicationContext());
boolean isActiveInConfig = false;
+ ArrayList activeSensorStatuses = new ArrayList();
try {
JSONArray sensorsList = studyConfig.getJSONArray("sensors");
for (int i = 0; i < sensorsList.length(); i++) {
@@ -403,29 +1300,46 @@ protected void onProgressUpdate(Preference... values) {
String sensorSetting = sensorInfo.getString("setting");
if (sensorStatuses.contains(sensorSetting)) {
- sensorStatuses.remove(sensorSetting);
- isActiveInConfig = sensorInfo.getBoolean("value");
+ boolean sensorEnabled = sensorInfo.getBoolean("value");
+ if (sensorEnabled) {
+ isActiveInConfig = true;
+ activeSensorStatuses.add(sensorSetting);
+ }
}
-
- if (isActiveInConfig || sensorStatuses.size() == 0) break;
}
} catch (JSONException e) {
e.printStackTrace();
}
- // Only show sensor if it is active in the study config
- if (isActiveInConfig) {
- if (pref != null) Log.i(TAG, "Pref with key: " + pref.getKey() + " is active!");
+ // Locked study view lists only the sensors the study actually collects. In editable
+ // mode (not in a study, or the study allowed edits via enable_config_update) show every
+ // sensor so the participant can enable/disable any of them, not just the study's set.
+ boolean showAllSensors = !isStudySettingsLocked();
+ if (isActiveInConfig || showAllSensors) {
+ if (pref != null) Log.i(TAG, "Pref with key: " + pref.getKey() + " is shown");
try {
Class res = R.drawable.class;
Field field = res.getField("ic_action_" + parent.getKey());
int icon_id = field.getInt(null);
Drawable category_icon = ContextCompat.getDrawable(getApplicationContext(), icon_id);
if (category_icon != null) {
- int colorId = isActive ? R.color.accent : R.color.lightGray;
+ // Blue if AWARE is actually collecting this sensor's data (recent rows
+ // in its provider), grey otherwise. Tap the sensor for the reason.
+ boolean accessibilityOn = isAccessibilityServiceEnabled(getApplicationContext(), Applications.class);
+ SensorCollection.Status collectionStatus =
+ SensorCollection.getStatus(getApplicationContext(), parent.getKey(), accessibilityOn);
+ int colorId = collectionStatus.collecting ? R.color.accent : R.color.lightGray;
category_icon.setColorFilter(new PorterDuffColorFilter(ContextCompat.getColor(getApplicationContext(), colorId), PorterDuff.Mode.SRC_IN));
parent.setIcon(category_icon);
- onContentChanged();
+ // Editable mode deliberately lists hardware-backed sensors even when this
+ // phone cannot provide them. Make that permanent limitation explicit in
+ // the list itself; opening the row shows the fuller status and last-data
+ // detail added by showSensorStatusRow().
+ if (showAllSensors
+ && !SensorCollection.isHardwareAvailable(
+ getApplicationContext(), parent.getKey())) {
+ parent.setSummary(collectionStatus.reason);
+ }
}
} catch (NoSuchFieldException | IllegalAccessException e) {
e.printStackTrace();
@@ -444,40 +1358,33 @@ public void onReceive(Context context, Intent intent) {
if (ScreenShot.ACTION_SCREENSHOT_STATUS.equals(intent.getAction())) {
String status = intent.getStringExtra(ScreenShot.EXTRA_SCREENSHOT_STATUS);
if (ScreenShot.STATUS_RETRY_COUNT_EXCEEDED.equals(status)) {
- Log.d(TAG, "Screenshot service retry count exceeded. Restarting service...");
- restartScreenshotService();
+ Log.w(TAG, "Screenshot capture retry limit reached; not auto-restarting");
}
}
}
};
- private void restartScreenshotService() {
- stopScreenshotService();
- // Optionally wait for a few seconds before restarting the service to avoid rapid restarts
- new Handler().postDelayed(new Runnable() {
- @Override
- public void run() {
- checkAndStartScreenshotService();
- }
- }, 2000); // Wait for 2 seconds before restarting
- }
-
private void checkAndStartScreenshotService() {
+ // Only act when the active study actually enabled screenshot. Outside a study, or in a
+ // study that doesn't use screenshot, do nothing — and never prompt for accessibility on
+ // plain app start / device mode. Screenshot is the only sensor started here and the only
+ // reason this path needs accessibility, so gating on it also gates the accessibility ask.
+ if (!Aware.isStudy(this)) return;
+ if (!Aware.getSetting(getApplicationContext(), Aware_Preferences.STATUS_SCREENSHOT).equals("true")) return;
+
if (!isAccessibilityServiceEnabled(this, Applications.class)) {
enableAccessibilityService();
return;
}
- if (Aware.getSetting(getApplicationContext(), Aware_Preferences.STATUS_SCREENSHOT).equals("true")) {
- if (ScreenShot.mediaProjectionResultCode != 0 && ScreenShot.mediaProjectionResultData != null) {
- if (!isScreenshotServiceRunning()) {
- startScreenshotService(ScreenShot.mediaProjectionResultCode, ScreenShot.mediaProjectionResultData);
- }
- } else {
- MediaProjectionManager projectionManager = (MediaProjectionManager) this.getSystemService(Context.MEDIA_PROJECTION_SERVICE);
- Intent intent = projectionManager.createScreenCaptureIntent();
- startActivityForResult(intent, REQUEST_CODE_SCREENSHOT);
+ if (ScreenShot.mediaProjectionResultCode != 0 && ScreenShot.mediaProjectionResultData != null) {
+ if (!isScreenshotServiceRunning()) {
+ startScreenshotService(ScreenShot.mediaProjectionResultCode, ScreenShot.mediaProjectionResultData);
}
+ } else {
+ MediaProjectionManager projectionManager = (MediaProjectionManager) this.getSystemService(Context.MEDIA_PROJECTION_SERVICE);
+ Intent intent = projectionManager.createScreenCaptureIntent();
+ startActivityForResult(intent, REQUEST_CODE_SCREENSHOT);
}
}
@@ -496,13 +1403,283 @@ private void checkAndStartPlugin(){
}
}
- private void enableAccessibilityService() {
- Intent intent = new Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS);
- intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
- startActivity(intent);
- Toast.makeText(this, "Please enable the accessibility service.", Toast.LENGTH_LONG).show();
+private AlertDialog accessibilityDialog;
+
+// Whether each prompt has already been shown this session (this Activity's lifetime), so neither
+// is re-shown on every subsequent onResume once the participant has dismissed it.
+private boolean accessibilityPromptedThisSession = false;
+private boolean locationServicesPromptedThisSession = false;
+
+private void enableAccessibilityService() {
+ enableAccessibilityService(null);
+}
+
+/**
+ * @param onResolved run when the dialog is dismissed, whichever button the participant chose;
+ * pass null for no follow-up action.
+ */
+private void enableAccessibilityService(final Runnable onResolved) {
+ if (accessibilityDialog != null && accessibilityDialog.isShowing()) {
+ return; // already prompting; don't stack dialogs on repeated onResume
+ }
+ accessibilityPromptedThisSession = true;
+ final ComponentName service = new ComponentName(this, Applications.class);
+
+ accessibilityDialog = new AlertDialog.Builder(this)
+ .setTitle("Enable AWARE accessibility")
+ .setMessage("AWARE needs the Accessibility service to record app usage and screen content. On the next screen, find \"AWARE\", open it, and turn the switch ON.")
+ .setPositiveButton("Open settings", new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ if (Build.VERSION.SDK_INT >= 30) {
+ try {
+ Intent details = new Intent("android.settings.ACCESSIBILITY_DETAILS_SETTINGS");
+ details.putExtra("android.intent.extra.COMPONENT_NAME", service.flattenToString());
+ details.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ startActivity(details);
+ return;
+ } catch (Exception e) {
+ Log.w(TAG, "Accessibility detail settings unavailable, falling back to list", e);
+ }
+ }
+ try {
+ startActivity(new Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
+ } catch (Exception e) {
+ Toast.makeText(Aware_Client.this,
+ "Please open Settings > Accessibility and enable AWARE.",
+ Toast.LENGTH_LONG).show();
+ }
+ }
+ })
+ .setNegativeButton("Not now", null)
+ .setCancelable(true)
+ .setOnDismissListener(new DialogInterface.OnDismissListener() {
+ @Override
+ public void onDismiss(DialogInterface dialog) {
+ if (onResolved != null) onResolved.run();
+ }
+ })
+ .show();
+ }
+
+ private AlertDialog locationServicesDialog;
+
+ /**
+ * WiFi scanning requires the OS-level Location toggle on system-wide (Android blocks
+ * WifiManager.startScan() with a SecurityException otherwise, regardless of granted
+ * permissions) — prompt the participant to enable it, mirroring enableAccessibilityService().
+ */
+ private void enableLocationServices() {
+ if (locationServicesDialog != null && locationServicesDialog.isShowing()) {
+ return; // already prompting; don't stack dialogs on repeated onResume
+ }
+ locationServicesPromptedThisSession = true;
+ locationServicesDialog = new AlertDialog.Builder(this)
+ .setTitle("Enable Location services")
+ .setMessage("This study collects WiFi data, which requires Location services to be turned on for the whole phone (Android requires this even though AWARE doesn't use your location for WiFi scanning). On the next screen, turn Location ON.")
+ .setPositiveButton("Open settings", new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ try {
+ startActivity(new Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
+ } catch (Exception e) {
+ Toast.makeText(Aware_Client.this,
+ "Please open Settings > Location and turn it on.",
+ Toast.LENGTH_LONG).show();
+ }
+ }
+ })
+ .setNegativeButton("Not now", null)
+ .setCancelable(true)
+ .setOnDismissListener(new DialogInterface.OnDismissListener() {
+ @Override
+ public void onDismiss(DialogInterface dialog) {
+ locationServicesDialog = null;
+ }
+ })
+ .show();
+ }
+
+ /**
+ * Fills in the Device section's delivery line: how far the research database has been brought up
+ * to, and whether delivery is currently failing.
+ *
+ * Shown here because the per-sensor delivery detail is one tap into each sensor, so a phone that
+ * has stopped delivering everything looks normal from the list. The pending-record count is not
+ * passed: it would mean counting rows in every provider on the main thread, and the line reads
+ * correctly without it.
+ */
+ private void showDataDeliveryStatus() {
+ Preference delivery = findPreference("data_delivery");
+ if (delivery == null) return;
+
+ long deliveredUpTo = UploadHealth.deliveredUpToMs(this);
+ CharSequence relative = deliveredUpTo > 0
+ ? DateUtils.getRelativeTimeSpanString(deliveredUpTo, System.currentTimeMillis(),
+ DateUtils.MINUTE_IN_MILLIS)
+ : null;
+ delivery.setSummary(UploadHealth.statusLine(
+ relative, UploadHealth.failingTables(this), 0));
+ }
+
+ /**
+ * Prompts for the accessibility service and the OS-level Location toggle the joined study needs,
+ * one dialog at a time. When both are needed the accessibility prompt is shown first and the
+ * Location prompt follows only once it is dismissed, so the two non-cancelable dialogs are never
+ * shown together. Each prompt is shown at most once per session.
+ */
+ private void promptForRequiredServices() {
+ if (Aware.is_watch(this) || !Aware.isStudy(this)) return;
+
+ boolean needsAccessibility = studyNeedsAccessibility()
+ && !isAccessibilityServiceEnabled(this, Applications.class);
+
+ if (needsAccessibility && !accessibilityPromptedThisSession) {
+ enableAccessibilityService(new Runnable() {
+ @Override
+ public void run() {
+ promptForLocationServicesIfNeeded();
+ }
+ });
+ } else if (accessibilityDialog == null || !accessibilityDialog.isShowing()) {
+ promptForLocationServicesIfNeeded();
+ }
+ }
+
+ /**
+ * Shows the Location-services prompt when the joined study needs WiFi, the OS Location toggle is
+ * off, and it hasn't already been shown this session.
+ */
+ private void promptForLocationServicesIfNeeded() {
+ if (Aware.is_watch(this) || !Aware.isStudy(this)) return;
+ if (locationServicesPromptedThisSession) return;
+ if (studyNeedsWifi() && !SensorCollection.isLocationServicesEnabled(this)) {
+ enableLocationServices();
+ }
+ }
+
+ /**
+ * True if the joined study currently has WiFi scanning enabled, i.e. Location services are
+ * actually needed right now. Used to avoid prompting participants in studies that don't use it.
+ */
+ private boolean studyNeedsWifi() {
+ return "true".equalsIgnoreCase(Aware.getSetting(getApplicationContext(), Aware_Preferences.STATUS_WIFI));
+ }
+
+ /**
+ * Device mode only: lists previously joined studies in the "study_actions" category, below
+ * the Join button. Each row opens a details dialog with re-join / copy link / delete actions.
+ * Safe to call again to refresh (e.g. after a delete).
+ */
+ private void populateStudyHistory() {
+ PreferenceCategory studyActions = (PreferenceCategory) findPreference("study_actions");
+ if (studyActions == null) return;
+
+ for (Preference p : studyHistoryPrefs) studyActions.removePreference(p);
+ studyHistoryPrefs.clear();
+
+ List studies = Aware.getJoinedStudies(getApplicationContext());
+ for (final ContentValues study : studies) {
+ Preference row = new Preference(this);
+ String title = study.getAsString(Aware_Provider.Aware_Studies.STUDY_TITLE);
+ row.setTitle((title == null || title.trim().length() == 0) ? "(untitled study)" : title);
+ row.setSummary(studyHistorySummary(study));
+ row.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() {
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ showStudyHistoryDialog(study);
+ return true;
+ }
+ });
+ studyActions.addPreference(row);
+ studyHistoryPrefs.add(row);
+ }
+ }
+
+ private String studyHistorySummary(ContentValues study) {
+ Double joined = study.getAsDouble(Aware_Provider.Aware_Studies.STUDY_JOINED);
+ Double exit = study.getAsDouble(Aware_Provider.Aware_Studies.STUDY_EXIT);
+ String status = (exit == null || exit == 0) ? "Enrolled" : "Left";
+ if (joined != null && joined > 0)
+ return "Joined " + DateFormat.getDateInstance().format(new Date(joined.longValue())) + " · " + status;
+ return status;
+ }
+
+ /** Details + management dialog for a past study: view (the card), re-join, copy link, delete. */
+ private void showStudyHistoryDialog(final ContentValues study) {
+ View card = getLayoutInflater().inflate(R.layout.study_card, null);
+ StudyCard.bind(this, card, study);
+ final String url = study.getAsString(Aware_Provider.Aware_Studies.STUDY_URL);
+
+ new AlertDialog.Builder(this)
+ .setView(card)
+ .setPositiveButton("Re-join", new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ rejoinStudy(url);
+ }
+ })
+ .setNeutralButton("Copy link", new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ AwareUtil.copyToClipboard(Aware_Client.this, "AWARE study link", url);
+ }
+ })
+ .setNegativeButton("Delete", new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ confirmDeleteStudy(study);
+ }
+ })
+ .show();
+ }
+
+ private void rejoinStudy(String url) {
+ if (url == null || url.length() == 0) return;
+ // Reuse the standard join dialog (properly shown/attached) with the URL pre-filled,
+ // rather than the direct Aware_Join_Study URL path, which crashes (unattached fragment).
+ new JoinStudyDialog(this).setStudyUrl(url).showDialog();
}
+ private void confirmDeleteStudy(final ContentValues study) {
+ final String url = study.getAsString(Aware_Provider.Aware_Studies.STUDY_URL);
+
+ // Guard: never delete the study we're currently enrolled in.
+ if (url != null && Aware.isStudy(getApplicationContext())) {
+ Cursor active = Aware.getActiveStudy(getApplicationContext());
+ boolean isActive = false;
+ if (active != null) {
+ if (active.moveToFirst()) {
+ isActive = url.equals(active.getString(
+ active.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_URL)));
+ }
+ active.close();
+ }
+ if (isActive) {
+ Toast.makeText(this, "You can't delete the study you're currently in.",
+ Toast.LENGTH_LONG).show();
+ return;
+ }
+ }
+
+ new AlertDialog.Builder(this)
+ .setTitle("Delete from history")
+ .setMessage("Remove this study from your history? This does not affect any data already uploaded to the server.")
+ .setPositiveButton("Delete", new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ if (url != null && url.length() > 0) {
+ getContentResolver().delete(Aware_Provider.Aware_Studies.CONTENT_URI,
+ Aware_Provider.Aware_Studies.STUDY_URL + "=?", new String[]{url});
+ }
+ populateStudyHistory();
+ }
+ })
+ .setNegativeButton("Cancel", null)
+ .show();
+ }
private boolean isScreenshotServiceRunning() {
ActivityManager manager = (ActivityManager) getSystemService(Context.ACTIVITY_SERVICE);
@@ -698,51 +1875,142 @@ public void run() {
if (requestCode == REQUEST_CODE_SCREENSHOT) {
if (resultCode == RESULT_OK) {
+ updateDeclinedSensor(Aware_Preferences.STATUS_SCREENSHOT, false);
startScreenshotService(resultCode, data);
} else {
+ // Persist this as a participant decline. Otherwise the study drift reconciler sees
+ // server=true/local=false and repeatedly re-enables screenshot, causing the capture
+ // consent Activity to return on later launches.
+ Aware.setSetting(getApplicationContext(),
+ Aware_Preferences.STATUS_SCREENSHOT, false);
+ updateDeclinedSensor(Aware_Preferences.STATUS_SCREENSHOT, true);
Toast.makeText(this, "Screen capture permission denied", Toast.LENGTH_SHORT).show();
}
}
}
+ private void updateDeclinedSensor(String statusSetting, boolean declined) {
+ Set settings = new HashSet<>();
+ String raw = Aware.getSetting(
+ getApplicationContext(), Aware_Preferences.STUDY_DECLINED_SENSORS);
+ if (raw != null) {
+ for (String setting : raw.split(",")) {
+ if (!setting.trim().isEmpty()) settings.add(setting.trim());
+ }
+ }
+ if (declined) settings.add(statusSetting);
+ else settings.remove(statusSetting);
+ Aware.setSetting(getApplicationContext(),
+ Aware_Preferences.STUDY_DECLINED_SENSORS,
+ TextUtils.join(",", settings));
+ }
+
private boolean isAccessibilityServiceEnabled(Context context, Class> accessibilityServiceClass) {
- int accessibilityEnabled = 0;
- final String service = context.getPackageName() + "/" + accessibilityServiceClass.getCanonicalName();
- try {
- accessibilityEnabled = Settings.Secure.getInt(context.getApplicationContext().getContentResolver(), Settings.Secure.ACCESSIBILITY_ENABLED);
- } catch (Settings.SettingNotFoundException e) {
- Log.e(TAG, "Error finding setting, default accessibility to not found: " + e.getMessage());
+ final ComponentName expected = new ComponentName(context, accessibilityServiceClass);
+ final String settingValue = Settings.Secure.getString(
+ context.getApplicationContext().getContentResolver(),
+ Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES);
+ if (settingValue == null) {
+ return false;
}
TextUtils.SimpleStringSplitter colonSplitter = new TextUtils.SimpleStringSplitter(':');
+ colonSplitter.setString(settingValue);
+ while (colonSplitter.hasNext()) {
+ ComponentName enabled = ComponentName.unflattenFromString(colonSplitter.next());
+ if (expected.equals(enabled)) {
+ return true;
+ }
+ }
- if (accessibilityEnabled == 1) {
- String settingValue = Settings.Secure.getString(context.getApplicationContext().getContentResolver(), Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES);
- if (settingValue != null) {
- colonSplitter.setString(settingValue);
- while (colonSplitter.hasNext()) {
- String componentName = colonSplitter.next();
+ return false;
+ }
- if (componentName.equalsIgnoreCase(service)) {
- return true;
- }
+ private boolean isCollectionAvailable(ArrayList activeSensorStatuses) {
+ boolean accessibilityEnabled = true;
+ for (String setting : activeSensorStatuses) {
+ if (requiresAccessibility(setting)) {
+ if (accessibilityEnabled) {
+ accessibilityEnabled = isAccessibilityServiceEnabled(this, Applications.class);
}
+ if (!accessibilityEnabled) return false;
}
}
+ return true;
+ }
+ private boolean requiresAccessibility(String setting) {
+ return Aware_Preferences.STATUS_APPLICATIONS.equals(setting)
+ || Aware_Preferences.STATUS_NOTIFICATIONS.equals(setting)
+ || Aware_Preferences.STATUS_CRASHES.equals(setting)
+ || Aware_Preferences.STATUS_SCREENTEXT.equals(setting)
+ || Aware_Preferences.STATUS_KEYBOARD.equals(setting)
+ || Aware_Preferences.STATUS_TOUCH.equals(setting);
+ }
+
+ /**
+ * True if any accessibility-backed sensor is currently enabled in settings, i.e. the
+ * accessibility service is actually needed for data collection right now. Used to avoid
+ * prompting participants in studies that don't rely on accessibility.
+ */
+ private boolean studyNeedsAccessibility() {
+ final String[] accessibilitySensors = {
+ Aware_Preferences.STATUS_APPLICATIONS,
+ Aware_Preferences.STATUS_NOTIFICATIONS,
+ Aware_Preferences.STATUS_CRASHES,
+ Aware_Preferences.STATUS_SCREENTEXT,
+ Aware_Preferences.STATUS_KEYBOARD,
+ Aware_Preferences.STATUS_TOUCH
+ };
+ for (String setting : accessibilitySensors) {
+ if ("true".equalsIgnoreCase(Aware.getSetting(getApplicationContext(), setting))) {
+ return true;
+ }
+ }
return false;
}
+ // Debounces the onResume() sync trigger below: rapid app-switching or configuration changes can
+ // fire onResume() several times in quick succession. Each one spawns its own background thread
+ // in Aware's receiver (syncStudyConfig -> a network fetch + a DB-credential check that can
+ // block), so without this, those can stack up concurrently for no benefit — the config can't
+ // meaningfully have changed again within a few seconds of the last check. static + elapsedRealtime
+ // so it survives this Activity being recreated and isn't affected by wall-clock changes.
+ private static volatile long lastSyncConfigBroadcastAtMs = 0;
+ private static final long SYNC_CONFIG_DEBOUNCE_MS = 10_000;
+
@Override
protected void onResume() {
super.onResume();
+ // Locked studies keep reconciling on app open. Editable studies intentionally retain the
+ // participant's local configuration until they explicitly tap "Check for study updates".
+ if (Aware.isStudy(getApplicationContext()) && isStudySettingsLocked()) {
+ long now = SystemClock.elapsedRealtime();
+ if (now - lastSyncConfigBroadcastAtMs >= SYNC_CONFIG_DEBOUNCE_MS) {
+ lastSyncConfigBroadcastAtMs = now;
+ sendBroadcast(new Intent(Aware.ACTION_AWARE_SYNC_CONFIG));
+ }
+ }
+
+ // Restore an editable-mode update proposal if the Activity was stopped/recreated while the
+ // participant was deciding. Nothing is applied until they explicitly agree.
+ showPendingStudyConfigApprovalIfAny();
+
+ // Catch up on any already-applied study update that was received while this Activity wasn't
+ // alive to receive the live broadcast.
+ showPendingStudyUpdateNoticeIfAny();
+
+ // A password-join study may have had its password rotated; background sync flags it but
+ // cannot prompt, so ask for the new password here while the app is open.
+ showPendingReauthIfAny();
+
permissions_ok = true;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
for (String p : REQUIRED_PERMISSIONS) {
- if (PermissionChecker.checkSelfPermission(this, p) != PermissionChecker.PERMISSION_GRANTED) {
+ if (PermissionChecker.checkSelfPermission(this, p) != PackageManager.PERMISSION_GRANTED) {
permissions_ok = false;
break;
}
@@ -760,7 +2028,7 @@ protected void onResume() {
} else {
- if (prefs.getAll().isEmpty() && Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID).length() == 0) {
+ if (prefs.getAll().isEmpty() && Aware.getDeviceID(getApplicationContext()).length() == 0) {
PreferenceManager.setDefaultValues(getApplicationContext(), "com.aware.phone", Context.MODE_PRIVATE, R.xml.aware_preferences, true);
prefs.edit().commit();
} else {
@@ -769,19 +2037,27 @@ protected void onResume() {
Map defaults = prefs.getAll();
for (Map.Entry entry : defaults.entrySet()) {
+ // Skip webservice_server: see the matching comment in Aware.onStartCommand()'s copy
+ // of this loop — the cached "com.aware.phone" SharedPreferences default is a stale
+ // placeholder URL, and copying it in here whenever the real setting is momentarily
+ // empty is the same landmine as the fallback removed below, just reached via the
+ // defaults cache instead of a literal.
+ if (entry.getKey().equals(Aware_Preferences.WEBSERVICE_SERVER)) continue;
if (Aware.getSetting(getApplicationContext(), entry.getKey(), "com.aware.phone").length() == 0) {
Aware.setSetting(getApplicationContext(), entry.getKey(), entry.getValue(), "com.aware.phone"); //default AWARE settings
}
}
- if (Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID).length() == 0) {
+ if (Aware.getDeviceID(getApplicationContext()).length() == 0) {
UUID uuid = UUID.randomUUID();
Aware.setSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID, uuid.toString(), "com.aware.phone");
}
- if (Aware.getSetting(getApplicationContext(), Aware_Preferences.WEBSERVICE_SERVER).length() == 0) {
- Aware.setSetting(getApplicationContext(), Aware_Preferences.WEBSERVICE_SERVER, "http://api.awareframework.com/index.php");
- }
+ // Deliberately no "if empty, default to the public AWARE demo server" fallback here
+ // anymore — see the matching comment in Aware.onStartCommand(). This ran on every
+ // onResume(), unsynchronized, and permanently overwrote a legitimate join URL with this
+ // placeholder the instant it observed the setting momentarily empty (e.g. mid-reset()),
+ // breaking study lookup with no way to self-correct afterward.
Set keys = optionalSensors.keySet();
for (String optionalSensor : keys) {
@@ -798,10 +2074,12 @@ protected void onResume() {
e.printStackTrace();
}
- //Check if AWARE is active on the accessibility services. Android Wear doesn't support accessibility services (no API yet...)
- if (!Aware.is_watch(this)) {
- Applications.isAccessibilityServiceActive(this);
- }
+ showDataDeliveryStatus();
+
+ // Prompt for the accessibility service and the OS-level Location toggle the joined study
+ // needs. Shown as in-app dialogs (no tray notification) so the request is contextual and
+ // actionable, one at a time and at most once per session — see the helper below.
+ promptForRequiredServices();
//Check if AWARE is allowed to run on Doze
//Aware.isBatteryOptimizationIgnored(this, getPackageName());
@@ -923,10 +2201,10 @@ protected void onStop() {
if (!isFinishing) {
if (isBatteryOptimizationIgnored(this, "com.aware.phone")) {
Log.d("AWARE-Client", "AWARE stopped from background: may be caused by battery optimization");
- Aware.debug(this, "AWARE stopped from background: may be caused by battery optimization");
+ Aware.debug(this, Aware.LogType.LIFECYCLE, "AWARE stopped from background: may be caused by battery optimization");
} else {
Log.d("AWARE-Client", "AWARE stopped from background: may be caused by system settings");
- Aware.debug(this, "AWARE stopped from background: may be caused by system settings");
+ Aware.debug(this, Aware.LogType.LIFECYCLE, "AWARE stopped from background: may be caused by system settings");
}
}
super.onStop();
@@ -941,7 +2219,7 @@ protected void onDestroy() {
// Handle based on whether it's user-initiated or system-initiated closure
if (isFinishing) {
// User initiated closure
- Aware.debug(this, "AWARE interface cleaned from the list of frequently used apps");
+ Aware.debug(this, Aware.LogType.LIFECYCLE, "AWARE interface cleaned from the list of frequently used apps");
}
Log.d("AWARE_Client", "AWARE interface cleaned from the list of frequently used apps");
super.onDestroy();
@@ -951,6 +2229,8 @@ protected void onDestroy() {
unregisterReceiver(packageMonitor);
unregisterReceiver(screenshotServiceStoppedReceiver);
unregisterReceiver(noteStatusReceiver);
+ unregisterReceiver(studyConfigUpdatedReceiver);
+ unregisterReceiver(reauthRequiredReceiver);
}
private void hideUnusedPreferences() {
diff --git a/aware-phone/src/main/java/com/aware/phone/ui/Aware_Join_Study.java b/aware-phone/src/main/java/com/aware/phone/ui/Aware_Join_Study.java
index aa3b5c1d..188aef91 100755
--- a/aware-phone/src/main/java/com/aware/phone/ui/Aware_Join_Study.java
+++ b/aware-phone/src/main/java/com/aware/phone/ui/Aware_Join_Study.java
@@ -23,6 +23,7 @@
import com.aware.Aware_Preferences;
import com.aware.phone.R;
import com.aware.phone.ui.dialogs.JoinStudyDialog;
+import com.aware.phone.ui.prefs.SensorCollection;
import com.aware.phone.utils.AwareUtil;
import com.aware.providers.Aware_Provider;
import com.aware.utils.*;
@@ -54,11 +55,24 @@ public class Aware_Join_Study extends Aware_Activity {
public static final String EXTRA_STUDY_CONFIG = "study_config";
public static final String INPUT_PASSWORD = "input_password";
+ /**
+ * Set when the participant already reviewed the consent on {@link SensorConsentActivity}
+ * before reaching this screen (the pre-join onboarding order). Skips re-launching the consent
+ * screen from Sign up — the grants and declined set are already recorded and applySettings honours
+ * them.
+ */
+ public static final String EXTRA_CONSENT_DONE = "consent_done";
- private static String study_url;
+
+ // Instance-scoped (not static): each launch of this screen must use only the study URL and
+ // password from its own intent. When these were static, a plugin-install relaunch (or a second
+ // study visited in the same process) could surface a previous study's URL/password in the
+ // re-shown join dialog — the "old one, not the current one" the participant sees.
+ private String study_url;
private JSONArray study_configs;
- private static String input_password;
+ private String input_password;
+ private boolean consentDone;
@@ -100,6 +114,7 @@ public void afterTextChanged(Editable s) {
study_url = getIntent().getStringExtra(EXTRA_STUDY_URL);
String studyConfigStr = getIntent().getStringExtra(EXTRA_STUDY_CONFIG);
input_password = getIntent().getStringExtra(INPUT_PASSWORD);
+ consentDone = getIntent().getBooleanExtra(EXTRA_CONSENT_DONE, false);
//If we are getting here from an AWARE study link (deeplink)
String scheme = getIntent().getScheme();
@@ -115,11 +130,19 @@ public void afterTextChanged(Editable s) {
// Fetch study config if not passed in intent
if (studyConfigStr == null) {
- new JoinStudyDialog(Aware_Join_Study.this).validateStudy(study_url); // TODO TEST 1
+ // Re-validate with the password the participant already entered, carried across
+ // config-less re-entries (e.g. the plugin-install relaunch below), not an empty one.
+ // Otherwise a password-protected study reports PASSWORD_REQUIRED and re-opens the join
+ // dialog even though the correct password was already given. Genuine first-time entries
+ // (deep link, QR, settings toggle) carry no password, so this stays empty and the
+ // prompt still appears for them as intended.
+ new JoinStudyDialog(Aware_Join_Study.this)
+ .validateStudy(new JoinStudyDialog.ValidationRequest(study_url, input_password));
} else {
Cursor qry = Aware.getStudy(this, study_url);
if (qry == null || !qry.moveToFirst()) {
- new PopulateStudy().execute(study_url, studyConfigStr);
+ new PopulateStudy().executeOnExecutor(
+ AsyncTask.THREAD_POOL_EXECUTOR, study_url, studyConfigStr);
} else {
initView(qry, studyConfigStr);
}
@@ -164,16 +187,66 @@ public void onClick(View v) {
btnAction.setEnabled(false);
btnAction.setAlpha(0.5f);
+ // getStudy() returns the latest still-enrolled (exit=0) row for this URL — present
+ // both on a fresh join (PopulateStudy just inserted it) and on a re-join (old join
+ // rows still have exit=0). We branch on isStudy(), which checks whether the single
+ // most-recent row (by timestamp) is active:
Cursor study = Aware.getStudy(getApplicationContext(), study_url);
if (study != null && study.moveToFirst()) {
- ContentValues studyData = new ContentValues();
- studyData.put(Aware_Provider.Aware_Studies.STUDY_JOINED, System.currentTimeMillis());
- studyData.put(Aware_Provider.Aware_Studies.STUDY_EXIT, 0);
- getContentResolver().update(Aware_Provider.Aware_Studies.CONTENT_URI, studyData, Aware_Provider.Aware_Studies.STUDY_URL + " LIKE '" + study_url + "'", null);
+ if (Aware.isStudy(getApplicationContext())) {
+ // Fresh join: refresh ONLY this session's enrollment row (scoped by _id).
+ // Never a blanket update — that used to flatten join times and erase prior
+ // quits' exit timestamps across the whole history.
+ long activeId = study.getLong(
+ study.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_ID));
+ ContentValues studyData = new ContentValues();
+ studyData.put(Aware_Provider.Aware_Studies.STUDY_JOINED, System.currentTimeMillis());
+ studyData.put(Aware_Provider.Aware_Studies.STUDY_EXIT, 0);
+ studyData.put(Aware_Provider.Aware_Studies.STUDY_COMPLIANCE, "joined study");
+ getContentResolver().update(Aware_Provider.Aware_Studies.CONTENT_URI, studyData,
+ Aware_Provider.Aware_Studies.STUDY_ID + "=" + activeId, null);
+ } else {
+ // Re-join after a previous exit: the latest row is a past "quit study", so
+ // isStudy() is false. Append a NEW enrollment row (copy of the study
+ // metadata) so it becomes the latest row with exit=0 → isStudy() true again.
+ // Prior join/quit rows are preserved (append-only).
+ ContentValues studyData = new ContentValues();
+ DatabaseUtils.cursorRowToContentValues(study, studyData);
+ studyData.remove(Aware_Provider.Aware_Studies.STUDY_ID);
+ studyData.put(Aware_Provider.Aware_Studies.STUDY_TIMESTAMP, System.currentTimeMillis());
+ studyData.put(Aware_Provider.Aware_Studies.STUDY_JOINED, System.currentTimeMillis());
+ studyData.put(Aware_Provider.Aware_Studies.STUDY_EXIT, 0);
+ studyData.put(Aware_Provider.Aware_Studies.STUDY_COMPLIANCE, "rejoined study");
+ getContentResolver().insert(Aware_Provider.Aware_Studies.CONTENT_URI, studyData);
+ }
}
if (study != null && !study.isClosed()) study.close();
- new JoinStudyAsync().execute();
+ // The consent screen is skipped only when there is nothing to show at all (no
+ // promptable sensors), or when nothing still needs granting AND the participant's
+ // recorded consent covers this exact study and sensor set. Granted OS permissions
+ // alone are not enough: they survive quitting a study, but agreement doesn't —
+ // quitting wipes the record, so a re-join always shows the screen again.
+ boolean nothingToShow = SensorCollection.enabledConsentsForConfig(study_configs).isEmpty();
+ boolean alreadyConsented = !SensorCollection.hasPendingConsents(getApplicationContext(), study_configs)
+ && SensorCollection.hasMatchingConsentRecord(getApplicationContext(), study_url, study_configs);
+ // consentDone: the participant already reviewed the sensor consent before this screen,
+ // so the grants and declined set are recorded — go straight to applying, don't re-ask.
+ if (consentDone || nothingToShow || alreadyConsented) {
+ new JoinStudyAsync().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
+ } else {
+ Intent consent = new Intent(getApplicationContext(), SensorConsentActivity.class);
+ consent.putExtra(EXTRA_STUDY_URL, study_url);
+ try {
+ consent.putExtra(EXTRA_STUDY_CONFIG, study_configs.getJSONObject(0).toString());
+ } catch (JSONException e) {
+ e.printStackTrace();
+ }
+ consent.putExtra(INPUT_PASSWORD, input_password);
+ consent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
+ startActivity(consent);
+ finish();
+ }
}
});
@@ -181,16 +254,15 @@ public void onClick(View v) {
@Override
public void onClick(View view) {
- Cursor dbStudy = Aware.getStudy(getApplicationContext(), study_url);
+ Cursor dbStudy = Aware.getActiveStudy(getApplicationContext());
if (dbStudy != null && dbStudy.moveToFirst()) {
ContentValues complianceEntry = new ContentValues();
complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_TIMESTAMP, System.currentTimeMillis());
- complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_KEY, dbStudy.getInt(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_KEY)));
complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_API, dbStudy.getString(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_API)));
complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_URL, dbStudy.getString(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_URL)));
complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_PI, dbStudy.getString(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_PI)));
- complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_CONFIG, dbStudy.getString(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_CONFIG)));
complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_JOINED, dbStudy.getLong(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_JOINED)));
complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_EXIT, dbStudy.getLong(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_EXIT)));
complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_TITLE, dbStudy.getString(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_TITLE)));
@@ -212,16 +284,16 @@ public void onClick(DialogInterface dialogInterface, int i) {
btnAction.setEnabled(false);
btnAction.setAlpha(1f);
- Cursor dbStudy = Aware.getStudy(getApplicationContext(), Aware.getSetting(getApplicationContext(), Aware_Preferences.WEBSERVICE_SERVER));
+ Cursor dbStudy = Aware.getActiveStudy(getApplicationContext());
+ ContentValues complianceEntry = null;
if (dbStudy != null && dbStudy.moveToFirst()) {
- ContentValues complianceEntry = new ContentValues();
+ complianceEntry = new ContentValues();
complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_TIMESTAMP, System.currentTimeMillis());
- complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_KEY, dbStudy.getInt(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_KEY)));
complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_API, dbStudy.getString(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_API)));
complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_URL, dbStudy.getString(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_URL)));
complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_PI, dbStudy.getString(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_PI)));
- complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_CONFIG, dbStudy.getString(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_CONFIG)));
complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_JOINED, dbStudy.getLong(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_JOINED)));
complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_EXIT, System.currentTimeMillis());
complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_TITLE, dbStudy.getString(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_TITLE)));
@@ -234,22 +306,22 @@ public void onClick(DialogInterface dialogInterface, int i) {
dialogInterface.dismiss();
- new QuitStudyAsync().execute();
+ // Best-effort notify the researcher (if reachable); leaving proceeds regardless.
+ new QuitStudyAsync(complianceEntry).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
})
.setNegativeButton("No", new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialogInterface, int i) {
- Cursor dbStudy = Aware.getStudy(getApplicationContext(), Aware.getSetting(getApplicationContext(), Aware_Preferences.WEBSERVICE_SERVER));
+ Cursor dbStudy = Aware.getActiveStudy(getApplicationContext());
if (dbStudy != null && dbStudy.moveToFirst()) {
ContentValues complianceEntry = new ContentValues();
complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_TIMESTAMP, System.currentTimeMillis());
- complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_KEY, dbStudy.getInt(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_KEY)));
complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_API, dbStudy.getString(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_API)));
complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_URL, dbStudy.getString(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_URL)));
complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_PI, dbStudy.getString(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_PI)));
- complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_CONFIG, dbStudy.getString(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_CONFIG)));
complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_JOINED, dbStudy.getLong(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_JOINED)));
complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_EXIT, dbStudy.getLong(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_EXIT)));
complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_TITLE, dbStudy.getString(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_TITLE)));
@@ -347,11 +419,11 @@ public void onClick(DialogInterface dialog, int which) {
JSONObject studyInfo = result.getJSONObject("study_info");
if (Aware.DEBUG)
- Log.d(Aware.TAG, DatabaseUtils.dumpCursorToString(dbStudy));
+ Log.d(Aware.TAG, LogRedactor.redact(DatabaseUtils.dumpCursorToString(dbStudy)));
if (dbStudy == null || !dbStudy.moveToFirst()) {
ContentValues studyData = new ContentValues();
- studyData.put(Aware_Provider.Aware_Studies.STUDY_DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ studyData.put(Aware_Provider.Aware_Studies.STUDY_DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
studyData.put(Aware_Provider.Aware_Studies.STUDY_TIMESTAMP, System.currentTimeMillis());
studyData.put(Aware_Provider.Aware_Studies.STUDY_API, study_api_key);
studyData.put(Aware_Provider.Aware_Studies.STUDY_URL, study_url);
@@ -364,12 +436,12 @@ public void onClick(DialogInterface dialog, int which) {
getContentResolver().insert(Aware_Provider.Aware_Studies.CONTENT_URI, studyData);
if (Aware.DEBUG) {
- Log.d(Aware.TAG, "New study data: " + studyData.toString());
+ Log.d(Aware.TAG, LogRedactor.redact("New study data: " + studyData.toString()));
}
} else {
//Update the information to the latest
ContentValues studyData = new ContentValues();
- studyData.put(Aware_Provider.Aware_Studies.STUDY_DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
+ studyData.put(Aware_Provider.Aware_Studies.STUDY_DEVICE_ID, Aware.getDeviceID(getApplicationContext()));
studyData.put(Aware_Provider.Aware_Studies.STUDY_TIMESTAMP, System.currentTimeMillis());
studyData.put(Aware_Provider.Aware_Studies.STUDY_JOINED, 0);
studyData.put(Aware_Provider.Aware_Studies.STUDY_EXIT, 0);
@@ -384,7 +456,7 @@ public void onClick(DialogInterface dialog, int which) {
getContentResolver().insert(Aware_Provider.Aware_Studies.CONTENT_URI, studyData);
if (Aware.DEBUG) {
- Log.d(Aware.TAG, "Re-scanned study data: " + studyData.toString());
+ Log.d(Aware.TAG, LogRedactor.redact("Re-scanned study data: " + studyData.toString()));
}
}
@@ -406,6 +478,11 @@ public void onClick(DialogInterface dialog, int which) {
private class QuitStudyAsync extends AsyncTask {
ProgressDialog mQuitting;
+ private final ContentValues exitEntry;
+
+ QuitStudyAsync(ContentValues exitEntry) {
+ this.exitEntry = exitEntry;
+ }
@Override
protected void onPreExecute() {
@@ -431,6 +508,11 @@ public void onDismiss(DialogInterface dialogInterface) {
@Override
protected Void doInBackground(Void... params) {
+ // Best-effort: notify the researcher if the database is reachable, but never block
+ // leaving on it. A participant must always be able to withdraw.
+ if (exitEntry != null) {
+ StudyUtils.uploadStudyExit(getApplicationContext(), exitEntry);
+ }
Aware.reset(getApplicationContext());
return null;
}
@@ -461,17 +543,18 @@ protected void onPreExecute() {
@Override
public void onDismiss(DialogInterface dialogInterface) {
finish();
- //Redirect the user to the main UI
- Intent mainUI = new Intent(getApplicationContext(), Aware_Client.class);
- mainUI.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
- startActivity(mainUI);
+ // Only reached when this study had nothing needing consent, so the main UI is
+ // always the right destination here.
+ Intent next = new Intent(getApplicationContext(), Aware_Client.class);
+ next.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
+ startActivity(next);
}
});
}
@Override
protected Void doInBackground(Void... params) {
- StudyUtils.applySettings(getApplicationContext(), study_url, study_configs, input_password);
+ StudyUtils.applySettings(getApplicationContext(), study_url, study_configs, false, input_password, Collections.emptySet());
return null;
}
@@ -482,19 +565,22 @@ protected void onPostExecute(Void aVoid) {
}
}
- private static PluginCompliance pluginCompliance = new PluginCompliance();
+ private final PluginCompliance pluginCompliance = new PluginCompliance();
@Override
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
//no-op, dummy from Aware_Activity super class interface
}
- public static class PluginCompliance extends BroadcastReceiver {
+ private class PluginCompliance extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
if (intent.getAction().equalsIgnoreCase(Aware.ACTION_AWARE_PLUGIN_INSTALLED)) {
Intent joinStudy = new Intent(context, Aware_Join_Study.class);
joinStudy.putExtra(EXTRA_STUDY_URL, study_url);
+ // Carry the already-entered password so the relaunched screen re-validates with it
+ // instead of re-prompting the participant (see the config-less branch in onCreate).
+ joinStudy.putExtra(INPUT_PASSWORD, input_password);
context.startActivity(joinStudy);
}
}
@@ -554,6 +640,13 @@ public int compare(SensorInfo s1, SensorInfo s2) {
mSensorsAdapter = new SensorsAdapter(active_sensors);
sensorsRecyclerView.setAdapter(mSensorsAdapter);
+ // When the participant arrived via the sensor consent screen, it already reviewed the full
+ // sensor list in a friendly form — so hide this duplicate "Sensors needed" list and keep the
+ // sign-up step focused on entering the study identifier.
+ if (consentDone) {
+ findViewById(R.id.ll_sensors_required).setVisibility(View.GONE);
+ }
+
//Show the plugins' information
active_plugins = new ArrayList<>();
for (int i = 0; i < plugins.length(); i++) {
diff --git a/aware-phone/src/main/java/com/aware/phone/ui/Aware_QRCode.java b/aware-phone/src/main/java/com/aware/phone/ui/Aware_QRCode.java
index 94dd799d..7d0458ac 100755
--- a/aware-phone/src/main/java/com/aware/phone/ui/Aware_QRCode.java
+++ b/aware-phone/src/main/java/com/aware/phone/ui/Aware_QRCode.java
@@ -1,49 +1,27 @@
package com.aware.phone.ui;
-import android.app.Activity;
-import android.app.AlertDialog;
-import android.app.ProgressDialog;
-import android.content.ContentValues;
-import android.content.DialogInterface;
-import android.content.Intent;
import android.content.SharedPreferences;
-import android.content.pm.PackageInfo;
-import android.content.pm.PackageManager;
-import android.database.Cursor;
-import android.database.DatabaseUtils;
-import android.hardware.Camera;
-import android.net.Uri;
-import android.os.AsyncTask;
import android.os.Bundle;
-import android.os.Looper;
import android.util.Log;
import android.view.View;
import android.widget.LinearLayout;
import android.widget.ListView;
-import android.widget.Toast;
import com.aware.Aware;
-import com.aware.Aware_Preferences;
-import com.aware.providers.Aware_Provider;
-import com.aware.utils.Http;
-import com.aware.utils.Https;
-import com.aware.utils.SSLManager;
+import com.aware.phone.ui.dialogs.JoinStudyDialog;
+import com.aware.utils.LogRedactor;
-import org.json.JSONArray;
-import org.json.JSONException;
-import org.json.JSONObject;
-
-import java.io.FileNotFoundException;
-import java.util.Hashtable;
-import java.util.List;
-
-import me.dm7.barcodescanner.core.CameraHandlerThread;
-import me.dm7.barcodescanner.core.CameraUtils;
import me.dm7.barcodescanner.zbar.Result;
import me.dm7.barcodescanner.zbar.ZBarScannerView;
/**
- * Created by denzil on 27/10/15.
+ * Reads a study link from a QR code and hands it to the join flow.
+ *
+ * The scan is one of two ways into the same journey: {@link JoinStudyDialog} fetches
+ * the study configuration from the link, checks it against the dataflow the study
+ * declares, and shows the consent screen. Pasting a link and scanning one therefore
+ * reach a participant through one path, and a study that joins by pasting joins by
+ * scanning too.
*/
public class Aware_QRCode extends Aware_Activity implements ZBarScannerView.ResultHandler {
@@ -94,212 +72,13 @@ protected void onDestroy() {
//Zbar QRCode handler
@Override
public void handleResult(Result result) {
- Log.d(Aware.TAG, "QR Code result: " + result.getContents());
- new StudyData().execute(result.getContents());
- }
-
- /**
- * Fetch study information and ask user to join the study
- */
- private class StudyData extends AsyncTask {
-
- private ProgressDialog loader;
-
- private String study_url = "";
- private String study_api_key = "";
- private String study_id = "";
- private String study_config = "";
-
- @Override
- protected void onPreExecute() {
- super.onPreExecute();
- loader = new ProgressDialog(Aware_QRCode.this);
- loader.setTitle("Loading study");
- loader.setMessage("Please wait...");
- loader.setCancelable(true);
- loader.setIndeterminate(true);
- loader.show();
- }
-
- @Override
- protected JSONObject doInBackground(String... params) {
- study_url = params[0];
-
- if (study_url.length() == 0) {
- Log.e(Aware.TAG, "Aware_QRCode study_url? " + study_url);
- return null;
- }
-
- if (Aware.DEBUG) Log.d(Aware.TAG, "Aware_QRCode study_url: " + study_url);
-
- Uri study_uri = Uri.parse(study_url);
- String protocol = study_uri.getScheme();
- List path_segments = study_uri.getPathSegments();
-
- if (path_segments.size() > 0) {
- study_api_key = path_segments.get(path_segments.size() - 1);
- study_id = path_segments.get(path_segments.size() - 2);
-
- // TODO: Replace GET to webserver a GET to study config URL
- String request;
- if (protocol.equals("https")) {
- //Note: Joining a study always downloads the certificate.
- SSLManager.handleUrl(getApplicationContext(), study_url, true);
-
- while(!SSLManager.hasCertificate(getApplicationContext(), study_uri.getHost())) {
- //wait until we have the certificate downloaded
- }
-
- try {
- request = new Https(SSLManager.getHTTPS(getApplicationContext(), study_url)).dataGET(study_url.substring(0, study_url.indexOf("/index.php")) + "/index.php/webservice/client_get_study_info/" + study_api_key, true);
- } catch (FileNotFoundException e) {
- request = null;
- }
- } else {
- request = new Http().dataGET(study_url.substring(0, study_url.indexOf("/index.php")) + "/index.php/webservice/client_get_study_info/" + study_api_key, true);
- }
-
- if (request != null) {
- try {
- if (request.equals("[]")) {
- return null;
- }
- JSONObject study_data = new JSONObject(request);
-
- //Automatically register this device on the study and create credentials for this device ID!
- Hashtable data = new Hashtable<>();
- data.put(Aware_Preferences.DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
- data.put("platform", "android");
- try {
- PackageInfo package_info = getApplicationContext().getPackageManager().getPackageInfo(getApplicationContext().getPackageName(), 0);
- data.put("package_name", package_info.packageName);
- data.put("package_version_code", String.valueOf(package_info.versionCode));
- data.put("package_version_name", String.valueOf(package_info.versionName));
- } catch (PackageManager.NameNotFoundException e) {
- Log.d(Aware.TAG, "Failed to put package info: " + e);
- e.printStackTrace();
- }
-
- // TODO: Replace POST to webserver with DB insert
- // This is where the study config is obtained
- String answer;
- if (protocol.equals("https")) {
- try {
- answer = new Https(SSLManager.getHTTPS(getApplicationContext(), study_url)).dataPOST(study_url, data, true);
- } catch (FileNotFoundException e) {
- answer = null;
- }
- } else {
- answer = new Http().dataPOST(study_url, data, true);
- }
-
- if (answer != null) {
- try {
- JSONArray configs_study = new JSONArray(answer);
- Log.i(Aware.TAG, "Study config: " + configs_study);
- if (!configs_study.getJSONObject(0).has("message")) {
- study_config = configs_study.toString();
- }
- } catch (JSONException e) {
- e.printStackTrace();
- }
- } else return null;
-
- return study_data;
- } catch (JSONException e) {
- e.printStackTrace();
- }
- }
- } else {
- Toast.makeText(Aware_QRCode.this, "Missing API key or study ID. Scanned: " + study_url, Toast.LENGTH_SHORT).show();
- }
- return null;
- }
-
- @Override
- protected void onPostExecute(JSONObject result) {
- super.onPostExecute(result);
-
- try {
- loader.dismiss();
- } catch (IllegalArgumentException e) {
- //It's ok, we might get here if we couldn't get study info.
- return;
- }
-
- if (result == null) {
- AlertDialog.Builder builder = new AlertDialog.Builder(Aware_QRCode.this);
- builder.setPositiveButton("OK", new DialogInterface.OnClickListener() {
- @Override
- public void onClick(DialogInterface dialog, int which) {
- setResult(Activity.RESULT_CANCELED);
- finish();
- }
- });
- builder.setTitle("Study information");
- builder.setMessage("Unable to retrieve this study information: " + study_url + "\nTry again later.");
- builder.show();
- } else {
-
- try {
- Cursor dbStudy = Aware.getStudy(getApplicationContext(), study_url);
-
- if (Aware.DEBUG)
- Log.d(Aware.TAG, DatabaseUtils.dumpCursorToString(dbStudy));
-
- if (dbStudy == null || !dbStudy.moveToFirst()) {
- ContentValues studyData = new ContentValues();
- studyData.put(Aware_Provider.Aware_Studies.STUDY_DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
- studyData.put(Aware_Provider.Aware_Studies.STUDY_TIMESTAMP, System.currentTimeMillis());
- studyData.put(Aware_Provider.Aware_Studies.STUDY_KEY, study_id);
- studyData.put(Aware_Provider.Aware_Studies.STUDY_API, study_api_key);
- studyData.put(Aware_Provider.Aware_Studies.STUDY_URL, study_url);
- studyData.put(Aware_Provider.Aware_Studies.STUDY_PI, result.getString("researcher_first") + " " + result.getString("researcher_last") + "\nContact: " + result.getString("researcher_contact"));
- studyData.put(Aware_Provider.Aware_Studies.STUDY_CONFIG, study_config);
- studyData.put(Aware_Provider.Aware_Studies.STUDY_TITLE, result.getString("study_name"));
- studyData.put(Aware_Provider.Aware_Studies.STUDY_DESCRIPTION, result.getString("study_description"));
-
- getContentResolver().insert(Aware_Provider.Aware_Studies.CONTENT_URI, studyData);
-
- if (Aware.DEBUG) {
- Log.d(Aware.TAG, "New study data: " + studyData.toString());
- }
- } else {
- //Update the information to the latest
- ContentValues studyData = new ContentValues();
- studyData.put(Aware_Provider.Aware_Studies.STUDY_DEVICE_ID, Aware.getSetting(getApplicationContext(), Aware_Preferences.DEVICE_ID));
- studyData.put(Aware_Provider.Aware_Studies.STUDY_TIMESTAMP, System.currentTimeMillis());
- studyData.put(Aware_Provider.Aware_Studies.STUDY_JOINED, 0);
- studyData.put(Aware_Provider.Aware_Studies.STUDY_EXIT, 0);
- studyData.put(Aware_Provider.Aware_Studies.STUDY_KEY, study_id);
- studyData.put(Aware_Provider.Aware_Studies.STUDY_API, study_api_key);
- studyData.put(Aware_Provider.Aware_Studies.STUDY_URL, study_url);
- studyData.put(Aware_Provider.Aware_Studies.STUDY_PI, result.getString("researcher_first") + " " + result.getString("researcher_last") + "\nContact: " + result.getString("researcher_contact"));
- studyData.put(Aware_Provider.Aware_Studies.STUDY_CONFIG, study_config);
- studyData.put(Aware_Provider.Aware_Studies.STUDY_TITLE, result.getString("study_name"));
- studyData.put(Aware_Provider.Aware_Studies.STUDY_DESCRIPTION, result.getString("study_description"));
-
- getContentResolver().insert(Aware_Provider.Aware_Studies.CONTENT_URI, studyData);
-
- if (Aware.DEBUG) {
- Log.d(Aware.TAG, "Re-scanned study data: " + studyData.toString());
- }
- }
-
- if (dbStudy != null && !dbStudy.isClosed()) dbStudy.close();
-
- //Load join study wizard. We already have the study info on the database.
- Intent studyInfo = new Intent(getApplicationContext(), Aware_Join_Study.class);
- studyInfo.putExtra(Aware_Join_Study.EXTRA_STUDY_URL, study_url);
- studyInfo.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
- startActivity(studyInfo);
-
- finish();
-
- } catch (JSONException e) {
- e.printStackTrace();
- }
- }
- }
+ String studyUrl = result.getContents();
+ Log.d(Aware.TAG, "QR Code result: " + LogRedactor.redact(studyUrl));
+
+ // Validation reports its own outcome to the participant: the consent screen on
+ // success, a toast naming what was wrong otherwise. The preview runs again so a
+ // link that turned out to be the wrong one can be followed by another scan.
+ new JoinStudyDialog(this).validateStudy(studyUrl);
+ mScannerView.resumeCameraPreview(this);
}
}
diff --git a/aware-phone/src/main/java/com/aware/phone/ui/SensorConsentActivity.java b/aware-phone/src/main/java/com/aware/phone/ui/SensorConsentActivity.java
new file mode 100644
index 00000000..ec8094da
--- /dev/null
+++ b/aware-phone/src/main/java/com/aware/phone/ui/SensorConsentActivity.java
@@ -0,0 +1,578 @@
+package com.aware.phone.ui;
+
+import android.app.ProgressDialog;
+import android.content.ContentValues;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Build;
+import android.os.Bundle;
+import android.provider.Settings;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AlertDialog;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.core.app.ActivityCompat;
+
+import com.aware.Aware;
+import com.aware.Aware_Preferences;
+import com.aware.phone.R;
+import com.aware.phone.ui.prefs.SensorCollection;
+import com.aware.phone.ui.prefs.SensorCollection.ConsentItem;
+import com.aware.providers.Aware_Provider;
+import com.aware.utils.StudyUtils;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Pre-join consent screen: lists the sensors this study would enable that need a runtime permission,
+ * one row at a time, each with an Enable button (or "Granted ✓"). Tapping Enable requests just that
+ * sensor's permission(s), so the OS shows them one sensor at a time instead of many services each
+ * firing their own request and stacking.
+ *
+ * Runs before the study's settings are applied: a sensor left ungranted when the participant taps
+ * Continue is never turned on at all, rather than being turned on and then possibly off again.
+ */
+public class SensorConsentActivity extends AppCompatActivity {
+
+ /**
+ * When true, this screen is a mid-study re-consent for sensors the researcher added and that were
+ * held off (declined) until the participant agrees — not a fresh join. The participant is already
+ * enrolled, so there is no enrolment to roll back, and only the held-off sensors are shown.
+ */
+ public static final String EXTRA_UPDATE_MODE = "update_mode";
+
+ /**
+ * When true, this screen runs BEFORE the participant enters their identifier and signs up (the
+ * new onboarding order): it collects the grant-requiring permissions, discloses the
+ * automatically-collected sensors, records the consent, then hands off to
+ * {@link Aware_Join_Study} — it does NOT apply the study or enrol here. When false (legacy /
+ * update flows) the screen applies the study and finishes into the app as before.
+ */
+ public static final String EXTRA_PRE_JOIN = "pre_join";
+
+ private LinearLayout list;
+ private List items;
+ // The full list rendered on screen. A rows (requiresGrant) map back to an entry in {@link #items} for the grant flow. Built once in onCreate.
+ private List displayRows;
+
+ private boolean updateMode;
+ private boolean preJoin;
+ private String studyUrl;
+ private String inputPassword;
+ private JSONArray studyConfigs;
+
+ // Rows the participant has explicitly acted on in this screen. Several rows can share the same
+ // underlying OS gate (ACCESS_COARSE_LOCATION backs Location, Wi-Fi and Bluetooth; the single
+ // Accessibility Service toggle backs Applications usage and Keyboard), so checking the OS state
+ // alone would silently mark every row sharing that gate as consented the moment one of them is
+ // granted. Consent is tracked per row instead, only set once that row's own action completes.
+ private final Set consentedKeys = new HashSet<>();
+
+ // Which accessibility row's Enable button was last tapped, so the onResume recheck (there's no
+ // onRequestPermissionsResult callback for a Settings-app toggle) attributes the enabled service
+ // back to that row specifically, not to every accessibility row at once.
+ private String pendingAccessibilityKey;
+
+ // Android 13+ blocks the Accessibility toggle for sideloaded apps until the participant manually
+ // allows it from the app's info screen. Shown once per screen instance, for any accessibility row.
+ private boolean shownRestrictedSettingsHelp;
+
+ // The "Allow all the time" (background location) nudge is shown at most once per screen instance.
+ private boolean shownAlwaysLocationHelp;
+
+ // Bundle keys used to carry the above state across a process death while the participant is away
+ // in the Settings app, so their prior taps and one-time dialogs aren't forgotten on return.
+ private static final String STATE_CONSENTED_KEYS = "consented_keys";
+ private static final String STATE_PENDING_ACCESSIBILITY_KEY = "pending_accessibility_key";
+ private static final String STATE_SHOWN_RESTRICTED_HELP = "shown_restricted_settings_help";
+ private static final String STATE_SHOWN_ALWAYS_LOCATION_HELP = "shown_always_location_help";
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_sensor_consent);
+
+ updateMode = getIntent().getBooleanExtra(EXTRA_UPDATE_MODE, false);
+ preJoin = getIntent().getBooleanExtra(EXTRA_PRE_JOIN, false);
+ studyConfigs = new JSONArray();
+ if (updateMode) {
+ // Mid-study re-consent: the participant is already enrolled, so read the study they're in
+ // (its live config, URL and DB password) rather than taking them from the launch intent.
+ JSONObject activeConfig = Aware.getActiveStudyConfig(getApplicationContext());
+ if (activeConfig != null) studyConfigs.put(activeConfig);
+ studyUrl = activeStudyUrl();
+ inputPassword = Aware.getSetting(getApplicationContext(), Aware_Preferences.DB_PASSWORD);
+ } else {
+ studyUrl = getIntent().getStringExtra(Aware_Join_Study.EXTRA_STUDY_URL);
+ inputPassword = getIntent().getStringExtra(Aware_Join_Study.INPUT_PASSWORD);
+ try {
+ studyConfigs.put(new JSONObject(getIntent().getStringExtra(Aware_Join_Study.EXTRA_STUDY_CONFIG)));
+ } catch (JSONException e) {
+ e.printStackTrace();
+ }
+ }
+
+ list = findViewById(R.id.consent_list);
+ findViewById(R.id.btn_continue).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ onContinue();
+ }
+ });
+ findViewById(R.id.btn_dont_join).setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ cancelJoin();
+ }
+ });
+
+ if (updateMode) {
+ // Mid-study re-consent: show only the sensors the researcher added that are held off,
+ // and reframe the screen — the participant is already enrolled, so "don't join" becomes
+ // "not now" and declining just leaves those sensors off.
+ ((TextView) findViewById(R.id.consent_title)).setText("Study updated");
+ ((TextView) findViewById(R.id.consent_subtitle)).setText(
+ "The researcher added sensors to this study. Enable the ones you agree to share, or leave them off — you can enable them later from the sensor list.");
+ ((Button) findViewById(R.id.btn_dont_join)).setText("Not now");
+ items = SensorCollection.heldConsentsForConfig(getApplicationContext(), studyConfigs);
+ // Update mode shows only the held (grant-requiring) sensors
+ displayRows = rowsFromItems(items);
+ } else {
+ items = SensorCollection.enabledConsentsForConfig(studyConfigs);
+ // Fresh join: show everything the study collects — grant-requiring and automatic.
+ displayRows = SensorCollection.consentRowsForConfig(studyConfigs);
+ ((TextView) findViewById(R.id.consent_title)).setText("Data this study collects");
+ ((TextView) findViewById(R.id.consent_subtitle)).setText(
+ "Review what this study collects. Sensors marked “Needs permission” ask for your "
+ + "consent when you tap Enable; the rest are collected automatically once you join. "
+ + "Agreeing continues to the last step.");
+ ((Button) findViewById(R.id.btn_continue)).setText("Agree & continue");
+ }
+
+ // Nothing to show: update mode → back to app; fresh join → straight on to the identifier step
+ // (pre-join) or apply directly (legacy post-join). "Nothing" means no rows at all — a study
+ // sensors still shows the screen so they are disclosed.
+ if (displayRows.isEmpty()) {
+ if (updateMode) { goToMainUi(); return; }
+ if (preJoin) { forwardToJoinStudy(); return; }
+ applyAndFinish(Collections.emptySet());
+ return;
+ }
+
+ if (savedInstanceState != null) {
+ // Coming back after a process death: restore the per-row consent exactly as it was.
+ // Re-deriving it from live OS state here would be wrong, because several rows share one
+ // gate (ACCESS_COARSE_LOCATION backs Location, Wi-Fi and Bluetooth; the Accessibility
+ // toggle backs Applications and Keyboard) — a permission granted for one tapped row would
+ // otherwise silently mark every row sharing it as consented.
+ restoreInstanceState(savedInstanceState);
+ } else {
+ // A row already satisfied before this screen ever opened (e.g. granted during an earlier
+ // study join) doesn't need a redundant tap. Snapshotted once, here, rather than re-checked
+ // on every buildRows(): a permission that only becomes satisfied DURING this screen because
+ // another row's tap happens to share it (ACCESS_COARSE_LOCATION backs Location, Wi-Fi and
+ // Bluetooth alike) must still go through that row's own tap, not get a free pass.
+ for (ConsentItem item : items) {
+ if (SensorCollection.isAlreadyGranted(getApplicationContext(), item)) {
+ consentedKeys.add(item.key);
+ }
+ }
+ }
+ buildRows();
+ }
+
+ private void buildRows() {
+ list.removeAllViews();
+ LayoutInflater inflater = LayoutInflater.from(this);
+ SensorCollection.ConsentGroup currentGroup = null;
+ for (SensorCollection.ConsentRow row : displayRows) {
+ if (row.group != currentGroup) {
+ currentGroup = row.group;
+ View section = inflater.inflate(
+ R.layout.item_consent_section, (ViewGroup) list, false);
+ ((TextView) section.findViewById(R.id.consent_section_title)).setText(
+ currentGroup == SensorCollection.ConsentGroup.PLUGIN
+ ? "Plugins" : "Sensors");
+ list.addView(section);
+ }
+ View view = inflater.inflate(R.layout.item_consent_row, (ViewGroup) list, false);
+ ((TextView) view.findViewById(R.id.consent_emoji)).setText(row.emoji);
+ ((TextView) view.findViewById(R.id.consent_label)).setText(row.label);
+ ((TextView) view.findViewById(R.id.consent_description)).setText(row.description);
+ ((TextView) view.findViewById(R.id.consent_badge)).setText(badgeText(row.badge));
+
+ Button enable = view.findViewById(R.id.btn_enable);
+ TextView granted = view.findViewById(R.id.txt_granted);
+
+ if (!row.requiresGrant()) {
+ // collected automatically once the study starts; nothing to grant.
+ enable.setVisibility(View.GONE);
+ granted.setVisibility(View.GONE);
+ } else {
+ final ConsentItem item = row.grantItem;
+ final int requestCode = indexOfItem(item.key);
+ if (consentedKeys.contains(item.key)) {
+ enable.setVisibility(View.GONE);
+ granted.setVisibility(View.VISIBLE);
+ } else {
+ enable.setVisibility(View.VISIBLE);
+ granted.setVisibility(View.GONE);
+ enable.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (item.needsAccessibility) {
+ enableAccessibility(item);
+ } else {
+ ActivityCompat.requestPermissions(SensorConsentActivity.this, item.permissions, requestCode);
+ }
+ }
+ });
+ }
+ }
+ list.addView(view);
+ }
+ }
+
+ /** Index of the consent item with {@code key} in {@link #items} — the request code its grant uses. */
+ private int indexOfItem(String key) {
+ for (int i = 0; i < items.size(); i++) {
+ if (items.get(i).key.equals(key)) return i;
+ }
+ return -1;
+ }
+
+ private String badgeText(SensorCollection.ConsentBadge badge) {
+ switch (badge) {
+ case PERMISSION: return "Needs permission";
+ case ACCESSIBILITY: return "Accessibility";
+ default: return "Automatic";
+ }
+ }
+
+ /** Wrap grant-requiring consent items into display rows (used by the mid-study update list). */
+ private List rowsFromItems(List src) {
+ List