diff --git a/.gitignore b/.gitignore index 4155b5b..b371856 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,11 @@ aware-phone/release/* .DS_Store .DS_Store? local.properties + +# Built release APKs: these are build output, not source, and were being committed on every +# rebuild (~6MB each x 5 ABI variants) — see resources/ below, which had 15 revisions across them +# bloating .git well past what removing the current copies alone would show. +*.apk + *.jks *.keystore diff --git a/CHANGES.txt b/CHANGES.txt index 2cd02b9..ddbb728 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,61 +1,96 @@ Change log for AWARE -4.7.3.beta -- add column for record bluetooth connection status + +4.8.1.beta +- Sensor and plugin consent is now shown before joining a study instead of after, and covers the plugins a study needs as well as its sensors, grouped into labelled sections +- Leaving a study now always completes on the device, even when the research database cannot be reached; telling the researcher is best-effort, and the exit record states when it could not be delivered +- New: when a researcher changes a study's database password, the app tells a rejected password apart from a database that is simply down, asks the participant to re-enter it, and pauses uploads until it is accepted +- Fixed joining a study from a link or QR code, which crashed before any validation had run +- A study that needs a participant-entered password can now be joined from a link or QR code; the app asks for the password instead of failing with nothing the participant can do +- Join failures now say what is actually wrong: an invalid configuration, a missing password, a wrong password, or a database that cannot be reached. An unreachable database is no longer reported as a wrong password +- Study addresses are checked before anything is downloaded, so a missing or malformed address gives a message instead of failing silently +- When several study config checks are triggered at once, the app now runs one check instead of queuing every request +- The app now stops waiting for a study configuration that does not answer in time, and refuses a configuration file larger than 5 MB +- Sensors whose hardware the device does not have are now also ignored when a config update is applied, not only on first sync +- Fixed a background timer that kept a processor core busy for hours for every pending ESM questionnaire +- Fixed a pending ESM questionnaire preventing a study join from ever starting +- The background collection notice is now always visible and silent, says that study data is being collected, and opens the app when tapped; added the notification permission that Android 13 and later require +- Fixed screenshot capture restarting in a loop and creating a new capture session on every background service start +- Fixed MQTT failing to start on an empty or malformed port, keep-alive, or quality-of-service setting +- Fixed a worker thread being leaked on every restart of the background service +- Fixed a scheduled action failing the whole scheduler when it pointed at something no longer installed or carried an unreadable value +- Further Ambient Noise collection stability fixes +- Fixed repeated permission requests looping in the background after a permission was denied +- Security: database passwords are no longer written to the device log, including the database connection log +- Security: password, secret, and token values are masked in logged study configuration and study data +- Security: uploads are always encrypted, and a study that publishes its database's certificate authority in its configuration also gets the server's identity verified, so data reaches that study's server and no other host. A study that publishes no authority still gets an encrypted connection, but the server it connects to is not verified +- Upload progress is tracked in its own table, so each table is uploaded once instead of being re-sent from the beginning whenever the log was cleared +- Device information is now one row per device, rewritten when the device's hardware or Android version actually changes, and the server records a row per change +- Device information drops four fields that carried no information: the serial (which Android stops reporting), the build type, the device label, and the brand (the manufacturer says the same thing) +- Log entries now carry the kind of entry they are, so restarts, schedules, uploads, study changes and sensor diagnostics can be counted separately +- Study rows now record when the configuration was last updated, name the join and re-join events explicitly, and carry the configuration only on the rows where it changed +- Device information is no longer sent to awareframework.com when a device is first recorded +- Fixed the on-device database never upgrading: a schema change was aborted part-way and silently rolled back, so several releases' worth of database changes had never reached a phone that already had AWARE installed. Upgrades now complete, keeping existing data, and a failed upgrade is recorded instead of being discarded +- A study configuration is now applied even while the research database is unreachable. Only the upload waits for the database to come back; the participant is no longer told every minute that their study configuration is broken when nothing is wrong with it +- A wrong database password and an unreachable database now give different messages when the study configuration is checked +- Fixed a crash when the screen was rotated while a study was being joined +- New: the participant is told when the study needs something from them — a study password, or consent for sensors a researcher has newly added — as a notification, so a paused study is noticed without opening the app. Notifications appear only for changes that need a decision: a frequency-only change is applied quietly +- New: the main screen shows how far collected data has reached the research database, and each sensor shows the same alongside when it last recorded +- New: the participant is told when data stops reaching the research database, and told once rather than repeatedly. Data continues to be collected and kept on the device until it can be sent. Researchers see when delivery started failing, why, and when it recovered +- Fixed sensors reporting that they had never collected anything once their data had been uploaded and cleared from the device; the status and the researcher diagnostics now count delivered data as data the sensor collected +- Fixed the magnetometer comparing one axis against the wrong two when deciding whether a reading changed enough to record, which made any magnetometer threshold keep and drop readings unpredictably +- Sensor sensitivity settings (accelerometer, barometer, gravity, gyroscope, light, linear accelerometer, magnetometer, proximity, rotation, temperature) changed from raw numeric entry to descriptive presets stated in each sensor's own unit, with recommended values per sensor. A sensitivity larger than the sensor's readings ever change by is refused, and one already set that way now says the sensor is recording nothing instead of showing the number alone +- Fixed a single sensor's delivery failure being hidden by the other sensors still delivering: whether data is reaching the research database is now tracked per sensor rather than as one flag for the whole upload. The main screen names the sensors that are not delivering, and a sensor that recovers no longer clears a warning that belongs to a different one. Researchers see each affected sensor's failure and its recovery separately +- Fixed location collection stopping permanently the first time a study configuration was applied, and never resuming for as long as the app kept running, while the location sensor still reported itself as active. GPS, network and passive location are now re-registered when the sensor restarts +- Fixed an upload waiting indefinitely when the research database accepts the connection and then stops answering. The wait is now bounded, so the stall is reported to the participant and the researcher instead of passing unnoticed for hours, and one stalled table no longer holds back every other table's upload. Collection is unaffected either way and data is kept on the device until it can be sent + +4.8.0.beta +- Sensor sampling-rate settings (accelerometer, barometer, gravity, gyroscope, light, magnetometer, proximity, rotation, temperature, location, Wi-Fi/Bluetooth scanning, processor, Ambient Noise, OpenWeather) changed from raw numeric entry to descriptive dropdown presets, with more sensible default frequencies per sensor +- New sensor diagnostic logging: when a sensor isn't collecting data, the reason (missing hardware, permission, or accessibility) is now recorded so researchers can see it in the synced data +- Sensors without supported hardware are now excluded from study config sync instead of silently failing to apply +- Fixed study config sync not always saving and applying updated settings correctly +- Added missing consent requests for some sensors; consent checks are now consistent across all sensors +- New per-sensor consent screen shown after joining a study +- Fixed crash loops in the Ambient Noise and OpenWeather plugins +- Wi-Fi scanning now correctly enforces the device's Location services toggle +- Redesigned study info/history view with a shared study card; improved join/quit/rejoin tracking and sync +- Fixed a crash when enabling or updating the accessibility service +- Security: stopped exposing the API key in the UI and source 4.7.2.beta - Fix ESM settings parsing crashes and improve error handling 4.7.1.beta - New feature: Allow participants to add notes and save them to the remote database - -4.6.2.beta - Adjust function to count installation, change the official website url used for receive device info - -4.6.1.beta - New feature to new plugin: Ambient Noise and OpenWeather - Rename "AWARE-Light" to "AWARE" - -Change log for AWARE-Light -4.6.1.beta - New feature: Allow users to take notes if config file container correspond setting. - -4.5.5.beta - Add function to count installation and send info to official website - -4.5.4.beta - Fix logic of reschedule next day random esm trigger, avoid repeat appear of esm notification - -4.5.3.beta - Fix the ESM queue logic to ensure that the correct number of questions are displayed according to the configurator settings. - -4.5.2.beta - Screenshot sensor can capture application name for foreground app. - -4.5.1.beta - New feature: Allow users to filter and save screenshots based on specific package names. Support for inclusive or exclusive. - -4.4.1.beta - New feature to synchronize the screenshot image data to the remote database specified by the aware-light configurator through base64 encoding. - -4.3.1.beta - New feature to capture screenshots at regular intervals and save them to the device's storage. - -4.2.2.beta - Fix display issues: long question titles can be displayed completely. - -4.2.1.beta - New feature to export the aware-light database files to the device's storage. -4.1.6.beta -- Fixed a crash that occurred when enabling accessibility before starting a study. +Change log for AWARE-Light +4.1.6.light.bundle +- A crash that occurred when enabling accessibility before starting a study has been fixed. - The screen text sensor now correctly collects duplicate text when the foreground app changes, ensuring that screen text is captured during app switches, home screen access, and notification checks. +- Fix display issues: long question titles can be displayed completely. +- Fix the ESM queue logic to ensure that the correct number of questions are displayed according to the configurator settings. +- Fix logic of reschedule next day random esm trigger, avoid repeat appear of esm notification. +- A stronger password detection added in ScreenText Sensor -4.1.5.beta +4.1.5.light.bundle - Logging if the application is terminated by user actions or system actions to aware_log table - -4.1.4.beta - Screen reader function improved performance +4.1.4.light.bundle +- same as 4.1.3.light.bundle + 4.1.3.light.bundle - Fixed bug for random EMA triggered condition - Screen reader function improved performance diff --git a/README.md b/README.md index 4668f55..160c4f5 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ in naturalistic and laboratory settings. The platform can be integrated with MyS You can get the source code of all the components that make the AWARE client from GitHub. ```bash -$ git clone https://github.com/awareframework/aware-light-client.git -$ cd aware-light-client +$ git clone https://github.com/awareframework/aware-client.git +$ cd aware-client $ touch .gitmodules $ git submodule update --init ``` diff --git a/aware-core/aware.gradle b/aware-core/aware.gradle index f7d6f26..6701868 100644 --- a/aware-core/aware.gradle +++ b/aware-core/aware.gradle @@ -1,19 +1,18 @@ //shared with all modules buildscript { ext { + // ion_libs, google_libs and aware_libs removed: unused (see build.gradle at repo root for + // the same cleanup). Note this file is currently not reached by any included module in + // settings.gradle — see the plugin build.gradle fix note below. mqtt_libs = '1.2.1' - ion_libs = "2.+" - google_libs = "17.0.0" - version_code = (System.getenv("VERSION_CODE") ?: "3").toInteger() - version_readable = System.getenv("VERSION_NAME") ?: ("4.7." + version_code + ".beta") + version_code = (System.getenv("VERSION_CODE") ?: "1").toInteger() + version_readable = System.getenv("VERSION_NAME") ?: ("4.8." + version_code + ".beta") compile_sdk = 28 target_sdk = 28 minimum_sdk = 24 build_tools = "28.0.3" - aware_libs = "master-SNAPSHOT" kotlin_version = "1.3.41" build_gradle = "3.4.2" -// anko_version = "0.10.8" } repositories { diff --git a/aware-core/build.gradle b/aware-core/build.gradle index e1a6863..e8a488f 100644 --- a/aware-core/build.gradle +++ b/aware-core/build.gradle @@ -81,7 +81,17 @@ dependencies { implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0-alpha' implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "com.squareup.okhttp3:okhttp:4.2.2" - implementation files('libs/mysql-connector-java-5.1.48.jar') - implementation files('libs/jsonassert-1.5.0.jar') + // Was vendored as libs/mysql-connector-java-5.1.48.jar (a binary committed to git with no + // update path). Same version, sourced from Maven Central instead so it's a normal tracked + // dependency. The 5.1.x line itself is EOL — moving to the current com.mysql:mysql-connector-j + // 8.x is a separate, larger change (driver class name and connection URL params changed + // between the lines) and needs a real device/DB test pass before swapping, tracked as its own + // follow-up rather than bundled into this dependency-hygiene fix. + implementation "mysql:mysql-connector-java:5.1.49" + // Was vendored as libs/jsonassert-1.5.0.jar — identical version, now from Maven Central. + // Group is org.skyscreamer there, not com.github.skyscreamer (that's a JitPack-style coordinate). + implementation "org.skyscreamer:jsonassert:1.5.0" + + testImplementation 'junit:junit:4.13.2' } diff --git a/aware-core/src/main/AndroidManifest.xml b/aware-core/src/main/AndroidManifest.xml index 16f5d82..95064e8 100644 --- a/aware-core/src/main/AndroidManifest.xml +++ b/aware-core/src/main/AndroidManifest.xml @@ -13,6 +13,7 @@ + @@ -788,4 +789,4 @@ - \ No newline at end of file + diff --git a/aware-core/src/main/java/com/aware/Accelerometer.java b/aware-core/src/main/java/com/aware/Accelerometer.java index 27f4527..3c79ad3 100644 --- a/aware-core/src/main/java/com/aware/Accelerometer.java +++ b/aware-core/src/main/java/com/aware/Accelerometer.java @@ -26,6 +26,7 @@ import com.aware.providers.Accelerometer_Provider.Accelerometer_Data; import com.aware.providers.Accelerometer_Provider.Accelerometer_Sensor; import com.aware.utils.Aware_Sensor; +import com.aware.utils.SensorTimeUnits; import java.util.ArrayList; import java.util.List; @@ -273,15 +274,15 @@ public int onStartCommand(Intent intent, int flags, int startId) { saveAccelerometerDevice(mAccelerometer); if (Aware.getSetting(this, Aware_Preferences.FREQUENCY_ACCELEROMETER).length() == 0) { - Aware.setSetting(this, Aware_Preferences.FREQUENCY_ACCELEROMETER, 200000); + Aware.setSetting(this, Aware_Preferences.FREQUENCY_ACCELEROMETER, 20000); } if (Aware.getSetting(this, Aware_Preferences.THRESHOLD_ACCELEROMETER).length() == 0) { Aware.setSetting(this, Aware_Preferences.THRESHOLD_ACCELEROMETER, 0.0); } - int new_frequency = Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_ACCELEROMETER)); - double new_threshold = Double.parseDouble(Aware.getSetting(getApplicationContext(), Aware_Preferences.THRESHOLD_ACCELEROMETER)); + int new_frequency = Aware.getSettingAsInt(getApplicationContext(), Aware_Preferences.FREQUENCY_ACCELEROMETER, 20000); + double new_threshold = Aware.getSettingAsDouble(getApplicationContext(), Aware_Preferences.THRESHOLD_ACCELEROMETER, 0.0); boolean new_enforce_frequency = (Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_ACCELEROMETER_ENFORCE).equals("true") || Aware.getSetting(getApplicationContext(), Aware_Preferences.ENFORCE_FREQUENCY_ALL).equals("true")); @@ -297,7 +298,7 @@ public int onStartCommand(Intent intent, int flags, int startId) { ENFORCE_FREQUENCY = new_enforce_frequency; } - mSensorManager.registerListener(this, mAccelerometer, Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_ACCELEROMETER)), sensorHandler); + mSensorManager.registerListener(this, mAccelerometer, SensorTimeUnits.samplingPeriodUs(new_frequency), sensorHandler); LAST_SAVE = System.currentTimeMillis(); if (Aware.DEBUG) Log.d(TAG, "Accelerometer service active: " + FREQUENCY + " ms"); @@ -305,7 +306,7 @@ public int onStartCommand(Intent intent, int flags, int startId) { if (Aware.isStudy(this)) { ContentResolver.setIsSyncable(Aware.getAWAREAccount(this), Accelerometer_Provider.getAuthority(this), 1); ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), 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), Accelerometer_Provider.getAuthority(this)) diff --git a/aware-core/src/main/java/com/aware/Applications.java b/aware-core/src/main/java/com/aware/Applications.java index 8c537b9..a86cc58 100644 --- a/aware-core/src/main/java/com/aware/Applications.java +++ b/aware-core/src/main/java/com/aware/Applications.java @@ -38,6 +38,7 @@ import com.aware.utils.Converters; import com.aware.utils.Encrypter; import com.aware.utils.Scheduler; +import com.aware.utils.SensorTimeUnits; import org.json.JSONException; import java.util.ArrayList; @@ -620,20 +621,21 @@ protected void onServiceConnected() { Aware.setSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_APPLICATIONS, 0); } - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP && Aware.getSetting(getApplicationContext(), Aware_Preferences.STATUS_APPLICATIONS).equals("true") && Integer.parseInt(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_APPLICATIONS)) > 0) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP && Aware.getSetting(getApplicationContext(), Aware_Preferences.STATUS_APPLICATIONS).equals("true") && Aware.getSettingAsInt(getApplicationContext(), Aware_Preferences.FREQUENCY_APPLICATIONS, 0) > 0) { try { Scheduler.Schedule backgroundApps = Scheduler.getSchedule(getApplicationContext(), SCHEDULER_APPLICATIONS_BACKGROUND); if (backgroundApps == null) { backgroundApps = new Scheduler.Schedule(SCHEDULER_APPLICATIONS_BACKGROUND) - .setInterval(Long.parseLong(Aware.getSetting(getApplicationContext(), Aware_Preferences.FREQUENCY_APPLICATIONS))) + .setInterval(SensorTimeUnits.minutesAsIs(Aware.getSettingAsLong(getApplicationContext(), Aware_Preferences.FREQUENCY_APPLICATIONS, 0))) .setActionIntentAction(ACTION_AWARE_APPLICATIONS_HISTORY) .setActionType(Scheduler.ACTION_TYPE_SERVICE) .setActionClass(getPackageName() + "/" + BackgroundService.class.getName()); Scheduler.saveSchedule(this, backgroundApps); } else { - if (backgroundApps.getInterval() != Long.parseLong(Aware.getSetting(this, Aware_Preferences.FREQUENCY_APPLICATIONS))) { - backgroundApps.setInterval(Long.parseLong(Aware.getSetting(this, Aware_Preferences.FREQUENCY_APPLICATIONS))); + long frequencyApplications = SensorTimeUnits.minutesAsIs(Aware.getSettingAsLong(this, Aware_Preferences.FREQUENCY_APPLICATIONS, 0)); + if (backgroundApps.getInterval() != frequencyApplications) { + backgroundApps.setInterval(frequencyApplications); Scheduler.saveSchedule(this, backgroundApps); } } @@ -657,7 +659,7 @@ protected void onServiceConnected() { ContentResolver.setIsSyncable(Aware.getAWAREAccount(this), Applications_Provider.getAuthority(this), 1); ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Applications_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), Applications_Provider.getAuthority(this)) diff --git a/aware-core/src/main/java/com/aware/Aware.java b/aware-core/src/main/java/com/aware/Aware.java index 953ecc5..19f6255 100644 --- a/aware-core/src/main/java/com/aware/Aware.java +++ b/aware-core/src/main/java/com/aware/Aware.java @@ -36,6 +36,7 @@ import android.widget.TextView; import android.widget.Toast; import androidx.core.app.NotificationCompat; +import androidx.core.content.ContextCompat; import androidx.core.content.PermissionChecker; import com.aware.providers.Aware_Provider; import com.aware.providers.Aware_Provider.Aware_Device; @@ -154,6 +155,36 @@ public class Aware extends Service { */ public static final String ACTION_AWARE_SYNC_CONFIG = "ACTION_AWARE_SYNC_CONFIG"; public static final String SYNC_CONFIG_EXTRA_TOAST = "SYNC_CONFIG_EXTRA_TOAST"; + /** True only for an explicit participant "Check for study updates" action. */ + public static final String SYNC_CONFIG_EXTRA_MANUAL = "SYNC_CONFIG_EXTRA_MANUAL"; + /** True after the participant approved the exact pending server configuration. */ + public static final String SYNC_CONFIG_EXTRA_APPROVED = "SYNC_CONFIG_EXTRA_APPROVED"; + /** + * Broadcast after a study config update has been applied, so an open UI can refresh + * (e.g. rebuild the sensor list to reflect newly enabled/disabled sensors). + */ + public static final String ACTION_AWARE_STUDY_CONFIG_UPDATED = "ACTION_AWARE_STUDY_CONFIG_UPDATED"; + /** + * Broadcast when a study needs the participant to re-enter its database password (the researcher + * rotated it, so the stored password is now rejected). An open UI shows the re-auth prompt live + * instead of only on the next app open. + */ + public static final String ACTION_AWARE_STUDY_REAUTH_REQUIRED = "ACTION_AWARE_STUDY_REAUTH_REQUIRED"; + /** Broadcast when an editable-mode server configuration is awaiting participant approval. */ + public static final String ACTION_AWARE_STUDY_CONFIG_UPDATE_AVAILABLE = + "ACTION_AWARE_STUDY_CONFIG_UPDATE_AVAILABLE"; + /** String ArrayList extras on {@link #ACTION_AWARE_STUDY_CONFIG_UPDATED}: sensors newly enabled / disabled. */ + public static final String EXTRA_SENSORS_ADDED = "sensors_added"; + public static final String EXTRA_SENSORS_REMOVED = "sensors_removed"; + /** + * Boolean extras on {@link #ACTION_AWARE_STUDY_CONFIG_UPDATED}: whether enable_config_update + * changed, and if so, its new value. EXTRA_CONFIG_UPDATE_ALLOWED_NEW_VALUE is only meaningful + * when EXTRA_CONFIG_UPDATE_ALLOWED_CHANGED is true. + */ + public static final String EXTRA_CONFIG_UPDATE_ALLOWED_CHANGED = "config_update_allowed_changed"; + public static final String EXTRA_CONFIG_UPDATE_ALLOWED_NEW_VALUE = "config_update_allowed_new_value"; + /** Whether a study update was applied by the participant's explicit manual check. */ + public static final String EXTRA_CONFIG_UPDATE_MANUAL = "config_update_manual"; /** * Notification ID for AWARE service as foreground (to handle Doze, Android O battery optimizations) @@ -174,6 +205,7 @@ public class Aware extends Service { public static final String AWARE_NOTIFICATION_CHANNEL_GENERAL = "AWARE_NOTIFICATION_CHANNEL_GENERAL"; public static final String AWARE_NOTIFICATION_CHANNEL_SILENT = "AWARE_NOTIFICATION_CHANNEL_SILENT"; public static final String AWARE_NOTIFICATION_CHANNEL_DATASYNC = "AWARE_NOTIFICATION_CHANNEL_DATASYNC"; + public static final String AWARE_NOTIFICATION_CHANNEL_FOREGROUND = "AWARE_FOREGROUND_SERVICE"; public static final String ACTION_AWARE_START_PLUGIN = "ACTION_AWARE_START_PLUGIN"; public static final String ACTION_AWARE_STOP_PLUGIN = "ACTION_AWARE_STOP_PLUGIN"; @@ -219,6 +251,23 @@ public class Aware extends Service { */ public static final int AWARE_BATTERY_OPTIMIZATION_ID = 567567; + /** + * "The study needs your password" notification. Its own id, so re-authenticating cancels it + * without disturbing any other AWARE notification. + */ + public static final int AWARE_STUDY_REAUTH_NOTIFICATION_ID = 567568; + + /** + * "The study changed" notification. Its own id: notifications sharing one id replace each other. + */ + public static final int AWARE_STUDY_UPDATE_NOTIFICATION_ID = 567569; + + /** + * "Data has not been delivered for a long time" notification. Posted once per outage and + * cancelled when delivery recovers, so it needs an id of its own. + */ + public static final int AWARE_UPLOAD_HEALTH_NOTIFICATION_ID = 567570; + /** * Holds a reference to the AWARE account, automatically restore in each plugin. */ @@ -298,6 +347,21 @@ public void onCreate() { aware_channel_silent.enableVibration(false); aware_channel_silent.setSound(null,null); not_manager.createNotificationChannel(aware_channel_silent); + + // Foreground-service notifications must remain visible. This separate channel uses LOW + // importance without sound or vibration; channel importance cannot be raised after a + // channel has already been created on the device. + NotificationChannel aware_channel_foreground = new NotificationChannel( + AWARE_NOTIFICATION_CHANNEL_FOREGROUND, + getResources().getString(R.string.channel_foreground_name), + NotificationManager.IMPORTANCE_LOW); + aware_channel_foreground.setDescription( + getResources().getString(R.string.channel_foreground_description)); + aware_channel_foreground.enableLights(false); + aware_channel_foreground.enableVibration(false); + aware_channel_foreground.setSound(null, null); + aware_channel_foreground.setShowBadge(false); + not_manager.createNotificationChannel(aware_channel_foreground); } // Start the foreground service only if it's the client or a standalone application @@ -355,20 +419,32 @@ public void onReceive(Context context, Intent intent) { public void foreground(boolean enable) { if (enable) { - Intent aware = new Intent(this, Aware.class); - PendingIntent onTap = PendingIntent.getService(this, 0, aware, 0); + Intent launchIntent = getPackageManager().getLaunchIntentForPackage(getPackageName()); + PendingIntent onTap = null; + if (launchIntent != null) { + launchIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); + onTap = PendingIntent.getActivity( + this, + 0, + launchIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + } - NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(this, Aware.AWARE_NOTIFICATION_CHANNEL_SILENT); + NotificationCompat.Builder mBuilder = + new NotificationCompat.Builder(this, Aware.AWARE_NOTIFICATION_CHANNEL_FOREGROUND); mBuilder.setSmallIcon(R.drawable.ic_action_aware_studies); mBuilder.setContentTitle(getApplicationContext().getResources().getString(R.string.foreground_notification_title)); mBuilder.setContentText(getApplicationContext().getResources().getString(R.string.foreground_notification_text)); mBuilder.setOngoing(true); mBuilder.setOnlyAlertOnce(true); - mBuilder.setContentIntent(onTap); - mBuilder = Aware.setNotificationProperties(mBuilder, AWARE_NOTIFICATION_IMPORTANCE_SILENT); + mBuilder.setCategory(NotificationCompat.CATEGORY_SERVICE); + mBuilder.setPriority(NotificationCompat.PRIORITY_LOW); + mBuilder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC); + mBuilder.setShowWhen(false); + if (onTap != null) mBuilder.setContentIntent(onTap); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - mBuilder.setChannelId(Aware.AWARE_NOTIFICATION_CHANNEL_SILENT); + mBuilder.setChannelId(Aware.AWARE_NOTIFICATION_CHANNEL_FOREGROUND); startForeground(Aware.AWARE_FOREGROUND_SERVICE, mBuilder.build()); } else { @@ -402,6 +478,10 @@ public static NotificationCompat.Builder setNotificationProperties(NotificationC } private final SchedulerTicker schedulerTicker = new SchedulerTicker(); + private static final long DONATION_PING_INTERVAL_MS = 24L * 60L * 60L * 1000L; + private static final java.util.concurrent.atomic.AtomicBoolean donationPingInFlight = + new java.util.concurrent.atomic.AtomicBoolean(false); + private static volatile long lastDonationPingAt; public class SchedulerTicker extends BroadcastReceiver { long last_time = 0; long interval_ms = 60000; // Set in Aware class where we have context @@ -451,6 +531,17 @@ protected Boolean doInBackground(Void... params) { } return true; } + + @Override + protected void onPostExecute(Boolean result) { + lastDonationPingAt = System.currentTimeMillis(); + donationPingInFlight.set(false); + } + + @Override + protected void onCancelled() { + donationPingInFlight.set(false); + } } /** @@ -571,42 +662,76 @@ protected void onPostExecute(Boolean studyStatus) { } } + /** This device's facts as the platform reports them, keyed by aware_device column name. */ + private static Map deviceSnapshot() { + Map snapshot = new HashMap<>(); + snapshot.put(Aware_Device.BOARD, Build.BOARD); + snapshot.put(Aware_Device.DEVICE, Build.DEVICE); + snapshot.put(Aware_Device.BUILD_ID, Build.DISPLAY); + snapshot.put(Aware_Device.HARDWARE, Build.HARDWARE); + snapshot.put(Aware_Device.MANUFACTURER, Build.MANUFACTURER); + snapshot.put(Aware_Device.MODEL, Build.MODEL); + snapshot.put(Aware_Device.PRODUCT, Build.PRODUCT); + snapshot.put(Aware_Device.RELEASE, Build.VERSION.RELEASE); + snapshot.put(Aware_Device.SDK, String.valueOf(Build.VERSION.SDK_INT)); + return snapshot; + } + + /** + * Keeps this device's hardware and OS profile current in aware_device. + * + * The table holds one row per device — {@code UNIQUE(device_id)} permits exactly that — so the + * row is rewritten in place when the device's facts change, carrying a fresh timestamp. The sync + * selects rows newer than its marker, so an unchanged row is never re-sent and a rewritten one + * travels once: the server accumulates a row per real change, which is where the history of a + * mid-study Android upgrade lives. + * + * Runs on every intent delivered to this service, so it stays cheap: one single-row read of a + * table holding one row per device_id this phone has used. + */ private void get_device_info() { - Cursor awareContextDevice = getContentResolver().query(Aware_Device.CONTENT_URI, null, null, null, null); - if (awareContextDevice == null || !awareContextDevice.moveToFirst()) { - ContentValues rowData = new ContentValues(); - rowData.put(Aware_Device.TIMESTAMP, System.currentTimeMillis()); - rowData.put(Aware_Device.DEVICE_ID, Aware.getSetting(this, Aware_Preferences.DEVICE_ID)); - rowData.put(Aware_Device.BOARD, Build.BOARD); - rowData.put(Aware_Device.BRAND, Build.BRAND); - rowData.put(Aware_Device.DEVICE, Build.DEVICE); - rowData.put(Aware_Device.BUILD_ID, Build.DISPLAY); - rowData.put(Aware_Device.HARDWARE, Build.HARDWARE); - rowData.put(Aware_Device.MANUFACTURER, Build.MANUFACTURER); - rowData.put(Aware_Device.MODEL, Build.MODEL); - rowData.put(Aware_Device.PRODUCT, Build.PRODUCT); - rowData.put(Aware_Device.SERIAL, Build.SERIAL); - rowData.put(Aware_Device.RELEASE, Build.VERSION.RELEASE); - rowData.put(Aware_Device.RELEASE_TYPE, Build.TYPE); - rowData.put(Aware_Device.SDK, String.valueOf(Build.VERSION.SDK_INT)); - rowData.put(Aware_Device.LABEL, Aware.getSetting(this, Aware_Preferences.DEVICE_LABEL)); + String device_id = Aware.getSetting(this, Aware_Preferences.DEVICE_ID); + Map current = deviceSnapshot(); + Map stored = null; + + Cursor storedRow = getContentResolver().query(Aware_Device.CONTENT_URI, null, + Aware_Device.DEVICE_ID + "=?", new String[]{device_id}, null); + if (storedRow != null && storedRow.moveToFirst()) { + stored = new HashMap<>(); + for (String column : DeviceFacts.COMPARED_COLUMNS) { + int index = storedRow.getColumnIndex(column); + stored.put(column, index < 0 ? null : storedRow.getString(index)); + } + } + if (storedRow != null && !storedRow.isClosed()) storedRow.close(); - try { + if (DeviceFacts.unchanged(stored, current)) return; + + ContentValues rowData = new ContentValues(); + rowData.put(Aware_Device.TIMESTAMP, System.currentTimeMillis()); + rowData.put(Aware_Device.DEVICE_ID, device_id); + for (Map.Entry fact : current.entrySet()) { + rowData.put(fact.getKey(), fact.getValue()); + } + + try { + if (stored == null) { getContentResolver().insert(Aware_Device.CONTENT_URI, rowData); + } else { + getContentResolver().update(Aware_Device.CONTENT_URI, rowData, + Aware_Device.DEVICE_ID + "=?", new String[]{device_id}); + } - Intent deviceData = new Intent(ACTION_AWARE_DEVICE_INFORMATION); - sendBroadcast(deviceData); + Intent deviceData = new Intent(ACTION_AWARE_DEVICE_INFORMATION); + sendBroadcast(deviceData); - if (Aware.DEBUG) Log.d(TAG, "Device information:" + rowData.toString()); + if (Aware.DEBUG) Log.d(TAG, "Device information:" + rowData.toString()); - } catch (SQLiteException e) { - if (Aware.DEBUG) Log.d(TAG, e.getMessage()); - } catch (SQLException e) { - if (Aware.DEBUG) Log.d(TAG, e.getMessage()); - } + } catch (SQLiteException e) { + if (Aware.DEBUG) Log.d(TAG, e.getMessage()); + } catch (SQLException e) { + if (Aware.DEBUG) Log.d(TAG, e.getMessage()); } - if (awareContextDevice != null && !awareContextDevice.isClosed()) - awareContextDevice.close(); } /** @@ -639,13 +764,59 @@ public static boolean isStudy(Context c) { return participant; } + /** + * The kinds of record aware_log holds, stored in {@code log_type}. + * + * A log entry's kind is what analysis groups and filters by, so it travels as its own column + * rather than as a prefix inside the message. Keep these values stable: the research database + * accumulates them, and a renamed value reads as a new kind for every row written after the + * rename. + */ + public static final class LogType { + private LogType() { + } + + /** The framework or the device starting, stopping, rebooting, or being cleared away. */ + public static final String LIFECYCLE = "lifecycle"; + /** A scheduled action firing. */ + public static final String SCHEDULER = "scheduler"; + /** Progress of a table's upload. */ + public static final String SYNC = "sync"; + /** Study configuration being checked, applied, or reconciled against live settings. */ + public static final String STUDY = "study"; + /** Why a sensor is or is not producing data. */ + public static final String DIAGNOSTICS = "diagnostics"; + /** A snapshot of the device's connectivity and hardware state. */ + public static final String CONTEXT = "context"; + /** The kind a caller that classifies nothing gets, including plugins. */ + public static final String EVENT = "event"; + } + + /** + * Records a log entry of an unclassified kind. Retained for callers outside this package — + * plugins reach aware_log through here. + */ public static void debug(Context c, String message) { + debug(c, LogType.EVENT, message); + } + + /** + * Records a log entry under one of the {@link LogType} kinds. + * + * Entries are kept only while the device is in a study, since their purpose is the researcher's + * compliance record. + * + * @param type one of the {@link LogType} values + * @param message the entry itself + */ + public static void debug(Context c, String type, String message) { //NOTE: only collect this aware_log if in a study for compliance checks if (!Aware.isStudy(c)) return; ContentValues log = new ContentValues(); log.put(Aware_Provider.Aware_Log.LOG_TIMESTAMP, System.currentTimeMillis()); log.put(Aware_Provider.Aware_Log.LOG_DEVICE_ID, Aware.getSetting(c, Aware_Preferences.DEVICE_ID)); + log.put(Aware_Provider.Aware_Log.LOG_TYPE, type); log.put(Aware_Provider.Aware_Log.LOG_MESSAGE, message); if (Aware.DEBUG) Log.d(TAG, "Aware_Log: \n" + log.toString()); @@ -664,6 +835,91 @@ public static Cursor getStudy(Context c, String study_url) { return c.getContentResolver().query(Aware_Provider.Aware_Studies.CONTENT_URI, null, Aware_Provider.Aware_Studies.STUDY_URL + " LIKE '" + study_url + "%' AND " + Aware_Provider.Aware_Studies.STUDY_EXIT + "=0", null, Aware_Provider.Aware_Studies.STUDY_TIMESTAMP + " DESC LIMIT 1"); } + /** + * Fetch the cursor for the study the device is currently enrolled in, independent of + * any URL. Mirrors {@link #isStudy(Context)}: the most recent study row that has been + * joined and not yet exited. Use this (instead of {@link #getStudy(Context, String)}) + * for quitting a study, so it still works when the WEBSERVICE_SERVER setting has drifted + * from the study URL (e.g. after a partial reset). Caller must close the returned Cursor. + * + * @param c + * @return cursor positioned at the active study row, or null/empty if not enrolled + */ + public static Cursor getActiveStudy(Context c) { + // Restricted to rows carrying a configuration, which are the rows that define an enrolment. + // aware_studies also holds compliance events — a consent decision, a plugin install, a + // cancelled quit — and those copy double_join from the enrolment they describe, so they match + // "joined and not exited" too and, being newer, would win the sort. Callers here want the + // enrolment and its config, so the newest configuration-bearing row is what answers them. + return c.getContentResolver().query( + Aware_Provider.Aware_Studies.CONTENT_URI, + null, + Aware_Provider.Aware_Studies.STUDY_EXIT + "=0 AND " + + Aware_Provider.Aware_Studies.STUDY_JOINED + ">0 AND " + + Aware_Provider.Aware_Studies.STUDY_CONFIG + "!=''", + null, + Aware_Provider.Aware_Studies.STUDY_TIMESTAMP + " DESC LIMIT 1" + ); + } + + /** + * Append a compliance row to aware_studies for the currently active study, carrying a free-text + * {@code message}. This is the server-visible audit trail the researcher reads (same mechanism as + * "quit study"/"updated study" events) — used here to record consent decisions (given, declined, + * per-sensor enables). No-op if not currently enrolled. Caller need not hold a study cursor. + */ + public static void logStudyCompliance(Context c, String message) { + Cursor study = getActiveStudy(c); + if (study != null && study.moveToFirst()) { + ContentValues entry = new ContentValues(); + entry.put(Aware_Provider.Aware_Studies.STUDY_TIMESTAMP, System.currentTimeMillis()); + entry.put(Aware_Provider.Aware_Studies.STUDY_DEVICE_ID, getSetting(c, Aware_Preferences.DEVICE_ID)); + entry.put(Aware_Provider.Aware_Studies.STUDY_KEY, study.getInt(study.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_KEY))); + entry.put(Aware_Provider.Aware_Studies.STUDY_API, study.getString(study.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_API))); + entry.put(Aware_Provider.Aware_Studies.STUDY_URL, study.getString(study.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_URL))); + entry.put(Aware_Provider.Aware_Studies.STUDY_PI, study.getString(study.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_PI))); + entry.put(Aware_Provider.Aware_Studies.STUDY_JOINED, study.getLong(study.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_JOINED))); + entry.put(Aware_Provider.Aware_Studies.STUDY_EXIT, study.getLong(study.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_EXIT))); + entry.put(Aware_Provider.Aware_Studies.STUDY_TITLE, study.getString(study.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_TITLE))); + entry.put(Aware_Provider.Aware_Studies.STUDY_DESCRIPTION, study.getString(study.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_DESCRIPTION))); + entry.put(Aware_Provider.Aware_Studies.STUDY_COMPLIANCE, message); + c.getContentResolver().insert(Aware_Provider.Aware_Studies.CONTENT_URI, entry); + } + if (study != null && !study.isClosed()) study.close(); + } + + /** + * Returns the list of studies the device has successfully joined at any point, most recent + * first, collapsed to one entry per distinct study. The aware_studies table keeps a row for + * every join and every compliance event, so this de-duplicates by study URL (falling back to + * study key) and keeps only the latest row per study. Intended for a "previously joined + * studies" history/management list. + * + * @param c + * @return one {@link ContentValues} per distinct joined study (all study columns), newest first + */ + public static List getJoinedStudies(Context c) { + List studies = new ArrayList<>(); + Set seen = new HashSet<>(); + Cursor cursor = c.getContentResolver().query(Aware_Provider.Aware_Studies.CONTENT_URI, null, + Aware_Provider.Aware_Studies.STUDY_JOINED + ">0", null, + Aware_Provider.Aware_Studies.STUDY_TIMESTAMP + " DESC"); + if (cursor != null) { + while (cursor.moveToNext()) { + String url = cursor.getString(cursor.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_URL)); + String identity = (url != null && url.length() > 0) + ? url + : "key:" + cursor.getInt(cursor.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_KEY)); + if (!seen.add(identity)) continue; // already have the latest row for this study + ContentValues row = new ContentValues(); + DatabaseUtils.cursorRowToContentValues(cursor, row); + studies.add(row); + } + cursor.close(); + } + return studies; + } + /** * Gets the study config object for a given study URL * @@ -676,26 +932,109 @@ public static JSONObject getStudyConfig(Context c, String study_url) { JSONObject studyConfig = new JSONObject(); if (study != null && study.moveToFirst()) { - try { - studyConfig = new JSONObject(study.getString( - study.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_CONFIG))); - } catch (JSONException e) { - e.printStackTrace(); - } + studyConfig = parseStudyConfig(study.getString( + study.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_CONFIG))); } + if (study != null && !study.isClosed()) study.close(); return studyConfig; } + /** + * Gets the config object for the currently joined study, independent of WEBSERVICE_SERVER. + * + * @param c + * @return JSONObject representing the active study config + */ + public static JSONObject getActiveStudyConfig(Context c) { + Cursor study = getActiveStudy(c); + JSONObject studyConfig = new JSONObject(); + + if (study != null && study.moveToFirst()) { + studyConfig = parseStudyConfig(study.getString( + study.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_CONFIG))); + } + + if (study != null && !study.isClosed()) study.close(); + return studyConfig; + } + + private static JSONObject parseStudyConfig(String config) { + if (config == null || config.trim().length() == 0) return new JSONObject(); + + try { + String trimmed = config.trim(); + if (trimmed.startsWith("[")) { + JSONArray configs = new JSONArray(trimmed); + return configs.length() > 0 ? configs.getJSONObject(0) : new JSONObject(); + } + return new JSONObject(trimmed); + } catch (JSONException e) { + e.printStackTrace(); + return new JSONObject(); + } + } + + /** + * Read a numeric setting, tolerating empty/missing/invalid values. Some settings + * (e.g. FREQUENCY_SYNC_CONFIG) are only populated by a study config, so after + * {@link #reset(Context)} on study exit they can be empty; parsing them directly + * would throw NumberFormatException and crash the service on start. + */ + public static long getSettingAsLong(Context context, String key, long defaultValue) { + return parseLongOrDefault(Aware.getSetting(context, key), defaultValue); + } + + /** @see #getSettingAsLong(Context, String, long) */ + public static int getSettingAsInt(Context context, String key, int defaultValue) { + return parseIntOrDefault(Aware.getSetting(context, key), defaultValue); + } + + /** @see #getSettingAsLong(Context, String, long) */ + public static double getSettingAsDouble(Context context, String key, double defaultValue) { + return parseDoubleOrDefault(Aware.getSetting(context, key), defaultValue); + } + + /** Context-free parse core of {@link #getSettingAsLong}, split out so it's unit-testable without a Context. */ + static long parseLongOrDefault(String raw, long defaultValue) { + try { + return Long.parseLong(raw); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + /** Context-free parse core of {@link #getSettingAsInt}, split out so it's unit-testable without a Context. */ + static int parseIntOrDefault(String raw, int defaultValue) { + try { + return Integer.parseInt(raw); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + /** Context-free parse core of {@link #getSettingAsDouble}, split out so it's unit-testable without a Context. */ + static double parseDoubleOrDefault(String raw, double defaultValue) { + try { + // Unlike Integer.parseInt/Long.parseLong, Double.parseDouble(null) throws + // NullPointerException rather than NumberFormatException — catch both. + return Double.parseDouble(raw); + } catch (NumberFormatException | NullPointerException e) { + return defaultValue; + } + } + @Override public int onStartCommand(Intent intent, int flags, int startId) { super.onStartCommand(intent, flags, startId); // To track the application starting info - Aware.debug(getApplicationContext(),"Aware-starting"); + Aware.debug(getApplicationContext(), LogType.LIFECYCLE, "Aware-starting"); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && PermissionChecker.checkSelfPermission(getApplicationContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE) != PermissionChecker.PERMISSION_GRANTED) { - return START_STICKY; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && ContextCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + Log.w(TAG, "AWARE core not starting: storage permission is missing"); + stopSelf(startId); + return START_NOT_STICKY; } if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) { @@ -714,6 +1053,16 @@ public int onStartCommand(Intent intent, int flags, int startId) { //this sets the default settings to all plugins too Map defaults = prefs.getAll(); for (Map.Entry entry : defaults.entrySet()) { + // Skip webservice_server: the "com.aware.phone" SharedPreferences file baked in the + // XML placeholder ("http://api.awareframework.com/index.php") the first time + // setDefaultValues() ever ran on this device, and nothing ever updates that cached + // copy when a participant later joins a real study (Aware_Join_Study writes straight + // to the ContentProvider settings table, not to this SharedPreferences file). So this + // loop was silently refilling a real join URL with that stale placeholder every time + // it happened to observe the ContentProvider's copy empty — the same landmine as the + // explicit fallback removed below, just reached through the cached-defaults path + // instead. See Aware_Join_Study for how webservice_server is actually meant to be set. + 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 } @@ -724,17 +1073,35 @@ public int onStartCommand(Intent intent, int flags, int startId) { 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"); + // Self-heal webservice_server from aware_studies — the authoritative record of what was + // actually joined — instead of ever defaulting it to a placeholder. This setting doubles + // as the join URL Aware.getStudy()/isStudy() match against aware_studies.study_url, so + // it must never silently become a demo-server placeholder (that used to happen via a + // couple of "if empty, default to..." fallbacks, all removed) or sit corrupted from that + // historical bug waiting on a manual rejoin/DB fix. If it doesn't match the enrolled + // study's real URL, correct it here, every service start. + if (isStudy(getApplicationContext())) { + Cursor activeStudy = getActiveStudy(getApplicationContext()); + if (activeStudy != null && activeStudy.moveToFirst()) { + String realUrl = activeStudy.getString(activeStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_URL)); + String currentUrl = Aware.getSetting(getApplicationContext(), Aware_Preferences.WEBSERVICE_SERVER); + if (realUrl != null && realUrl.length() > 0 && !realUrl.equals(currentUrl)) { + Aware.setSetting(getApplicationContext(), Aware_Preferences.WEBSERVICE_SERVER, realUrl); + } + } + if (activeStudy != null && !activeStudy.isClosed()) activeStudy.close(); } - DEBUG = Aware.getSetting(this, Aware_Preferences.DEBUG_FLAG).equals("true"); TAG = Aware.getSetting(this, Aware_Preferences.DEBUG_TAG).length() > 0 ? Aware.getSetting(this, Aware_Preferences.DEBUG_TAG) : TAG; get_device_info(); if (Aware.getSetting(getApplicationContext(), Aware_Preferences.AWARE_DONATE_USAGE).equals("true")) { - new AsyncPing().execute(); + long now = System.currentTimeMillis(); + if (now - lastDonationPingAt >= DONATION_PING_INTERVAL_MS + && donationPingInFlight.compareAndSet(false, true)) { + new AsyncPing().execute(); + } } //only the client and self-contained apps need to run the keep alive. Plugins are handled by them. @@ -798,11 +1165,6 @@ public int onStartCommand(Intent intent, int flags, int startId) { startPlugins(getApplicationContext()); } - if (intent.getAction().equalsIgnoreCase(ACTION_AWARE_KEEP_ALIVE)) { - startAWARE(getApplicationContext()); - startPlugins(getApplicationContext()); - } - } else { startAWARE(getApplicationContext()); startPlugins(getApplicationContext()); @@ -811,7 +1173,7 @@ public int onStartCommand(Intent intent, int flags, int startId) { if (Aware.isStudy(this)) { ContentResolver.setIsSyncable(Aware.getAWAREAccount(this), Aware_Provider.getAuthority(this), 1); ContentResolver.setSyncAutomatically(Aware.getAWAREAccount(this), Aware_Provider.getAuthority(this), true); - long frequency = Long.parseLong(Aware.getSetting(this, Aware_Preferences.FREQUENCY_WEBSERVICE)) * 60; + long frequency = getSettingAsLong(this, Aware_Preferences.FREQUENCY_WEBSERVICE, 30) * 60; SyncRequest request = new SyncRequest.Builder() .syncPeriodic(frequency, frequency/3) .setSyncAdapter(Aware.getAWAREAccount(this), Aware_Provider.getAuthority(this)) @@ -821,10 +1183,11 @@ public int onStartCommand(Intent intent, int flags, int startId) { // Set scheduler for syncing config data try { Scheduler.Schedule syncConfig = Scheduler.getSchedule(this, Aware.SCHEDULE_SYNC_CONFIG); - frequency = Long.parseLong(getSetting(this, Aware_Preferences.FREQUENCY_SYNC_CONFIG)); + frequency = getSettingAsLong(this, Aware_Preferences.FREQUENCY_SYNC_CONFIG, 30); if (syncConfig != null && syncConfig.getInterval() != frequency) { syncConfig.setInterval(frequency); + Scheduler.saveSchedule(this, syncConfig); } if (syncConfig == null) { syncConfig = new Scheduler.Schedule(Aware.SCHEDULE_SYNC_CONFIG); @@ -1219,12 +1582,6 @@ public static void setSetting(Context context, String key, Object value) { return; } - if (key.equals(Aware_Preferences.DEVICE_LABEL) && ((String) value).length() > 0) { - ContentValues newLabel = new ContentValues(); - newLabel.put(Aware_Provider.Aware_Device.LABEL, (String) value); - context.getApplicationContext().getContentResolver().update(Aware_Provider.Aware_Device.CONTENT_URI, newLabel, Aware_Provider.Aware_Device.DEVICE_ID + " LIKE '" + Aware.getSetting(context, Aware_Preferences.DEVICE_ID) + "'", null); - } - ContentValues setting = new ContentValues(); setting.put(Aware_Settings.SETTING_KEY, key); setting.put(Aware_Settings.SETTING_VALUE, value.toString()); @@ -1287,12 +1644,6 @@ public static void setSetting(Context context, String key, Object value, String return; } - if (key.equals(Aware_Preferences.DEVICE_LABEL) && ((String) value).length() > 0) { - ContentValues newLabel = new ContentValues(); - newLabel.put(Aware_Provider.Aware_Device.LABEL, (String) value); - context.getContentResolver().update(Aware_Provider.Aware_Device.CONTENT_URI, newLabel, Aware_Provider.Aware_Device.DEVICE_ID + " LIKE '" + Aware.getSetting(context, Aware_Preferences.DEVICE_ID) + "'", null); - } - ContentValues setting = new ContentValues(); setting.put(Aware_Settings.SETTING_KEY, key); setting.put(Aware_Settings.SETTING_VALUE, value.toString()); @@ -1557,6 +1908,7 @@ protected static void tweakSettings(Context c, JSONArray serverConfig) { if (config_changed) { ContentValues newCfg = new ContentValues(); newCfg.put(Aware_Provider.Aware_Studies.STUDY_CONFIG, localConfig.toString()); + newCfg.put(Aware_Provider.Aware_Studies.STUDY_UPDATED, System.currentTimeMillis()); c.getContentResolver().update(Aware_Provider.Aware_Studies.CONTENT_URI, newCfg, Aware_Provider.Aware_Studies._ID + "=" + study_id, null); Intent aware = new Intent(c, Aware.class); @@ -1743,8 +2095,9 @@ protected void onHandleIntent(Intent intent) { if (protocol.equals("https")) { SSLManager.handleUrl(getApplicationContext(), full_url, true); - while(!SSLManager.hasCertificate(getApplicationContext(), study_uri.getHost())) { - //wait until we have the certificate downloaded + if (!SSLManager.hasCertificate(getApplicationContext(), study_uri.getHost())) { + Log.e(TAG, "Unable to obtain an SSL certificate for " + study_uri.getHost()); + return; } // TODO RIO: Replace GET to webserver a GET to study config URL @@ -1800,7 +2153,7 @@ protected void onHandleIntent(Intent intent) { JSONArray study_config = new JSONArray(answer); if (DEBUG) - Log.d(TAG, "Study config: " + study_config.toString(5)); + Log.d(TAG, LogRedactor.redact("Study config: " + study_config.toString(5))); if (study_config.getJSONObject(0).has("message")) { Toast.makeText(getApplicationContext(), study_config.getJSONObject(0).getString("message"), Toast.LENGTH_LONG).show(); @@ -1809,7 +2162,7 @@ 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(); @@ -1827,7 +2180,7 @@ 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 { ContentValues studyData = new ContentValues(); @@ -1846,7 +2199,7 @@ 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())); } } @@ -1874,13 +2227,21 @@ protected void onHandleIntent(Intent intent) { } } + // Programmatic join with no consent UI: hold every consent-requiring sensor + // off and persist it to the declined set, so a sensor needing a runtime + // permission or the Accessibility Service isn't silently enabled. Base + // (permission-free) sensors apply as the config specifies. + Set declined = StudyUtils.holdConsentSensorsUnlessAgreed(getApplicationContext(), study_config); + //Set the sensors' settings first for (int i = 0; i < sensors.length(); i++) { try { JSONObject sensor_config = sensors.getJSONObject(i); + String setting = sensor_config.getString("setting"); String package_name = "com.aware.phone"; if (getApplicationContext().getResources().getBoolean(R.bool.standalone)) package_name = getApplicationContext().getPackageName(); - Aware.setSetting(getApplicationContext(), sensor_config.getString("setting"), sensor_config.get("value"), package_name); + Object value = declined.contains(setting) ? Boolean.FALSE : sensor_config.get("value"); + Aware.setSetting(getApplicationContext(), setting, value, package_name); } catch (JSONException e) { e.printStackTrace(); } @@ -1961,9 +2322,36 @@ public void onDestroy() { } } - public static void reset(Context context) { + /** + * synchronized: reset() captures device_id/device_label/webservice_server, wipes all settings, + * then restores them. Every ACTION_AWARE_SYNC_CONFIG trigger (scheduled poll, manual button, + * onResume) spawns its own thread and can end up calling this concurrently (directly, or via + * applySettings()). Without serializing, one thread's capture step can read another thread's + * already-wiped-but-not-yet-restored (empty) value and persist that — which is exactly how + * webservice_server was intermittently coming back empty despite the fix below. + */ + public static synchronized void reset(Context context) { String device_id = Aware.getSetting(context, Aware_Preferences.DEVICE_ID); String device_label = Aware.getSetting(context, Aware_Preferences.DEVICE_LABEL); + // Preserve across the wipe below, same as device_id/device_label: this is the join URL + // Aware.getStudy() matches against aware_studies.study_url. Without this, it comes back + // empty and the next service start refills it with a hardcoded placeholder, permanently + // breaking study lookup even though the aware_studies row is still healthy. + String webservice_server = Aware.getSetting(context, Aware_Preferences.WEBSERVICE_SERVER); + // Preserve across the wipe below: not part of aware_preferences.xml's defaults, so it would + // otherwise come back empty until the current sync's processSensorSettings() re-applies it a + // few lines later in applySettings(). This is the shared "how often to sync data" interval — + // nearly every sensor's onStartCommand() does Long.parseLong(getSetting(FREQUENCY_WEBSERVICE)) + // with no validation to schedule its own periodic data sync (the real JDBC upload path, not + // just the legacy webservice one). If a sensor happens to (re)start on a different thread + // during that narrow window, it crashes on an empty string — observed for real in + // ESM.onStartCommand and the ambient_noise plugin's AudioAnalyser. + String frequency_webservice = Aware.getSetting(context, Aware_Preferences.FREQUENCY_WEBSERVICE); + // Preserve across the wipe below: a not-yet-shown "study updated" notice for the participant + // (StudyUtils.syncStudyConfig sets this, Aware_Client shows it and clears it once seen). Also + // not part of aware_preferences.xml's defaults, so without this it would be silently dropped + // if a reset() races a pending notice — the participant would simply never see that update. + String pending_study_update_notice = Aware.getSetting(context, Aware_Preferences.PENDING_STUDY_UPDATE_NOTICE); //Remove all settings context.getContentResolver().delete(Aware_Settings.CONTENT_URI, null, null); @@ -1984,10 +2372,9 @@ public static void reset(Context context) { //Keep previous AWARE Device ID and label Aware.setSetting(context, Aware_Preferences.DEVICE_ID, device_id, "com.aware.phone"); Aware.setSetting(context, Aware_Preferences.DEVICE_LABEL, device_label, "com.aware.phone"); - - ContentValues update_label = new ContentValues(); - update_label.put(Aware_Device.LABEL, device_label); - context.getContentResolver().update(Aware_Device.CONTENT_URI, update_label, Aware_Device.DEVICE_ID + " LIKE '" + device_id + "'", null); + Aware.setSetting(context, Aware_Preferences.WEBSERVICE_SERVER, webservice_server, "com.aware.phone"); + Aware.setSetting(context, Aware_Preferences.FREQUENCY_WEBSERVICE, frequency_webservice, "com.aware.phone"); + Aware.setSetting(context, Aware_Preferences.PENDING_STUDY_UPDATE_NOTICE, pending_study_update_notice, "com.aware.phone"); //Turn off all active plugins ArrayList active_plugins = new ArrayList<>(); @@ -2073,7 +2460,6 @@ public void onReceive(Context context, Intent intent) { complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_API, studyInfo.getString(studyInfo.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_API))); complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_URL, studyInfo.getString(studyInfo.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_URL))); complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_PI, studyInfo.getString(studyInfo.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_PI))); - complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_CONFIG, studyInfo.getString(studyInfo.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_CONFIG))); complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_JOINED, studyInfo.getLong(studyInfo.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_JOINED))); complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_TITLE, studyInfo.getString(studyInfo.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_TITLE))); complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_DESCRIPTION, studyInfo.getString(studyInfo.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_DESCRIPTION))); @@ -2163,7 +2549,6 @@ public void onReceive(Context context, Intent intent) { complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_API, studyInfo.getString(studyInfo.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_API))); complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_URL, studyInfo.getString(studyInfo.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_URL))); complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_PI, studyInfo.getString(studyInfo.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_PI))); - complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_CONFIG, studyInfo.getString(studyInfo.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_CONFIG))); complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_JOINED, studyInfo.getLong(studyInfo.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_JOINED))); complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_TITLE, studyInfo.getString(studyInfo.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_TITLE))); complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_DESCRIPTION, studyInfo.getString(studyInfo.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_DESCRIPTION))); @@ -2230,7 +2615,6 @@ public void onReceive(Context context, Intent intent) { complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_API, studyInfo.getString(studyInfo.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_API))); complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_URL, studyInfo.getString(studyInfo.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_URL))); complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_PI, studyInfo.getString(studyInfo.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_PI))); - complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_CONFIG, studyInfo.getString(studyInfo.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_CONFIG))); complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_JOINED, studyInfo.getLong(studyInfo.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_JOINED))); complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_TITLE, studyInfo.getString(studyInfo.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_TITLE))); complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_DESCRIPTION, studyInfo.getString(studyInfo.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_DESCRIPTION))); @@ -2268,6 +2652,71 @@ public void onReceive(Context context, Intent intent) { * @author denzil */ public static final Aware_Broadcaster aware_BR = new Aware_Broadcaster(); + // Single-threaded: syncStudyConfig() -> applySettings() calls Aware.reset() (wipe all settings) + // and then writes the new config back non-atomically over several steps. Only reset() itself is + // synchronized, so two overlapping ACTION_AWARE_SYNC_CONFIG triggers (scheduled poll, onResume(), + // repeated manual button taps — none of which are mutually exclusive) used to each run on their + // own ad-hoc Thread, and a second thread's reset() could wipe settings a first thread had only + // partially finished re-applying, corrupting the result (observed for real: study config kept + // re-appearing as "updated" every sync, and device_id drifted to a freshly-minted UUID mid-sync). + // Routing every sync through one single-thread executor makes them run strictly one at a time. + private static final java.util.concurrent.ExecutorService syncConfigExecutor = + java.util.concurrent.Executors.newSingleThreadExecutor(); + private static final Object syncConfigLock = new Object(); + private static boolean syncConfigDrainScheduled; + private static boolean syncConfigPending; + private static boolean syncConfigPendingToast; + private static boolean syncConfigPendingManual; + private static boolean syncConfigPendingApproved; + + /** + * Runs only one configuration sync at a time. If another request arrives while it is running, + * schedules one additional sync after the current one finishes. + * A single-thread executor alone still accepts an unbounded queue, which allowed alarms, + * UI taps and config-triggered service restarts to accumulate hundreds of stale requests. + */ + private static void enqueueStudyConfigSync( + Context context, boolean showToast, boolean manual, boolean approved) { + final Context appContext = context.getApplicationContext(); + synchronized (syncConfigLock) { + syncConfigPending = true; + syncConfigPendingToast |= showToast; + syncConfigPendingManual |= manual; + syncConfigPendingApproved |= approved; + if (syncConfigDrainScheduled) return; + syncConfigDrainScheduled = true; + } + + syncConfigExecutor.execute(new Runnable() { + @Override + public void run() { + while (true) { + final boolean toast; + final boolean manual; + final boolean approved; + synchronized (syncConfigLock) { + if (!syncConfigPending) { + syncConfigDrainScheduled = false; + return; + } + toast = syncConfigPendingToast; + manual = syncConfigPendingManual; + approved = syncConfigPendingApproved; + syncConfigPending = false; + syncConfigPendingToast = false; + syncConfigPendingManual = false; + syncConfigPendingApproved = false; + } + try { + StudyUtils.syncStudyConfig(appContext, toast, manual, approved); + } catch (RuntimeException e) { + Log.e(TAG, "Study config sync failed", e); + } + } + } + }); + } + public static class Aware_Broadcaster extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { @@ -2291,13 +2740,10 @@ public void onReceive(Context context, Intent intent) { ContentResolver.requestSync(Aware.getAWAREAccount(context), Aware_Provider.getAuthority(context), sync); } if (intent.getAction().equals(Aware.ACTION_AWARE_SYNC_CONFIG) && isStudy(context)) { - new Thread(new Runnable() { - @Override - public void run() { - Boolean showToast = intent.getBooleanExtra(Aware.SYNC_CONFIG_EXTRA_TOAST, false); - StudyUtils.syncStudyConfig(context, showToast); - } - }).start(); + final boolean showToast = intent.getBooleanExtra(Aware.SYNC_CONFIG_EXTRA_TOAST, false); + final boolean manual = intent.getBooleanExtra(Aware.SYNC_CONFIG_EXTRA_MANUAL, false); + final boolean approved = intent.getBooleanExtra(Aware.SYNC_CONFIG_EXTRA_APPROVED, false); + enqueueStudyConfigSync(context, showToast, manual, approved); } } } @@ -2359,7 +2805,7 @@ public void onReceive(Context context, Intent intent) { } } if (intent.getAction().equalsIgnoreCase(Intent.ACTION_BOOT_COMPLETED)) { - Aware.debug(context, "phone: on"); + Aware.debug(context, LogType.LIFECYCLE, "phone: on"); rowData.put(Battery_Provider.Battery_Data.STATUS, Battery.STATUS_PHONE_BOOTED); Intent aware = new Intent(context, Aware.class); @@ -2369,11 +2815,11 @@ public void onReceive(Context context, Intent intent) { context.sendBroadcast(new Intent(Aware.ACTION_AWARE_PRIORITY_FOREGROUND)); } if (intent.getAction().equalsIgnoreCase(Intent.ACTION_SHUTDOWN)) { - Aware.debug(context, "phone: off"); + Aware.debug(context, LogType.LIFECYCLE, "phone: off"); rowData.put(Battery_Provider.Battery_Data.STATUS, Battery.STATUS_PHONE_SHUTDOWN); } if (intent.getAction().equalsIgnoreCase(Intent.ACTION_REBOOT)) { - Aware.debug(context, "phone: reboot"); + Aware.debug(context, LogType.LIFECYCLE, "phone: reboot"); rowData.put(Battery_Provider.Battery_Data.STATUS, Battery.STATUS_PHONE_REBOOT); } if (intent.getAction().equalsIgnoreCase(Intent.ACTION_BOOT_COMPLETED) @@ -2454,11 +2900,22 @@ private static void complianceStatus(Context context) { complianceStatus.put("location_gps", locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)); complianceStatus.put("location_network", locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)); - Aware.debug(context, complianceStatus.toString()); + Aware.debug(context, LogType.CONTEXT, complianceStatus.toString()); } catch (JSONException e) { e.printStackTrace(); } + + // Log why any study-enabled sensor can't actually collect on this device — same aware_log + // sink as the compliance line just above, so it reaches the researcher on the existing + // ~SCHEDULE_STUDY_COMPLIANCE cadence without any new sync path or server-side schema. + if (isStudy(context)) { + try { + SensorDiagnostics.logActiveStudySensorStatus(context); + } catch (Exception e) { + Log.e(TAG, "Error logging sensor diagnostics: " + e.getMessage()); + } + } } /** @@ -2466,6 +2923,11 @@ private static void complianceStatus(Context context) { */ public static void startAWARE(Context context) { + // Keep grouped participant consent authoritative before any sensor service is evaluated. + // This also migrates studies joined before installation events were tied to Applications + // consent, preventing package monitoring from remaining active after that consent was denied. + StudyUtils.enforceGroupedConsent(context); + startScheduler(context); if (Aware.getSetting(context, Aware_Preferences.STATUS_SIGNIFICANT_MOTION).equals("true")) { @@ -2717,10 +3179,10 @@ public static void stopAWARE(Context context) { // Handle based on whether it's user-initiated or system-initiated closure if (isFinishing) { // User initiated closure - Aware.debug(context, "AWARE interface cleaned from the array of frequently used apps"); + Aware.debug(context, LogType.LIFECYCLE, "AWARE interface cleaned from the array of frequently used apps"); } else { // System-initiated closure - Aware.debug(context, "AWARE interface cleaned by smartphone system"); + Aware.debug(context, LogType.LIFECYCLE, "AWARE interface cleaned by smartphone system"); } } diff --git a/aware-core/src/main/java/com/aware/Aware_Preferences.java b/aware-core/src/main/java/com/aware/Aware_Preferences.java index 74de1e7..242aa2d 100644 --- a/aware-core/src/main/java/com/aware/Aware_Preferences.java +++ b/aware-core/src/main/java/com/aware/Aware_Preferences.java @@ -81,7 +81,9 @@ public class Aware_Preferences { public static final String STATUS_APPLICATIONS = "status_applications"; /** - * Background applications update frequency (default = 30) seconds + * Background applications update frequency, in minutes. Default = 0 (disabled) -- the + * scheduled background scan only runs when this is set > 0. Fed directly into + * Scheduler.Schedule#setInterval(long), which takes minutes. */ public static final String FREQUENCY_APPLICATIONS = "frequency_applications"; @@ -301,7 +303,9 @@ public class Aware_Preferences { public static final String STATUS_NETWORK_TRAFFIC = "status_network_traffic"; /** - * Network traffic frequency (default = 60), in seconds + * Network traffic frequency (default = 60), in seconds. + * Currently unused: Traffic.java does not read this setting -- its sync cadence is + * driven by FREQUENCY_WEBSERVICE instead. */ public static final String FREQUENCY_NETWORK_TRAFFIC = "frequency_network_traffic"; @@ -371,7 +375,9 @@ public class Aware_Preferences { public static final String STATUS_TIMEZONE = "status_timezone"; /** - * Timezone frequency (default = 3600) in seconds + * Timezone frequency (default = 3600) in seconds. + * Currently unused: Timezone.java does not read this setting -- its sync cadence is + * driven by FREQUENCY_WEBSERVICE instead. */ public static final String FREQUENCY_TIMEZONE = "frequency_timezone"; @@ -628,10 +634,96 @@ public class Aware_Preferences { public static final String WEBSERVICE_SILENT = "webservice_silent"; /** - * Disable the sensors config settings from being updated from the mobile app. + * Whether an enrolled participant may maintain a device-specific sensor configuration. + * When true, sensor edits made on the phone are persisted into the active study record and + * automatic server-config updates are skipped. An explicit "Check for study updates" applies + * the complete latest server configuration, including any change that disables this mode. */ public static final String ENABLE_CONFIG_UPDATE = "enable_config_update"; + /** + * Serialized JSON payload describing the last study config update the participant hasn't + * seen yet (sensors added/removed, enable_config_update change). Written by + * StudyUtils.syncStudyConfig() so the notice survives even if no UI was open to receive the + * live ACTION_AWARE_STUDY_CONFIG_UPDATED broadcast; cleared once shown. + */ + public static final String PENDING_STUDY_UPDATE_NOTICE = "pending_study_update_notice"; + + /** + * Validated server configuration found by an editable-mode manual check but not yet accepted + * by the participant. + */ + public static final String PENDING_STUDY_CONFIG_APPROVAL = "pending_study_config_approval"; + + /** + * Study URL awaiting participant re-authentication: set by StudyUtils.syncStudyConfig() when a + * password-join study (config_without_password=true) rejects the stored database password + * (auth failure, not an unreachable server). Aware_Client shows a password prompt while this is + * non-empty; cleared once the participant enters a password the database accepts. Empty means + * no re-authentication is pending. + */ + public static final String PENDING_STUDY_REAUTH = "pending_study_reauth"; + + /** + * When the current delivery outage began, or 0 while delivery is healthy. Held as a start time + * rather than a failure count so one outage is one fact however many tables report it. + */ + public static final String UPLOAD_OUTAGE_SINCE = "upload_outage_since"; + + /** Short, non-sensitive reason the current outage was first recorded with; empty when healthy. */ + public static final String UPLOAD_OUTAGE_REASON = "upload_outage_reason"; + + /** + * Which tables are currently failing to deliver, as {@code table:sinceMs} pairs separated by + * commas; empty when every table is delivering. + * + * Per table rather than one flag for the whole upload, because a single table can fail while its + * neighbours succeed — a column the server lacks, or a sensor that has stopped collecting. One + * shared flag is cleared by the next table's success, so that case reported itself as healthy. + * Held in one setting rather than one per table so adding a sensor needs no new key. + */ + public static final String UPLOAD_OUTAGE_TABLES = "upload_outage_tables"; + + /** Whether the participant has already been notified about the current outage. */ + public static final String UPLOAD_OUTAGE_NOTIFIED = "upload_outage_notified"; + + /** + * Signature of the last detected mismatch between live sensor settings and the study config + * that StudyUtils.syncStudyConfig() attempted to self-heal (empty string = none). Used to avoid + * re-applying settings on every sync poll when the drift can't actually be fixed (e.g. a sensor + * whose hardware is missing) — see LAST_DRIFT_RECONCILE_TS. + */ + public static final String LAST_DRIFT_SIGNATURE = "last_drift_signature"; + + /** + * Timestamp (ms) of the last time syncStudyConfig() attempted to self-heal a live-settings + * drift matching LAST_DRIFT_SIGNATURE. Paired with a backoff window so an unfixable drift + * doesn't restart every sensor service on every ~1 minute sync poll forever. + */ + public static final String LAST_DRIFT_RECONCILE_TS = "last_drift_reconcile_ts"; + + /** + * Record of the study consent the participant last gave: the study URL plus a fingerprint of + * the promptable-sensor set they agreed to (see SensorCollection.consentFingerprint in + * aware-phone). Written when the consent screen's Continue completes; compared at join time so + * a re-join with an unchanged sensor set can skip the screen. Deliberately NOT preserved across + * Aware.reset(): quitting a study wipes it, so the next join always asks for consent again — + * OS permissions surviving the quit must not stand in for renewed agreement. Config-sync's + * internal reset (StudyUtils.applySettings) snapshots and restores it, since a background sync + * is not a consent event in either direction. + */ + public static final String STUDY_CONSENT_RECORD = "study_consent_record"; + + /** + * Comma-joined status_* keys the participant explicitly declined consent for. Source of truth + * for "off by participant choice" (as opposed to "off because the config says so"): applySettings + * forces these to false regardless of the config, and syncStudyConfig's drift self-heal excludes + * them so a decline isn't re-enabled on the next ~1-minute poll. Preserved across the reset() + * inside applySettings, but — like STUDY_CONSENT_RECORD — wiped by the Aware.reset() every quit + * path calls, so declines are scoped to the current enrolment. + */ + public static final String STUDY_DECLINED_SENSORS = "study_declined_sensors"; + /** * Key management strategy. * - "once" = keys are not updated once downloaded. diff --git a/aware-core/src/main/java/com/aware/Barometer.java b/aware-core/src/main/java/com/aware/Barometer.java index 59b81e9..2e372fa 100644 --- a/aware-core/src/main/java/com/aware/Barometer.java +++ b/aware-core/src/main/java/com/aware/Barometer.java @@ -26,6 +26,7 @@ import com.aware.providers.Barometer_Provider.Barometer_Data; import com.aware.providers.Barometer_Provider.Barometer_Sensor; import com.aware.utils.Aware_Sensor; +import com.aware.utils.SensorTimeUnits; import java.util.ArrayList; import java.util.List; @@ -250,15 +251,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 +275,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 +283,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)) diff --git a/aware-core/src/main/java/com/aware/Battery.java b/aware-core/src/main/java/com/aware/Battery.java index 88fb39c..05ac48a 100644 --- a/aware-core/src/main/java/com/aware/Battery.java +++ b/aware-core/src/main/java/com/aware/Battery.java @@ -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 ba2a229..cc3ba44 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); @@ -436,11 +448,14 @@ 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(); @@ -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); } } @@ -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.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())); - 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 a6a7b0d..97e9fb1 100644 --- a/aware-core/src/main/java/com/aware/Communication.java +++ b/aware-core/src/main/java/com/aware/Communication.java @@ -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 d1345de..cbb436b 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)) @@ -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 @@ -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 9c8b25e..b178e24 100644 --- a/aware-core/src/main/java/com/aware/Gravity.java +++ b/aware-core/src/main/java/com/aware/Gravity.java @@ -26,6 +26,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; @@ -287,15 +288,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 +312,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 +320,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)) diff --git a/aware-core/src/main/java/com/aware/Gyroscope.java b/aware-core/src/main/java/com/aware/Gyroscope.java index 611cdaf..28987f4 100644 --- a/aware-core/src/main/java/com/aware/Gyroscope.java +++ b/aware-core/src/main/java/com/aware/Gyroscope.java @@ -26,6 +26,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; @@ -298,15 +299,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 +323,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 +332,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)) diff --git a/aware-core/src/main/java/com/aware/Installations.java b/aware-core/src/main/java/com/aware/Installations.java index 992a13d..c933235 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)) diff --git a/aware-core/src/main/java/com/aware/Keyboard.java b/aware-core/src/main/java/com/aware/Keyboard.java index 48d8206..9e4dcf1 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 496d426..d9ffed7 100644 --- a/aware-core/src/main/java/com/aware/Light.java +++ b/aware-core/src/main/java/com/aware/Light.java @@ -26,6 +26,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; @@ -268,8 +269,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 +286,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)) diff --git a/aware-core/src/main/java/com/aware/LinearAccelerometer.java b/aware-core/src/main/java/com/aware/LinearAccelerometer.java index dcec63a..50a6618 100644 --- a/aware-core/src/main/java/com/aware/LinearAccelerometer.java +++ b/aware-core/src/main/java/com/aware/LinearAccelerometer.java @@ -26,6 +26,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; @@ -285,15 +286,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 +310,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 +319,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)) diff --git a/aware-core/src/main/java/com/aware/Locations.java b/aware-core/src/main/java/com/aware/Locations.java index 0faeebd..23e8663 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 @@ -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,15 +338,16 @@ 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"); @@ -352,13 +369,14 @@ 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"); @@ -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)) diff --git a/aware-core/src/main/java/com/aware/Magnetometer.java b/aware-core/src/main/java/com/aware/Magnetometer.java index a9782f0..26205ba 100644 --- a/aware-core/src/main/java/com/aware/Magnetometer.java +++ b/aware-core/src/main/java/com/aware/Magnetometer.java @@ -26,6 +26,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; @@ -99,8 +100,8 @@ 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; } @@ -263,15 +264,15 @@ public int onStartCommand(Intent intent, int flags, int startId) { saveSensorDevice(mMagnetometer); 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 +288,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 +296,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)) diff --git a/aware-core/src/main/java/com/aware/Mqtt.java b/aware-core/src/main/java/com/aware/Mqtt.java index 89812ad..7e14186 100644 --- a/aware-core/src/main/java/com/aware/Mqtt.java +++ b/aware-core/src/main/java/com/aware/Mqtt.java @@ -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); @@ -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 6e47705..ad338ae 100644 --- a/aware-core/src/main/java/com/aware/Network.java +++ b/aware-core/src/main/java/com/aware/Network.java @@ -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 ccb71c0..40533aa 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 d81e016..3280448 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; @@ -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 1ceedc2..7fc9448 100644 --- a/aware-core/src/main/java/com/aware/Proximity.java +++ b/aware-core/src/main/java/com/aware/Proximity.java @@ -26,6 +26,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; @@ -255,15 +256,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 +280,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 +289,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)) diff --git a/aware-core/src/main/java/com/aware/Rotation.java b/aware-core/src/main/java/com/aware/Rotation.java index 1295987..10cadf2 100644 --- a/aware-core/src/main/java/com/aware/Rotation.java +++ b/aware-core/src/main/java/com/aware/Rotation.java @@ -26,6 +26,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; @@ -294,14 +295,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 +318,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 +327,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)) diff --git a/aware-core/src/main/java/com/aware/Screen.java b/aware-core/src/main/java/com/aware/Screen.java index 3eeb176..7619f64 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)) diff --git a/aware-core/src/main/java/com/aware/ScreenShot.java b/aware-core/src/main/java/com/aware/ScreenShot.java index 327c1d9..d4cfb6e 100644 --- a/aware-core/src/main/java/com/aware/ScreenShot.java +++ b/aware-core/src/main/java/com/aware/ScreenShot.java @@ -78,6 +78,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 +129,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 +139,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 +148,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 +219,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 +298,7 @@ public void run() { retryCount++; if (retryCount > MAX_RETRY_COUNT) { sendRetryExceededBroadcast(); + stopSelf(); return; } int retryDelay = Math.min(capture_delay, retryCount * 100); @@ -478,19 +493,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 +517,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 ed445c9..81f193f 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 20db416..6a3b108 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: @@ -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 522e7dd..5ec46b0 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)) diff --git a/aware-core/src/main/java/com/aware/Temperature.java b/aware-core/src/main/java/com/aware/Temperature.java index 5f2d2cb..361f7af 100644 --- a/aware-core/src/main/java/com/aware/Temperature.java +++ b/aware-core/src/main/java/com/aware/Temperature.java @@ -26,6 +26,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; @@ -264,15 +265,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 +289,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 +298,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)) diff --git a/aware-core/src/main/java/com/aware/Timezone.java b/aware-core/src/main/java/com/aware/Timezone.java index a283d8a..c863853 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 { @@ -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 92accba..b8ccd28 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,22 +36,33 @@ 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()); @@ -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 bd9a930..a17bc99 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)) @@ -328,9 +329,13 @@ 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(); 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 db46cb8..630f58b 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 @@ -19,10 +19,6 @@ import com.aware.utils.DatabaseHelper; 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 +28,7 @@ */ public class Aware_Provider extends ContentProvider { - public static final int DATABASE_VERSION = 18; + public static final int DATABASE_VERSION = 19; /** * AWARE framework content authority @@ -50,6 +46,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,22 +62,17 @@ 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 +136,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,28 +154,57 @@ 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"; } 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 + ")", // Settings @@ -212,13 +235,21 @@ 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," + + "UNIQUE(" + Aware_Sync_Markers.MARKER_TABLE + ")" }; private UriMatcher sUriMatcher; @@ -227,6 +258,7 @@ private Aware_Log() { private HashMap pluginsMap; private HashMap studiesMap; private HashMap logMap; + private HashMap syncMarkersMap; private DatabaseHelper dbHelper; private static SQLiteDatabase database; @@ -266,6 +298,9 @@ public synchronized int delete(Uri uri, String selection, String[] selectionArgs case LOG: count = database.delete(DATABASE_TABLES[4], selection, selectionArgs); break; + case SYNC_MARKER: + count = database.delete(DATABASE_TABLES[5], selection, selectionArgs); + break; default: database.endTransaction(); throw new IllegalArgumentException("Unknown URI " + uri); @@ -302,6 +337,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); } @@ -323,36 +362,12 @@ public synchronized Uri insert(Uri uri, ContentValues initialValues) { 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(); @@ -402,6 +417,19 @@ public void run() { } database.endTransaction(); 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); + getContext().getContentResolver().notifyChange(markerUri, null, false); + database.setTransactionSuccessful(); + database.endTransaction(); + return markerUri; + } + database.endTransaction(); + throw new SQLException("Failed to insert row into " + uri); default: database.endTransaction(); throw new IllegalArgumentException("Unknown URI " + uri); @@ -432,24 +460,22 @@ 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); settingsMap = new HashMap<>(); settingsMap.put(Aware_Settings.SETTING_ID, Aware_Settings.SETTING_ID); @@ -479,6 +505,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 +513,14 @@ 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); + return true; } @@ -524,6 +557,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); } @@ -565,6 +602,9 @@ public synchronized int update(Uri uri, ContentValues values, String selection, 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: database.endTransaction(); throw new IllegalArgumentException("Unknown URI " + uri); 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 7f685e1..28b6d4b 100644 --- a/aware-core/src/main/java/com/aware/syncadapters/AwareSyncAdapter.java +++ b/aware-core/src/main/java/com/aware/syncadapters/AwareSyncAdapter.java @@ -25,6 +25,8 @@ import com.aware.utils.Https; import com.aware.utils.Jdbc; import com.aware.utils.SSLManager; +import com.aware.utils.SyncBatchBudget; +import com.aware.utils.UploadHealth; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; @@ -89,75 +91,32 @@ 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)); + // 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; + } + 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 +146,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"); @@ -212,30 +170,26 @@ private void offloadData(Context context, String database_table, String web_serv String device_id = Aware.getSetting(context, Aware_Preferences.DEVICE_ID); 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); /** - * 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 last-synced marker 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); + String latest = getLatestRecordSynched(database_table, columnsStr); String study_condition = getStudySyncCondition(context, database_table); int total_records = getNumberOfRecordsToSync(CONTENT_URI, columnsStr, latest, 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; - } + // Nothing further is computable without a marker. Checked outside the DEBUG block so a + // build with debugging off takes the same path as one with it on. + if (latest == null) return; - Log.d(Aware.TAG, "Table: " + database_table + " exists"); - Log.d(Aware.TAG, "Last synced record in this table: " + latest); + if (Aware.DEBUG) { + Log.d(Aware.TAG, "Syncing table: " + database_table); + Log.d(Aware.TAG, "Local last-synced marker for this table: " + latest); Log.d(Aware.TAG, "Joined study since: " + study_condition); Log.d(Aware.TAG, "Rows remaining to sync: " + total_records); } @@ -248,28 +202,39 @@ private void offloadData(Context context, String database_table, String web_serv int batches = (int) Math.ceil(total_records / (double) MAX_POST_SIZE); long removeFrom = 0; + long removeFromId = 0; + long[] batchMaxId = new long[]{0}; + int[] batchRowCount = new int[]{0}; Long lastSynced; 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); + batchRowCount[0] = 0; 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); + lastSynced = syncBatch(sync_data, database_table, device_id, context, DEBUG, batchMaxId, batchRowCount); if (lastSynced == null) { removeFrom = 0; - Log.d(Aware.TAG, "Connection to server interrupted. Will try again later."); + removeFromId = 0; + Log.d(Aware.TAG, "Batch for " + database_table + " was not acknowledged by the database. Will try again later."); break; } else { removeFrom = lastSynced; + removeFromId = batchMaxId[0]; } - uploaded_records += MAX_POST_SIZE; + // Advance by the rows the batch actually carried, which is fewer than the + // row-count cap whenever the payload budget closed the batch early. Advancing by + // the cap instead would step the offset past rows that were never uploaded. The + // floor of 1 is a guard against an unproductive batch looping forever; a batch + // that took no rows reports lastSynced 0 and the condition below ends the loop. + uploaded_records += Math.max(batchRowCount[0], 1); } while (uploaded_records < total_records && lastSynced > 0 && 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); + performDatabaseSpaceMaintenance(CONTENT_URI, removeFrom, removeFromId, 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 +281,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 +350,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,17 +370,31 @@ private String[] getTableColumnsNames(Uri CONTENT_URI, Context mContext) { return columnsStr; } + /** + * How far {@code database_table} has been uploaded, shaped as the single-element JSON array the + * sync loop consumes, or an empty array when the table has never been uploaded. + * + * 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. + * + * The key the timestamp is returned under has to match the column the table is paged by: + * session-based tables advance on their end timestamp, ESM answers on the answer timestamp, and + * everything else on {@code timestamp}. + */ private String getLatestRecordSynched(String database_table, String[] columnsStr) { JSONObject latest = new JSONObject(); - long last_sync_timestamp; - 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"); + 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); + boolean found = marker != null && marker.moveToFirst(); + if (found) { + long last_sync_timestamp = marker.getLong( + marker.getColumnIndex(Aware_Provider.Aware_Sync_Markers.MARKER_LAST_SYNCED)); + try { 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")) { @@ -418,14 +405,28 @@ private String getLatestRecordSynched(String database_table, String[] columnsStr } catch (JSONException e) { e.printStackTrace(); } - lastSynched.close(); - } else { - return new JSONArray().toString(); } + if (marker != null && !marker.isClosed()) marker.close(); + + if (!found) return new JSONArray().toString(); return new JSONArray().put(latest).toString(); } + /** + * 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. + */ + private void setLatestRecordSynched(String database_table, long lastSynced) { + ContentValues marker = new ContentValues(); + marker.put(Aware_Provider.Aware_Sync_Markers.MARKER_TABLE, database_table); + marker.put(Aware_Provider.Aware_Sync_Markers.MARKER_LAST_SYNCED, lastSynced); + mContext.getContentResolver().insert(Aware_Provider.Aware_Sync_Markers.CONTENT_URI, marker); + } + private String getStudySyncCondition(Context mContext, String DATABASE_TABLE) { //If in a study, get only data from joined date onwards String study_condition = ""; @@ -551,7 +552,7 @@ private Cursor getSyncData(JSONArray remoteData, Uri CONTENT_URI, String study_c return context_data; } - private void performDatabaseSpaceMaintenance(Uri CONTENT_URI, long last, String[] columnsStr, Boolean WEBSERVICE_REMOVE_DATA, Context mContext, String DATABASE_TABLE, Boolean DEBUG) { + private void performDatabaseSpaceMaintenance(Uri CONTENT_URI, long last, 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")) { @@ -565,7 +566,7 @@ private void performDatabaseSpaceMaintenance(Uri CONTENT_URI, long last, String[ Calendar cal = Calendar.getInstance(); cal.setTimeInMillis(last); 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 +585,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 + deleteSessionBasedSensors, null); break; } @@ -595,40 +599,76 @@ 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 { + private Long syncBatch(Cursor context_data, String DATABASE_TABLE, String DEVICE_ID, Context mContext, Boolean DEBUG, long[] outMaxId, int[] outRowCount) throws JSONException { JSONArray rows = new JSONArray(); long lastSynced = 0; + long maxId = 0; + long payloadBytes = 0; + boolean cappedByPayload = false; if (context_data != null && context_data.moveToFirst()) { 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)); } 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; } while (context_data.moveToNext()); context_data.close(); // Clear phone's memory immediately + if (rows.length() == 0) return 0L; + + 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 // For some tables, we must not clear everything. Leave one row of these tables. if (dontClearSensors.contains(DATABASE_TABLE)) { @@ -641,19 +681,26 @@ private Long syncBatch(Cursor context_data, String DATABASE_TABLE, String DEVICE boolean dataInserted = Jdbc.insertData(mContext, DATABASE_TABLE, rows); - // Something went wrong, e.g., server is down, lost internet, etc. + // The database did not acknowledge the batch. Jdbc 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?"); + if (DEBUG) Log.d(Aware.TAG, DATABASE_TABLE + ": batch of " + rows.length() + + " row(s) / ~" + (payloadBytes / 1024) + " KB was not acknowledged. See the 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 null; } 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 highest _id so + // the caller can delete exactly these rows and nothing inserted after this read, and + // the row count so the caller resumes from what actually went rather than from the + // row-count cap it asked for. + outMaxId[0] = maxId; + outRowCount[0] = rows.length(); + setLatestRecordSynched(DATABASE_TABLE, lastSynced); + UploadHealth.recordSuccess(mContext, DATABASE_TABLE); if (DEBUG) Log.d(Aware.TAG, "Sync OK into " + DATABASE_TABLE + " [ " + rows.length() + " rows ]"); @@ -664,9 +711,20 @@ private Long syncBatch(Cursor context_data, String DATABASE_TABLE, String DEVICE } + /** + * 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 have changed and a new row is warranted (see + * {@code Aware.get_device_info()}), and because the participant's device label is maintained by + * {@code UPDATE ... WHERE device_id LIKE} on that row. A locally deleted row makes the + * comparison find nothing and those 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"); } 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 0000000..4986ba1 --- /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 2a53d55..9e0d7df 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_Question.java b/aware-core/src/main/java/com/aware/ui/esms/ESM_Question.java index 91e682b..4dddd2d 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/utils/Aware_Plugin.java b/aware-core/src/main/java/com/aware/utils/Aware_Plugin.java index 05c94fe..7da2d9e 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 6366a82..933cb8d 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 69431d5..af1f3f1 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 15f953c..b7f64b2 100644 --- a/aware-core/src/main/java/com/aware/utils/DatabaseHelper.java +++ b/aware-core/src/main/java/com/aware/utils/DatabaseHelper.java @@ -64,11 +64,64 @@ 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()); @@ -79,12 +132,19 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { //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]); + // 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; @@ -193,6 +253,21 @@ public synchronized SQLiteDatabase getWritableDatabase() { } return database; } catch (Exception e) { + // 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; + } return null; } } 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 0000000..11afadd --- /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/Jdbc.java b/aware-core/src/main/java/com/aware/utils/Jdbc.java index 21acab4..387faa4 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 0000000..e91ba9e --- /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 0000000..4da90da --- /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 e8bf677..ebc4cd1 100644 --- a/aware-core/src/main/java/com/aware/utils/Scheduler.java +++ b/aware-core/src/main/java/com/aware/utils/Scheduler.java @@ -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 0000000..f996e8d --- /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 0000000..e871b75 --- /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 0000000..f660ef9 --- /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 0000000..b434cb4 --- /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 0000000..1d6b706 --- /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 f6a91d2..85892af 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; @@ -56,6 +63,12 @@ public class StudyUtils extends IntentService { private static final String[] REQUIRED_STUDY_CONFIG_KEYS = {"database", "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 @@ -142,7 +155,7 @@ 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(); @@ -160,7 +173,7 @@ 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. @@ -171,7 +184,6 @@ protected void onHandleIntent(Intent intent) { 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))); @@ -196,13 +208,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 +227,140 @@ 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()); + } + return Jdbc.insertDataFastFail(context, "aware_studies", + new JSONArray().put(row), 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.getSetting(context, Aware_Preferences.DEVICE_ID)); + 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()); + } + Jdbc.insertData(context, "aware_studies", new JSONArray().put(json)); + } 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 +398,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 +466,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) { @@ -307,6 +504,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 +558,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 +596,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 +673,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 +688,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 +704,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 +758,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 +780,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 +1131,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 +1216,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 +1378,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 +1630,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 +1658,536 @@ 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; + } + + 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; + 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; } /** - * 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 0000000..d63c59d --- /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/UploadHealth.java b/aware-core/src/main/java/com/aware/utils/UploadHealth.java new file mode 100644 index 0000000..d1096b8 --- /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/res/values/arrays.xml b/aware-core/src/main/res/values/arrays.xml index 498ffdb..d15faea 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 68f5a26..d2bbf41 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,6 +24,13 @@ 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 @@ -40,7 +49,7 @@ Sign Up! Install AWARE - Data collection active + Collecting study data in the background Sync ScreenText @@ -77,4 +86,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 c4728de..8844bd7 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 0000000..6fc12e6 --- /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 0000000..9c4b75a --- /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 0000000..2af4765 --- /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 0000000..ac90583 --- /dev/null +++ b/aware-core/src/test/java/com/aware/providers/AwareSchemaTest.java @@ -0,0 +1,92 @@ +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 theDeviceTableCarriesNoColumnThatReportsNothing() { + // 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", "label"}) { + assertFalse(column + " is declared in aware_device again", + schema.contains(column + " 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 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/ui/PermissionSequenceTest.java b/aware-core/src/test/java/com/aware/ui/PermissionSequenceTest.java new file mode 100644 index 0000000..26ac9c1 --- /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 0000000..a878a7c --- /dev/null +++ b/aware-core/src/test/java/com/aware/utils/DatabaseHelperColumnsTest.java @@ -0,0 +1,135 @@ +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.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 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/DeviceFactsTest.java b/aware-core/src/test/java/com/aware/utils/DeviceFactsTest.java new file mode 100644 index 0000000..0731db8 --- /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/JdbcBreakerTest.java b/aware-core/src/test/java/com/aware/utils/JdbcBreakerTest.java new file mode 100644 index 0000000..adda575 --- /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 0000000..ad3850a --- /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 0000000..efb590a --- /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 0000000..f3c44a6 --- /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 0000000..fe48b1a --- /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 0000000..030a943 --- /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 0000000..8c8f97a --- /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 0000000..a38a695 --- /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 0000000..06c7813 --- /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/StudyUtilsTest.java b/aware-core/src/test/java/com/aware/utils/StudyUtilsTest.java new file mode 100644 index 0000000..c61e0d4 --- /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 0000000..3d16d77 --- /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/UploadHealthTest.java b/aware-core/src/test/java/com/aware/utils/UploadHealthTest.java new file mode 100644 index 0000000..1f484f5 --- /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-phone/build.gradle b/aware-phone/build.gradle index f58e7d8..61df7c4 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 2bf7f46..12432a2 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"> + + @@ -162,14 +169,15 @@ 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 5db3cab..41515fb 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; } @@ -322,6 +326,10 @@ 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 } @@ -332,9 +340,11 @@ protected void onResume() { 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) { @@ -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 7c08d6a..286ca09 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; } @@ -769,6 +2037,12 @@ 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 } @@ -779,9 +2053,11 @@ protected void onResume() { 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 aa3b5c1..f7542f1 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,20 @@ 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; private JSONArray study_configs; private static String input_password; + private boolean consentDone; @@ -100,6 +110,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(); @@ -119,7 +130,8 @@ public void afterTextChanged(Editable s) { } 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 +176,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,7 +243,7 @@ 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()); @@ -190,7 +252,6 @@ public void onClick(View view) { 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 +273,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_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,13 +295,14 @@ 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()); @@ -249,7 +311,6 @@ public void onClick(DialogInterface dialogInterface, int i) { 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,7 +408,7 @@ 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(); @@ -364,7 +425,7 @@ 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 @@ -384,7 +445,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 +467,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 +497,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 +532,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; } @@ -554,6 +626,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 94dd799..b7e11e2 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 @@ -27,6 +27,7 @@ import com.aware.providers.Aware_Provider; import com.aware.utils.Http; import com.aware.utils.Https; +import com.aware.utils.LogRedactor; import com.aware.utils.SSLManager; import org.json.JSONArray; @@ -95,7 +96,8 @@ protected void onDestroy() { @Override public void handleResult(Result result) { Log.d(Aware.TAG, "QR Code result: " + result.getContents()); - new StudyData().execute(result.getContents()); + new StudyData().executeOnExecutor( + AsyncTask.THREAD_POOL_EXECUTOR, result.getContents()); } /** @@ -146,9 +148,10 @@ protected JSONObject doInBackground(String... params) { //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 - } + // handleUrl(..., true) already blocks until the download attempt completes. + // An unreachable host must fail the join instead of spinning forever. + if (!SSLManager.hasCertificate(getApplicationContext(), study_uri.getHost())) + return null; 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); @@ -245,7 +248,7 @@ public void onClick(DialogInterface dialog, int which) { Cursor dbStudy = Aware.getStudy(getApplicationContext(), study_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(); 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 0000000..ec8094d --- /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 out = new ArrayList<>(); + for (ConsentItem item : src) out.add(SensorCollection.rowFor(item)); + return out; + } + + private void enableAccessibility(ConsentItem item) { + // The accessibility service is a single OS toggle shared by every accessibility-backed sensor + // (Applications usage, Keyboard, Screenshots). If it's already on — e.g. the participant just + // enabled it for another such row — this row's requirement is already met, so tapping Enable + // is itself the consent; don't send them back to Accessibility settings. + if (SensorCollection.isAccessibilityServiceEnabled(getApplicationContext())) { + consentedKeys.add(item.key); + buildRows(); + return; + } + pendingAccessibilityKey = item.key; + if (Build.VERSION.SDK_INT >= 33 && !shownRestrictedSettingsHelp) { + showRestrictedSettingsDialog(); + } else { + startActivity(new Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)); + } + } + + private void showRestrictedSettingsDialog() { + new AlertDialog.Builder(this) + .setTitle("One extra step needed") + .setMessage("Because AWARE wasn't installed from the Play Store, Android blocks " + + "Accessibility access until you allow it manually:\n\n" + + "1. On the App info screen that opens, tap the ⋮ menu (top-right) and " + + "choose \"Allow restricted settings\".\n" + + "2. Come back here and tap Enable again to open Accessibility Settings and " + + "turn AWARE on.") + .setPositiveButton("Open App Info", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + shownRestrictedSettingsHelp = true; + startActivity(new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse("package:" + getPackageName()))); + } + }) + .setNegativeButton("Cancel", null) + .show(); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + // Judge the row from the live permission state (not the raw grantResults array), so a row is + // marked consented once its required permissions are actually held — tolerating hard-restricted + // ones the platform won't grant, and not depending on the callback array's exact shape. + if (requestCode >= 0 && requestCode < items.size() + && SensorCollection.isAlreadyGranted(getApplicationContext(), items.get(requestCode))) { + ConsentItem granted = items.get(requestCode); + consentedKeys.add(granted.key); + // Location just went from off to on, but the foreground grant alone stops logging whenever + // AWARE isn't open. Nudge the participant to switch it to "Allow all the time" for complete + // location data, unless they already have it. + if ("locations".equals(granted.key) + && !SensorCollection.hasBackgroundLocation(getApplicationContext()) + && !shownAlwaysLocationHelp) { + promptAlwaysLocation(); + } + } + buildRows(); + } + + private void promptAlwaysLocation() { + shownAlwaysLocationHelp = true; + 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(); + } + + @Override + protected void onResume() { + super.onResume(); + // Accessibility grants happen in the Settings app, not this Activity, so there's no + // onRequestPermissionsResult callback for them — re-check on every return to this screen, + // attributing the enabled service only to the row whose Enable button was actually tapped. + if (pendingAccessibilityKey != null && SensorCollection.isAccessibilityServiceEnabled(getApplicationContext())) { + consentedKeys.add(pendingAccessibilityKey); + pendingAccessibilityKey = null; + } + if (items != null) buildRows(); + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + outState.putStringArrayList(STATE_CONSENTED_KEYS, new ArrayList<>(consentedKeys)); + outState.putString(STATE_PENDING_ACCESSIBILITY_KEY, pendingAccessibilityKey); + outState.putBoolean(STATE_SHOWN_RESTRICTED_HELP, shownRestrictedSettingsHelp); + outState.putBoolean(STATE_SHOWN_ALWAYS_LOCATION_HELP, shownAlwaysLocationHelp); + } + + private void restoreInstanceState(Bundle state) { + ArrayList saved = state.getStringArrayList(STATE_CONSENTED_KEYS); + if (saved != null) consentedKeys.addAll(saved); + pendingAccessibilityKey = state.getString(STATE_PENDING_ACCESSIBILITY_KEY); + shownRestrictedSettingsHelp = state.getBoolean(STATE_SHOWN_RESTRICTED_HELP); + shownAlwaysLocationHelp = state.getBoolean(STATE_SHOWN_ALWAYS_LOCATION_HELP); + } + + /** + * Confirm the participant's choices and apply them. Produces the final set of sensors to keep + * OFF and hands it to {@link ApplySettingsAsync}: + * 1. begin with the sensors already declined (persisted); + * 2. for each row shown, drop it from that set if the participant enabled it, or add it if they + * left it off; + * 3. log the enabled/declined lists to the researcher-visible audit trail, then apply. + * On a fresh join the starting set is empty, so this is simply "everything not enabled is off" + * (including tapping Continue with nothing enabled). In update mode only the newly-added rows are + * shown, so earlier declines not on screen are carried through untouched. + */ + private void onContinue() { + Set declined = new HashSet<>(); + for (String key : Aware.getSetting(getApplicationContext(), Aware_Preferences.STUDY_DECLINED_SENSORS).split(",")) { + if (key.trim().length() > 0) declined.add(key.trim()); + } + for (ConsentItem item : items) { + List controlled = SensorCollection.controlledSettings(item); + if (consentedKeys.contains(item.key)) { + declined.removeAll(controlled); + } else { + declined.addAll(controlled); + } + } + Aware.logStudyCompliance(getApplicationContext(), + (updateMode ? "consent given (study update): " : "consent given: ") + + SensorCollection.consentStateSummary(studyConfigs, declined)); + + if (preJoin) { + // Pre-join: record the participant's choices now, before enrolment. applySettings (run + // later, when they sign up) preserves both across its internal reset and forces the + // declined sensors off; the config-sync drift check reads the declined set to keep them + // off. Then hand off to the identifier / sign-up step — do NOT apply or enrol here. + Aware.setSetting(getApplicationContext(), Aware_Preferences.STUDY_DECLINED_SENSORS, + TextUtils.join(",", declined)); + Aware.setSetting(getApplicationContext(), Aware_Preferences.STUDY_CONSENT_RECORD, + SensorCollection.consentFingerprint(studyUrl, studyConfigs)); + forwardToJoinStudy(); + return; + } + applyAndFinish(declined); + } + + /** Hand off to the identifier / sign-up step after pre-join consent, flagged so it won't re-ask. */ + private void forwardToJoinStudy() { + Intent join = new Intent(getApplicationContext(), Aware_Join_Study.class); + join.putExtra(Aware_Join_Study.EXTRA_STUDY_URL, studyUrl); + try { + if (studyConfigs.length() > 0) { + join.putExtra(Aware_Join_Study.EXTRA_STUDY_CONFIG, studyConfigs.getJSONObject(0).toString()); + } + } catch (JSONException e) { + e.printStackTrace(); + } + join.putExtra(Aware_Join_Study.INPUT_PASSWORD, inputPassword); + join.putExtra(Aware_Join_Study.EXTRA_CONSENT_DONE, true); + join.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + startActivity(join); + finish(); + } + + private void applyAndFinish(Set declinedSettings) { + // Consent may coexist with a long-running legacy AsyncTask (notably an ESM expiration + // monitor). Joining must not wait on Android's process-wide serial AsyncTask queue. + new ApplySettingsAsync(declinedSettings) + .executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + private void goToMainUi() { + Intent mainUI = new Intent(getApplicationContext(), Aware_Client.class); + mainUI.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + startActivity(mainUI); + finish(); + } + + /** The URL of the study the device is currently enrolled in, or "" if none. */ + private String activeStudyUrl() { + String url = ""; + Cursor study = Aware.getActiveStudy(getApplicationContext()); + if (study != null && study.moveToFirst()) { + url = study.getString(study.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_URL)); + } + if (study != null && !study.isClosed()) study.close(); + return url; + } + + /** + * Leave this screen without agreeing (the "Don't join" / "Not now" button and the Back button). + * Fresh join: the participant is enrolled but hasn't consented, so undo the enrolment — mark the + * study row exited, which makes {@link Aware#isStudy} false — and return to the app not enrolled. + * This is what prevents backing out from leaving a half-joined study the config sync would later + * turn into a full, unconsented enable. Update mode: the participant is already a member and only + * skipped the researcher's added sensors, so nothing is rolled back — the added sensors stay off + * and they go back to the app. Either way the decision is logged to the audit trail. + */ + private void cancelJoin() { + if (updateMode) { + Aware.logStudyCompliance(getApplicationContext(), "study update: added sensors left off"); + goToMainUi(); + return; + } + + if (preJoin) { + // Not enrolled yet — enrolment happens at sign-up, after this screen — so there is nothing + // to roll back. Just leave the flow without joining. + goToMainUi(); + return; + } + + Aware.logStudyCompliance(getApplicationContext(), "consent declined — did not join"); + + Cursor study = Aware.getStudy(getApplicationContext(), studyUrl); + if (study != null && study.moveToFirst()) { + long id = study.getLong(study.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_ID)); + ContentValues values = new ContentValues(); + values.put(Aware_Provider.Aware_Studies.STUDY_EXIT, System.currentTimeMillis()); + getContentResolver().update(Aware_Provider.Aware_Studies.CONTENT_URI, values, + Aware_Provider.Aware_Studies.STUDY_ID + "=" + id, null); + } + if (study != null && !study.isClosed()) study.close(); + + goToMainUi(); + } + + @Override + public void onBackPressed() { + cancelJoin(); + } + + private class ApplySettingsAsync extends AsyncTask { + private final Set declinedSettings; + private ProgressDialog mLoading; + + ApplySettingsAsync(Set declinedSettings) { + this.declinedSettings = declinedSettings; + } + + @Override + protected void onPreExecute() { + if (isFinishing()) return; + mLoading = new ProgressDialog(SensorConsentActivity.this); + mLoading.setMessage("Joining study, please wait."); + mLoading.setCancelable(false); + mLoading.show(); + } + + @Override + protected Void doInBackground(Void... params) { + // Persist the declined set BEFORE applying: applySettings preserves this across its + // internal reset and forces these sensors off, and the config sync's drift check reads + // it to keep them off — so the decline survives past the first poll. + Aware.setSetting(getApplicationContext(), Aware_Preferences.STUDY_DECLINED_SENSORS, + TextUtils.join(",", declinedSettings)); + StudyUtils.applySettings(getApplicationContext(), studyUrl, studyConfigs, false, inputPassword, declinedSettings); + // Record what was agreed to (study + its promptable-sensor set). applySettings preserves + // it across its internal reset; quitting the study clears it via Aware.reset(), which is + // what forces the consent screen to show again on a re-join. + Aware.setSetting(getApplicationContext(), Aware_Preferences.STUDY_CONSENT_RECORD, + SensorCollection.consentFingerprint(studyUrl, studyConfigs)); + return null; + } + + @Override + protected void onPostExecute(Void aVoid) { + dismissLoading(); + // The study is applied by this point regardless of this screen's state. Only navigate + // while this screen is still the current one. + if (isFinishing()) return; + Intent mainUI = new Intent(getApplicationContext(), Aware_Client.class); + mainUI.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + startActivity(mainUI); + finish(); + } + + /** + * Dismisses the progress dialog if it is still attached. The dialog belongs to the Activity + * that started this task, which can be destroyed while the study is being applied; + * dismissing a window whose Activity is gone throws IllegalArgumentException. + */ + private void dismissLoading() { + if (mLoading == null || !mLoading.isShowing()) return; + try { + mLoading.dismiss(); + } catch (IllegalArgumentException e) { + Log.d(Aware.TAG, "Consent loader's window was already gone: " + e.getMessage()); + } + mLoading = null; + } + } +} diff --git a/aware-phone/src/main/java/com/aware/phone/ui/Services/ScreenshotCaptureService.java b/aware-phone/src/main/java/com/aware/phone/ui/Services/ScreenshotCaptureService.java index 9d316bc..60c0b82 100644 --- a/aware-phone/src/main/java/com/aware/phone/ui/Services/ScreenshotCaptureService.java +++ b/aware-phone/src/main/java/com/aware/phone/ui/Services/ScreenshotCaptureService.java @@ -62,6 +62,7 @@ public class ScreenshotCaptureService extends Service { private boolean isScreenOff = false; private long lastCaptureTime = 0; private int capture_delay = 3000; + private boolean resourcesCleaned = true; private BroadcastReceiver screenStateReceiver = new BroadcastReceiver() { @Override @@ -97,15 +98,24 @@ public int onStartCommand(Intent intent, int flags, int startId) { stopSelf(); return START_NOT_STICKY; } + if (intent == null) { + stopSelf(startId); + return START_NOT_STICKY; + } int resultCode = intent.getIntExtra(MEDIA_PROJECTION_RESULT_CODE, Activity.RESULT_CANCELED); Intent data = intent.getParcelableExtra(MEDIA_PROJECTION_RESULT_DATA); - compressionRate = intent.getIntExtra("COMPRESSION_RATE", 20); + compressionRate = Math.max(0, Math.min(100, + intent.getIntExtra("COMPRESSION_RATE", 20))); if (resultCode != Activity.RESULT_CANCELED && data != null) { - startForegroundService(resultCode, data); + if (mediaProjection == null || virtualDisplay == null) { + startForegroundService(resultCode, data); + } } else { Log.e(TAG, "Invalid result code or data"); + stopSelf(startId); + return START_NOT_STICKY; } return START_STICKY; @@ -147,6 +157,7 @@ private void registerScreenStateReceiver() { * @param data The intent data from the media projection request. */ private void startForegroundService(int resultCode, Intent data) { + resourcesCleaned = false; Intent stopSelf = new Intent(this, ScreenshotCaptureService.class); stopSelf.setAction(ACTION_STOP_CAPTURE); PendingIntent pStopSelf = PendingIntent.getService(this, 0, stopSelf, PendingIntent.FLAG_CANCEL_CURRENT); @@ -206,6 +217,7 @@ public void onStop() { * Runnable task that captures the screen image at regular intervals. */ private final Runnable captureRunnable = new Runnable() { + private static final int MAX_RETRY_COUNT = 5; private int retryCount = 0; @Override public void run() { @@ -224,6 +236,11 @@ public void run() { Log.e(TAG, "Failed to capture image: image is null"); resetImageReader(); retryCount++; + if (retryCount > MAX_RETRY_COUNT) { + Log.e(TAG, "Capture retry limit reached; stopping"); + stopSelf(); + return; + } int retryDelay = Math.min(capture_delay, retryCount * 100); // Backoff strategy: max 5 seconds handler.postDelayed(this, retryDelay); // Retry after a delay } @@ -320,20 +337,28 @@ private void startCapturing() { * Cleans up resources when the service is stopped or media projection is stopped. */ 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; + } + MediaProjection projection = mediaProjection; + mediaProjection = null; + if (projection != null) { + projection.stop(); } - if (mediaProjection != null) { - mediaProjection.stop(); + if (imageReader != null) { + imageReader.close(); + imageReader = null; } + handler = null; } @Override diff --git a/aware-phone/src/main/java/com/aware/phone/ui/dialogs/JoinStudyDialog.java b/aware-phone/src/main/java/com/aware/phone/ui/dialogs/JoinStudyDialog.java index d864011..82f500d 100644 --- a/aware-phone/src/main/java/com/aware/phone/ui/dialogs/JoinStudyDialog.java +++ b/aware-phone/src/main/java/com/aware/phone/ui/dialogs/JoinStudyDialog.java @@ -20,11 +20,15 @@ import com.aware.Aware_Preferences; import com.aware.phone.R; import com.aware.phone.ui.Aware_Join_Study; +import com.aware.phone.ui.SensorConsentActivity; import com.aware.utils.StudyUtils; import org.json.JSONException; import org.json.JSONObject; +import java.net.URI; +import java.net.URISyntaxException; + /** * Manages dialog that is used to join a study by typing in a URL of the study config. @@ -33,24 +37,41 @@ public class JoinStudyDialog extends DialogFragment { private static final String TAG = "AWARE::JoinStudyDialog"; private Activity mActivity; private ProgressBar mProgressBar; + private String prefillUrl; public JoinStudyDialog(Activity activity) { this.mActivity = activity; } + /** + * Pre-fills the study URL field. Used to re-join a previously joined study: the dialog is + * still shown (and thus attached), so the normal, working join path runs on confirm. + */ + public JoinStudyDialog setStudyUrl(String url) { + this.prefillUrl = url; + return this; + } + @Override public Dialog onCreateDialog(Bundle savedInstanceState) { AlertDialog.Builder builder = new AlertDialog.Builder(mActivity); LayoutInflater inflater = mActivity.getLayoutInflater(); final View dialogView = inflater.inflate(R.layout.dialog_join_study, null); + if (prefillUrl != null && prefillUrl.length() > 0) { + EditText etPrefill = dialogView.findViewById(R.id.et_join_study_url); + etPrefill.setText(prefillUrl); + } + builder.setView(dialogView); builder.setTitle("Enter URL for study") .setPositiveButton("Join", new DialogInterface.OnClickListener() { public void onClick(DialogInterface dialog, int id) { EditText etStudyConfigUrl = dialogView.findViewById(R.id.et_join_study_url); EditText dbPassword = dialogView.findViewById(R.id.db_password); // manually input password - new ValidateStudyConfig().execute(etStudyConfigUrl.getText().toString(), dbPassword.getText().toString()); + validateStudy(new ValidationRequest( + etStudyConfigUrl.getText().toString(), + dbPassword.getText().toString())); } }) .setNegativeButton("Cancel", new DialogInterface.OnClickListener() { @@ -65,83 +86,205 @@ public void showDialog() { this.show(mActivity.getFragmentManager(), "dialog"); } + /** + * Everything {@link ValidateStudyConfig} needs to validate one study, in a single object. + * + * Replaces the old {@code String...} varargs contract: the deeplink and QR-code entry points + * started the task with the URL alone while the task unconditionally read {@code strings[1]} for + * the password, so those paths died with {@code ArrayIndexOutOfBoundsException}. A participant + * who supplied no password is now represented explicitly by an empty string, never by a missing + * array element, and there is no constructor that lets a caller omit either field. + */ + public static final class ValidationRequest { + private final String studyUrl; + private final String inputPassword; + + public ValidationRequest(String studyUrl, String inputPassword) { + this.studyUrl = studyUrl == null ? "" : studyUrl.trim(); + this.inputPassword = inputPassword == null ? "" : inputPassword; + } + } + + /** + * Starts validation for a study whose config has not been fetched yet and for which the + * participant has supplied no password — the {@code aware://} deeplink and QR-code entry points. + */ public void validateStudy(String studyUrl) { - new ValidateStudyConfig().execute(studyUrl); + validateStudy(new ValidationRequest(studyUrl, "")); + } + + public void validateStudy(ValidationRequest request) { + new ValidateStudyConfig(request).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } + + /** + * Accepts an http or https URL with a host — which is exactly what {@link + * StudyUtils#getStudyConfig} can fetch, since OkHttp's {@code Request.Builder#url} rejects every + * other scheme. So this narrows nothing: it only converts crashes into messages. A null or blank + * URL (reachable whenever the launching intent carried no study URL) would NPE inside + * {@code getStudyConfig}, and a scheme-less string would throw {@code IllegalArgumentException} + * out of OkHttp. {@code aware://} links are rewritten to {@code https://} by + * {@code Aware_Join_Study} before they reach here. + * + * Uses {@code java.net.URI} rather than {@code android.net.Uri} so it stays unit-testable, and + * reads the authority rather than the host because {@code URI} reports a null host for + * hostnames containing an underscore, which internal study servers do use. + */ + static boolean isValidStudyUrl(String url) { + if (url == null || url.trim().isEmpty()) return false; + try { + URI uri = new URI(url.trim()); + String scheme = uri.getScheme(); + if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) return false; + String authority = uri.getAuthority(); + return authority != null && !authority.isEmpty(); + } catch (URISyntaxException e) { + return false; + } + } + + /** One validation attempt's outcome; {@code studyConfig} is set only when the result is OK. */ + private static final class Outcome { + final StudyUtils.StudyConfigValidation result; + final String studyConfig; + + Outcome(StudyUtils.StudyConfigValidation result, String studyConfig) { + this.result = result; + this.studyConfig = studyConfig; + } + + static Outcome of(StudyUtils.StudyConfigValidation result) { + return new Outcome(result, null); + } } - public class ValidateStudyConfig extends AsyncTask { + public class ValidateStudyConfig extends AsyncTask { + private final ValidationRequest request; private ProgressDialog mLoader; - private String url; - private String input_password; - private Boolean validPassword; - private Boolean validURL; + + public ValidateStudyConfig(ValidationRequest request) { + if (request == null) throw new IllegalArgumentException("Validation request is required"); + this.request = request; + } @Override protected void onPreExecute() { + if (mActivity == null || mActivity.isFinishing()) return; + // mActivity.getString, not Fragment#getResources(): the deeplink and QR-code entry points + // run this task from a JoinStudyDialog that is never shown, so the fragment has no host + // and getResources() would throw IllegalStateException before validation even starts. mLoader = new ProgressDialog(mActivity); mLoader.setTitle(R.string.loading_join_study_title); - mLoader.setMessage(getResources().getString(R.string.loading_join_study_msg)); + mLoader.setMessage(mActivity.getString(R.string.loading_join_study_msg)); mLoader.setCancelable(true); mLoader.setIndeterminate(true); mLoader.show(); } + /** No UI work here — this runs off the main thread. Results are typed, never a bare null. */ @Override - protected String doInBackground(String... strings) { - Log.i(TAG, "Joining study with URL " + url); - - JSONObject studyConfig; - - url = strings[0]; - input_password = strings[1]; + protected Outcome doInBackground(Void... unused) { + if (!isValidStudyUrl(request.studyUrl)) { + Log.d(TAG, "Rejected study URL before fetching: missing or malformed"); + return Outcome.of(StudyUtils.StudyConfigValidation.INVALID_CONFIG); + } + Log.i(TAG, "Joining study with URL " + request.studyUrl); try { - studyConfig = StudyUtils.getStudyConfig(url); - JoinStudyDialog.this.dismiss(); - - if (studyConfig == null){ - validURL = false; - }else{ - validURL = true; + JSONObject studyConfig = StudyUtils.getStudyConfig(request.studyUrl); + if (studyConfig == null) { + Log.d(TAG, "No usable study config at: " + request.studyUrl); + return Outcome.of(StudyUtils.StudyConfigValidation.INVALID_CONFIG); } - validPassword = StudyUtils.validateStudyConfig(mActivity, studyConfig, input_password); - - if (studyConfig == null || !validPassword) { - Log.d(TAG, "Failed to join study with URL: " + url); - } else { - return studyConfig.toString(); + // The application context, not mActivity: validation probes the database, and the + // trust store it reads for that outlives whichever screen started the join. + StudyUtils.StudyConfigValidation result = StudyUtils.validateStudyConfigDetailed( + mActivity.getApplicationContext(), studyConfig, request.inputPassword); + if (result != StudyUtils.StudyConfigValidation.OK) { + Log.d(TAG, "Failed to join study with URL: " + request.studyUrl + + ", reason: " + result); + return Outcome.of(result); } + return new Outcome(result, studyConfig.toString()); } catch (JSONException e) { - Log.d(TAG, "Failed to join study with URL: " + url + ", reason: " + e.getMessage()); + Log.d(TAG, "Failed to join study with URL: " + request.studyUrl + + ", reason: " + e.getMessage()); + return Outcome.of(StudyUtils.StudyConfigValidation.INVALID_CONFIG); + } catch (Exception e) { + // A controlled failure rather than a crash on the participant's screen. The message + // is a diagnostic only; the request's password is never part of it. + Log.e(TAG, "Unexpected failure validating study config: " + e.getClass().getName() + + ": " + e.getMessage()); + return Outcome.of(StudyUtils.StudyConfigValidation.UNREACHABLE); } - return null; } @Override - protected void onPostExecute(String studyConfig) { - mLoader.dismiss(); - JoinStudyDialog.this.dismiss(); - Log.d(TAG, "URL: " + validURL); - if (validURL == null || !validURL) { - Toast.makeText(mActivity, "Invalid study config or no internet. Please contact the " + - "administrator of this study or enter a different study URL.", - Toast.LENGTH_LONG).show(); - } - else if (!validPassword){ - Toast.makeText(mActivity, "Password not correct", - Toast.LENGTH_LONG).show(); + protected void onPostExecute(Outcome outcome) { + dismissLoader(); + if (isAdded()) JoinStudyDialog.this.dismiss(); + if (mActivity == null || mActivity.isFinishing()) return; + + Log.d(TAG, "Study validation result: " + outcome.result); + switch (outcome.result) { + case OK: + // Show the sensor consent screen first (it reviews what the study collects and + // collects any permission grants), then it hands off to the identifier / sign-up + // step. Runs before enrolment — nothing is applied until the participant signs up. + Intent consent = new Intent(mActivity, SensorConsentActivity.class); + consent.putExtra(Aware_Join_Study.EXTRA_STUDY_URL, request.studyUrl); + consent.putExtra(Aware_Join_Study.EXTRA_STUDY_CONFIG, outcome.studyConfig); + consent.putExtra(Aware_Join_Study.INPUT_PASSWORD, request.inputPassword); + consent.putExtra(SensorConsentActivity.EXTRA_PRE_JOIN, true); + consent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK); + mActivity.startActivity(consent); + break; + + case PASSWORD_REQUIRED: + // Reached from the entry points that have no password field: return the + // participant to an interactive screen instead of a dead end, with the URL + // already filled in so they only have to type the password. + Toast.makeText(mActivity, "This study needs a password. Please enter the " + + "password you received from the study administrator.", + Toast.LENGTH_LONG).show(); + new JoinStudyDialog(mActivity).setStudyUrl(request.studyUrl).showDialog(); + break; + + case AUTH_FAILED: + Toast.makeText(mActivity, "Password not correct", Toast.LENGTH_LONG).show(); + break; + + case UNREACHABLE: + Toast.makeText(mActivity, "Could not reach the study database. Check your " + + "internet connection and try again, or contact the administrator of " + + "this study.", Toast.LENGTH_LONG).show(); + break; + + default: + Toast.makeText(mActivity, "Invalid study config or no internet. Please contact " + + "the administrator of this study or enter a different study URL.", + Toast.LENGTH_LONG).show(); + break; } - else { - - Intent studyInfo = new Intent(mActivity, Aware_Join_Study.class); - studyInfo.putExtra(Aware_Join_Study.EXTRA_STUDY_URL, url); - studyInfo.putExtra(Aware_Join_Study.EXTRA_STUDY_CONFIG, studyConfig); - studyInfo.putExtra(Aware_Join_Study.INPUT_PASSWORD, input_password); - studyInfo.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK); - mActivity.startActivity(studyInfo); + } + + /** + * The loader belongs to an Activity that may already be gone — dismissing a dialog whose + * window has been torn down throws {@code IllegalArgumentException: View not attached to + * window manager}. The guards keep the common cases quiet; the catch covers Activity + * recreation, where {@code isFinishing()} is false but the window is already detached. The + * real fix is to move progress into the Activity's own layout (plan item 2). + */ + private void dismissLoader() { + if (mLoader == null || !mLoader.isShowing()) return; + try { + mLoader.dismiss(); + } catch (IllegalArgumentException e) { + Log.d(TAG, "Loader's window was already gone: " + e.getMessage()); } + mLoader = null; } } } diff --git a/aware-phone/src/main/java/com/aware/phone/ui/dialogs/QuitStudyDialog.java b/aware-phone/src/main/java/com/aware/phone/ui/dialogs/QuitStudyDialog.java index 779ad2d..f474276 100644 --- a/aware-phone/src/main/java/com/aware/phone/ui/dialogs/QuitStudyDialog.java +++ b/aware-phone/src/main/java/com/aware/phone/ui/dialogs/QuitStudyDialog.java @@ -14,13 +14,14 @@ import android.os.Bundle; import android.util.Log; import android.widget.ProgressBar; +import android.widget.Toast; import com.aware.Aware; import com.aware.Aware_Preferences; import com.aware.ScreenShot; import com.aware.phone.ui.Aware_Client; import com.aware.providers.Aware_Provider; - +import com.aware.utils.StudyUtils; /** * Manages dialog that is used to quit a study. @@ -29,6 +30,7 @@ public class QuitStudyDialog extends DialogFragment { private static final String TAG = "AWARE::QuitStudyDialog"; private Activity mActivity; private ProgressBar mProgressBar; + private ContentValues mStudyExitEntry; public QuitStudyDialog(Activity activity) { this.mActivity = activity; @@ -37,41 +39,35 @@ public QuitStudyDialog(Activity activity) { @Override public Dialog onCreateDialog(Bundle savedInstanceState) { AlertDialog.Builder builder = new AlertDialog.Builder(mActivity); - builder.setTitle("Quit Study") - .setMessage("Are you sure you want to quit the study?") + builder.setTitle("Leave this study?") + .setMessage("Leaving stops this study's data collection and removes its settings " + + "from this device. Data already uploaded is not deleted.\n\n" + + "Are you sure you want to leave?") .setCancelable(true) - .setPositiveButton("Yes", new DialogInterface.OnClickListener() { + .setPositiveButton("Leave study", new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialogInterface, int i) { - Cursor dbStudy = Aware.getStudy(mActivity, Aware.getSetting(mActivity, Aware_Preferences.WEBSERVICE_SERVER)); + Cursor dbStudy = Aware.getActiveStudy(mActivity); 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(mActivity, Aware_Preferences.DEVICE_ID)); - 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))); - complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_DESCRIPTION, dbStudy.getString(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_DESCRIPTION))); - complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_COMPLIANCE, "quit study"); - - mActivity.getContentResolver().insert(Aware_Provider.Aware_Studies.CONTENT_URI, complianceEntry); + mStudyExitEntry = createStudyExitEntry(dbStudy); } if (dbStudy != null && !dbStudy.isClosed()) dbStudy.close(); dialogInterface.dismiss(); - new QuitStudyAsync().execute(); + if (mStudyExitEntry != null) { + // Leaving a study is also completion-critical UI work; do not queue it + // behind long-lived ESM timers on AsyncTask's serial executor. + new QuitStudyAsync().executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + } else { + showLeaveFailedDialog(); + } } }) - .setNegativeButton("No", new DialogInterface.OnClickListener() { + .setNegativeButton("Stay in study", new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialogInterface, int i) { - Cursor dbStudy = Aware.getStudy(mActivity, Aware.getSetting(mActivity, Aware_Preferences.WEBSERVICE_SERVER)); + Cursor dbStudy = Aware.getActiveStudy(mActivity); if (dbStudy != null && dbStudy.moveToFirst()) { ContentValues complianceEntry = new ContentValues(); complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_TIMESTAMP, System.currentTimeMillis()); @@ -80,7 +76,6 @@ public void onClick(DialogInterface dialogInterface, int i) { 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))); @@ -99,6 +94,11 @@ public void onClick(DialogInterface dialogInterface, int i) { @Override public void onDismiss(DialogInterface dialog) { + // A confirmed leave uses the targeted, acknowledged upload below. Starting the full + // provider sync at the same time would compete for the JDBC connection and make leaving + // slow again. + if (mStudyExitEntry != null) return; + // Sync to server the studies statuses Bundle sync = new Bundle(); sync.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true); @@ -110,10 +110,9 @@ public void onDismiss(DialogInterface dialog) { * Store information on attempt to quit study and then show the dialog to confirm the quit. */ public void showDialog() { - String study_url = Aware.getSetting(mActivity, Aware_Preferences.WEBSERVICE_SERVER); - Log.i(TAG, "Quitting from study with URL: " + study_url); + Log.i(TAG, "Quitting from active study"); - Cursor dbStudy = Aware.getStudy(mActivity, study_url); + Cursor dbStudy = Aware.getActiveStudy(mActivity); if (dbStudy != null && dbStudy.moveToFirst()) { ContentValues complianceEntry = new ContentValues(); complianceEntry.put(Aware_Provider.Aware_Studies.STUDY_TIMESTAMP, System.currentTimeMillis()); @@ -122,7 +121,6 @@ public void showDialog() { 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))); @@ -135,7 +133,7 @@ public void showDialog() { this.show(mActivity.getFragmentManager(), "dialog"); } - private class QuitStudyAsync extends AsyncTask { + private class QuitStudyAsync extends AsyncTask { ProgressDialog mQuitting; @Override @@ -147,34 +145,47 @@ protected void onPreExecute() { mQuitting.setCancelable(false); mQuitting.setInverseBackgroundForced(false); mQuitting.show(); - mQuitting.setOnDismissListener(new DialogInterface.OnDismissListener() { - @Override - public void onDismiss(DialogInterface dialogInterface) { - mActivity.finish(); - - // Redirect the user to the main UI - Intent mainUI = new Intent(mActivity, Aware_Client.class); - mainUI.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); - startActivity(mainUI); - } - }); } @Override - protected Void doInBackground(Void... params) { - // Stop the screenshot service + protected Boolean 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, even when the research + // database is temporarily down or gone for good. The exit is recorded locally either + // way; the compliance value records whether the researcher could be notified. + boolean notified = StudyUtils.uploadStudyExit( + mActivity.getApplicationContext(), mStudyExitEntry); + if (!notified) { + mStudyExitEntry.put(Aware_Provider.Aware_Studies.STUDY_COMPLIANCE, + "quit study (server unreachable, not notified)"); + } + + mActivity.getContentResolver().insert( + Aware_Provider.Aware_Studies.CONTENT_URI, mStudyExitEntry); stopScreenshotService(); - - // Reset Aware settings Aware.reset(mActivity); - return null; + return notified; } @Override - protected void onPostExecute(Void aVoid) { - super.onPostExecute(aVoid); + protected void onPostExecute(Boolean notified) { + super.onPostExecute(notified); mQuitting.dismiss(); + + // Leaving always succeeds locally; only the researcher notification is best-effort. + if (!notified && mActivity != null && !mActivity.isFinishing()) { + Toast.makeText(mActivity, + "You've left the study. The researcher could not be notified " + + "(server unreachable).", + Toast.LENGTH_LONG).show(); + } + + if (mActivity == null) return; + mActivity.finish(); + Intent mainUI = new Intent(mActivity, Aware_Client.class); + mainUI.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + startActivity(mainUI); } } @@ -183,4 +194,39 @@ private void stopScreenshotService() { mActivity.stopService(serviceIntent); } + private ContentValues createStudyExitEntry(Cursor dbStudy) { + ContentValues entry = new ContentValues(); + entry.put(Aware_Provider.Aware_Studies.STUDY_TIMESTAMP, System.currentTimeMillis()); + entry.put(Aware_Provider.Aware_Studies.STUDY_DEVICE_ID, + Aware.getSetting(mActivity, Aware_Preferences.DEVICE_ID)); + entry.put(Aware_Provider.Aware_Studies.STUDY_KEY, + dbStudy.getInt(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_KEY))); + entry.put(Aware_Provider.Aware_Studies.STUDY_API, + dbStudy.getString(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_API))); + entry.put(Aware_Provider.Aware_Studies.STUDY_URL, + dbStudy.getString(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_URL))); + entry.put(Aware_Provider.Aware_Studies.STUDY_PI, + dbStudy.getString(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_PI))); + entry.put(Aware_Provider.Aware_Studies.STUDY_JOINED, + dbStudy.getLong(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_JOINED))); + entry.put(Aware_Provider.Aware_Studies.STUDY_EXIT, System.currentTimeMillis()); + entry.put(Aware_Provider.Aware_Studies.STUDY_TITLE, + dbStudy.getString(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_TITLE))); + entry.put(Aware_Provider.Aware_Studies.STUDY_DESCRIPTION, + dbStudy.getString(dbStudy.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_DESCRIPTION))); + entry.put(Aware_Provider.Aware_Studies.STUDY_COMPLIANCE, "quit study"); + return entry; + } + + private void showLeaveFailedDialog() { + if (mActivity == null || mActivity.isFinishing()) return; + + new AlertDialog.Builder(mActivity) + .setTitle("Could not leave study") + .setMessage("The researcher could not be notified. Check your internet connection " + + "and try again. You are still enrolled and no study settings were removed.") + .setPositiveButton("OK", null) + .show(); + } + } diff --git a/aware-phone/src/main/java/com/aware/phone/ui/prefs/EditableListPreference.java b/aware-phone/src/main/java/com/aware/phone/ui/prefs/EditableListPreference.java new file mode 100644 index 0000000..cab8115 --- /dev/null +++ b/aware-phone/src/main/java/com/aware/phone/ui/prefs/EditableListPreference.java @@ -0,0 +1,286 @@ +package com.aware.phone.ui.prefs; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.res.TypedArray; +import android.preference.ListPreference; +import android.text.Editable; +import android.text.InputType; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.util.AttributeSet; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.aware.phone.R; +import com.aware.utils.SensorThresholds; + +/** + * A {@link ListPreference} that offers the curated preset values AND a "Custom…" entry + * which opens a numeric-input dialog, so a value can either be picked from the recommended + * options or typed by hand. + * + * The optional unit shown next to a custom value (e.g. "seconds") comes from the custom + * {@code customUnit} XML attribute. It deliberately does not use {@code android:dialogMessage}: + * legacy preference dialogs render a message instead of their choice list, which would hide every + * preset and the Custom row.

+ */ +public class EditableListPreference extends ListPreference { + + private static final String CUSTOM_LABEL = "Custom value…"; + private static final int FORMAT_STORED_VALUE = 0; + private static final int FORMAT_SAMPLING_RATE_HZ = 1; + private static final int FORMAT_SENSOR_THRESHOLD = 2; + private final String customUnit; + private final int customValueFormat; + + public EditableListPreference(Context context, AttributeSet attrs) { + super(context, attrs); + TypedArray values = context.obtainStyledAttributes( + attrs, R.styleable.EditableListPreference); + customUnit = values.getString(R.styleable.EditableListPreference_customUnit); + customValueFormat = values.getInt( + R.styleable.EditableListPreference_customValueFormat, FORMAT_STORED_VALUE); + values.recycle(); + } + + /** + * A threshold's unit comes from its setting key rather than the XML, so the unit and the + * usable range are stated in one place instead of once per sensor in two preference files. + */ + private CharSequence unit() { + if (customValueFormat == FORMAT_SENSOR_THRESHOLD) { + SensorThresholds.Spec spec = SensorThresholds.of(getKey()); + if (spec != null) return spec.unit; + } + return customUnit; + } + + @Override + protected void onPrepareDialogBuilder(AlertDialog.Builder builder) { + final CharSequence[] entries = getEntries(); + final CharSequence[] entryValues = getEntryValues(); + + // Append a trailing "Custom…" row to the recommended presets. + final CharSequence[] items = new CharSequence[entries.length + 1]; + System.arraycopy(entries, 0, items, 0, entries.length); + items[entries.length] = CUSTOM_LABEL; + + int checked = findIndexOfValue(getValue()); + if (checked < 0) checked = entries.length; // current value is a custom one + + final int customIndex = entries.length; + builder.setSingleChoiceItems(items, checked, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + if (which == customIndex) { + showCustomDialog(); + } else { + String value = entryValues[which].toString(); + if (callChangeListener(value)) setValue(value); + } + } + }); + + // Tapping a row commits immediately, so no OK button is needed (Cancel stays). + builder.setPositiveButton(null, null); + } + + private void showCustomDialog() { + final EditText input = new EditText(getContext()); + input.setInputType(customValueFormat == FORMAT_STORED_VALUE + ? InputType.TYPE_CLASS_NUMBER + : InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_DECIMAL); + if (!TextUtils.isEmpty(unit())) input.setHint(unit()); + String current = displayValue(getValue()); + if (!TextUtils.isEmpty(current)) { + input.setText(current); + input.setSelection(current.length()); + } + + AlertDialog.Builder customDialog = new AlertDialog.Builder(getContext()) + .setTitle(getDialogTitle() != null ? getDialogTitle() : getTitle()); + if (customValueFormat == FORMAT_SAMPLING_RATE_HZ) { + customDialog.setView(samplingRateInput(input)); + } else if (customValueFormat == FORMAT_SENSOR_THRESHOLD) { + customDialog.setView(thresholdInput(input)); + } else { + customDialog.setView(input); + } + + customDialog + .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + String enteredValue = input.getText().toString().trim(); + String storedValue = storedValue(enteredValue); + if (!TextUtils.isEmpty(storedValue) && callChangeListener(storedValue)) { + setValue(storedValue); + } + } + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + private LinearLayout samplingRateInput(final EditText input) { + return annotatedInput(input, "Hz means samples per second.", new Annotation() { + @Override + public void describe(TextView target, String value) { + updateSamplingRateConversion(target, value); + } + }); + } + + private LinearLayout thresholdInput(final EditText input) { + SensorThresholds.Spec spec = SensorThresholds.of(getKey()); + String intro = spec == null + ? "A reading is stored only when it changed by at least this much." + : "A reading is stored only when it differs from the last stored reading by at" + + " least this much, in " + spec.unit + "."; + return annotatedInput(input, intro, new Annotation() { + @Override + public void describe(TextView target, String value) { + updateThresholdExplanation(target, value); + } + }); + } + + /** The live line under a custom value, restating what the entered number will do. */ + private interface Annotation { + void describe(TextView target, String value); + } + + private LinearLayout annotatedInput(final EditText input, String intro, + final Annotation annotation) { + LinearLayout content = new LinearLayout(getContext()); + content.setOrientation(LinearLayout.VERTICAL); + int horizontalPadding = (int) (24 * getContext().getResources() + .getDisplayMetrics().density); + content.setPadding(horizontalPadding, 0, horizontalPadding, 0); + + TextView explanation = new TextView(getContext()); + explanation.setText(intro); + content.addView(explanation); + content.addView(input); + + final TextView annotated = new TextView(getContext()); + content.addView(annotated); + annotation.describe(annotated, input.getText().toString()); + input.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence value, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence value, int start, int before, int count) { + annotation.describe(annotated, value.toString()); + } + + @Override + public void afterTextChanged(Editable value) { + } + }); + return content; + } + + private void updateSamplingRateConversion(TextView conversion, String samplingRateHz) { + if (TextUtils.isEmpty(samplingRateHz)) { + conversion.setText("Example: 20 Hz = one sample every 50 ms (1/20 second)"); + return; + } + try { + conversion.setText(FrequencyValueConverter + .samplingRateHzDescription(samplingRateHz)); + } catch (IllegalArgumentException ignored) { + conversion.setText("Enter a sampling rate greater than 0 Hz"); + } + } + + private void updateThresholdExplanation(TextView explanation, String threshold) { + if (TextUtils.isEmpty(threshold)) { + explanation.setText("0 stores every sample, with no filtering."); + return; + } + try { + explanation.setText(SensorThresholds.explain(getKey(), Double.parseDouble(threshold))); + } catch (NumberFormatException ignored) { + explanation.setText("Enter a number."); + } + } + + /** + * The readable label for the current value: the matching preset entry, or a + * "Custom: " label when the value isn't one of the presets. Feeds both the + * {@code %s} summary and the summary refresh done in Aware_Client. + */ + @Override + public CharSequence getEntry() { + CharSequence preset = super.getEntry(); + if (preset != null) return preset; + String value = getValue(); + if (TextUtils.isEmpty(value)) return null; + value = displayValue(value); + if (TextUtils.isEmpty(value)) return null; + if (customValueFormat == FORMAT_SAMPLING_RATE_HZ) { + return "Custom: " + value + " " + unit() + " (" + + FrequencyValueConverter.samplingRateHzInterval(value) + ")"; + } + if (customValueFormat == FORMAT_SENSOR_THRESHOLD) { + // The dialog rejects an out-of-range threshold, but a study config can still push one + // — and did: a deployed config set 120 m/s². Say so here, because the summary is the + // only place that value is ever shown. + return "Custom: " + value + " " + unit() + + (isOutOfRange(value) ? " — too high, this sensor records nothing" : ""); + } + return TextUtils.isEmpty(unit()) + ? "Custom: " + value + : "Custom: " + value + " " + unit(); + } + + private boolean isOutOfRange(String threshold) { + try { + return !SensorThresholds.isWithinRange(getKey(), Double.parseDouble(threshold)); + } catch (NumberFormatException ignored) { + return false; + } + } + + private String storedValue(String displayedValue) { + if (TextUtils.isEmpty(displayedValue)) return null; + if (customValueFormat == FORMAT_SENSOR_THRESHOLD) { + // A threshold past the sensor's range filters out every sample, so it is rejected + // rather than stored: it would leave the sensor reporting itself as enabled while + // recording nothing, which is indistinguishable from the sensor being broken. + try { + double threshold = Double.parseDouble(displayedValue); + return SensorThresholds.isWithinRange(getKey(), threshold) + ? displayedValue + : null; + } catch (NumberFormatException ignored) { + return null; + } + } + if (customValueFormat != FORMAT_SAMPLING_RATE_HZ) return displayedValue; + try { + return FrequencyValueConverter.samplingRateHzToPeriodUs(displayedValue); + } catch (IllegalArgumentException ignored) { + return null; + } + } + + private String displayValue(String storedValue) { + if (TextUtils.isEmpty(storedValue) + || customValueFormat != FORMAT_SAMPLING_RATE_HZ) { + return storedValue; + } + try { + return FrequencyValueConverter.periodUsToSamplingRateHz(storedValue); + } catch (IllegalArgumentException ignored) { + return null; + } + } +} diff --git a/aware-phone/src/main/java/com/aware/phone/ui/prefs/FrequencyValueConverter.java b/aware-phone/src/main/java/com/aware/phone/ui/prefs/FrequencyValueConverter.java new file mode 100644 index 0000000..c29933b --- /dev/null +++ b/aware-phone/src/main/java/com/aware/phone/ui/prefs/FrequencyValueConverter.java @@ -0,0 +1,68 @@ +package com.aware.phone.ui.prefs; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +/** + * Converts researcher-facing sampling rates to Android's stored sampling-period representation. + */ +final class FrequencyValueConverter { + + private static final BigDecimal MICROSECONDS_PER_SECOND = new BigDecimal("1000000"); + + private FrequencyValueConverter() { + } + + static String samplingRateHzToPeriodUs(String samplingRateHz) { + BigDecimal rate = new BigDecimal(samplingRateHz); + if (rate.signum() <= 0) { + throw new IllegalArgumentException("Sampling rate must be greater than zero"); + } + + BigDecimal period = MICROSECONDS_PER_SECOND.divide(rate, 0, RoundingMode.HALF_UP); + if (period.signum() <= 0 || period.compareTo(BigDecimal.valueOf(Integer.MAX_VALUE)) > 0) { + throw new IllegalArgumentException("Sampling rate is outside the supported range"); + } + return period.toPlainString(); + } + + static String periodUsToSamplingRateHz(String samplingPeriodUs) { + BigDecimal period = new BigDecimal(samplingPeriodUs); + if (period.signum() <= 0) { + throw new IllegalArgumentException("Sampling period must be greater than zero"); + } + return MICROSECONDS_PER_SECOND.divide(period, 3, RoundingMode.HALF_UP) + .stripTrailingZeros() + .toPlainString(); + } + + static String samplingRateHzDescription(String samplingRateHz) { + String normalizedRate = new BigDecimal(samplingRateHz).stripTrailingZeros().toPlainString(); + String periodUs = samplingRateHzToPeriodUs(samplingRateHz); + return normalizedRate + " Hz = " + normalizedRate + + " samples/second = one sample every " + readablePeriod(periodUs) + + " (1/" + normalizedRate + " second)"; + } + + static String samplingRateHzInterval(String samplingRateHz) { + return "every " + readablePeriod(samplingRateHzToPeriodUs(samplingRateHz)); + } + + private static String readablePeriod(String samplingPeriodUs) { + BigDecimal microseconds = new BigDecimal(samplingPeriodUs); + if (microseconds.compareTo(new BigDecimal("1000")) < 0) { + return format(microseconds) + " µs"; + } + if (microseconds.compareTo(MICROSECONDS_PER_SECOND) < 0) { + return format(microseconds.divide(new BigDecimal("1000"), 3, RoundingMode.HALF_UP)) + + " ms"; + } + String seconds = format(microseconds.divide( + MICROSECONDS_PER_SECOND, 3, RoundingMode.HALF_UP)); + return seconds + ("1".equals(seconds) ? " second" : " seconds"); + } + + private static String format(BigDecimal value) { + return value.stripTrailingZeros().toPlainString(); + } +} diff --git a/aware-phone/src/main/java/com/aware/phone/ui/prefs/SensorCollection.java b/aware-phone/src/main/java/com/aware/phone/ui/prefs/SensorCollection.java new file mode 100644 index 0000000..e5bc953 --- /dev/null +++ b/aware-phone/src/main/java/com/aware/phone/ui/prefs/SensorCollection.java @@ -0,0 +1,1177 @@ +package com.aware.phone.ui.prefs; + +import android.Manifest; +import android.content.ComponentName; +import android.content.Context; +import android.database.Cursor; +import android.hardware.Sensor; +import android.content.pm.PackageManager; +import android.location.LocationManager; +import android.net.Uri; +import android.os.Build; +import android.provider.Settings; +import android.text.TextUtils; + +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 com.aware.utils.SensorAvailability; +import com.aware.utils.SensorFreshness; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Determines, per sensor, whether AWARE is actually collecting data on this device, and — when it + * isn't — the most likely reason. "Collecting" is grounded in the sensor provider having a recent + * row (the ground truth); if there is none, the reason is derived from preconditions (sensor + * hardware present, runtime permission granted, accessibility enabled, or simply no data yet). + * + * Used by the study sensor list to color each sensor blue (collecting) vs grey (not), and to show a + * details dialog on tap. + */ +public final class SensorCollection { + + private SensorCollection() {} + + /** Result of a collection check for one sensor. */ + public static final class Status { + public final boolean collecting; + public final boolean waitingForEvent; + public final long lastDataMs; // 0 = never any data + public final String reason; // short human-readable status + public final String fixHint; // actionable hint, or null + + Status(boolean collecting, long lastDataMs, String reason, String fixHint) { + this(collecting, false, lastDataMs, reason, fixHint); + } + + Status(boolean collecting, boolean waitingForEvent, long lastDataMs, String reason, String fixHint) { + this.collecting = collecting; + this.waitingForEvent = waitingForEvent; + this.lastDataMs = lastDataMs; + this.reason = reason; + this.fixHint = fixHint; + } + } + + /** Registry entry describing where a sensor's data lives and what it needs to collect. */ + private static final class Def { + final String authoritySuffix; // e.g. ".provider.battery" + final String table; // primary table, e.g. "battery" + final int hardwareType; // Sensor.TYPE_* for physical sensors, or -1 + final String[] permissions; // required runtime permissions (may be empty) + final boolean needsAccessibility; + final boolean needsLocationServices; // the OS-level Location toggle, not a permission + + Def(String authoritySuffix, String table, int hardwareType, String[] permissions, boolean needsAccessibility) { + this(authoritySuffix, table, hardwareType, permissions, needsAccessibility, false); + } + + Def(String authoritySuffix, String table, int hardwareType, String[] permissions, boolean needsAccessibility, boolean needsLocationServices) { + this.authoritySuffix = authoritySuffix; + this.table = table; + this.hardwareType = hardwareType; + this.permissions = permissions; + this.needsAccessibility = needsAccessibility; + this.needsLocationServices = needsLocationServices; + } + } + + private static final String[] NONE = new String[0]; + private static final Map REGISTRY = new HashMap<>(); + private static final Map STATUS_SETTINGS = new HashMap<>(); + private static final Map SAMPLED = new HashMap<>(); + + private static final class SampleDef { + final String frequencySetting; + final long defaultValue; + final SensorFreshness.Unit unit; + + SampleDef(String frequencySetting, long defaultValue, SensorFreshness.Unit unit) { + this.frequencySetting = frequencySetting; + this.defaultValue = defaultValue; + this.unit = unit; + } + } + + static { + REGISTRY.put("accelerometer", new Def(".provider.accelerometer", "accelerometer", Sensor.TYPE_ACCELEROMETER, NONE, false)); + REGISTRY.put("linear_accelerometer", new Def(".provider.accelerometer.linear", "linear_accelerometer", Sensor.TYPE_LINEAR_ACCELERATION, NONE, false)); + REGISTRY.put("significant_motion", new Def(".provider.significant", "significant", Sensor.TYPE_ACCELEROMETER, NONE, false)); + REGISTRY.put("barometer", new Def(".provider.barometer", "barometer", Sensor.TYPE_PRESSURE, NONE, false)); + REGISTRY.put("gravity", new Def(".provider.gravity", "gravity", Sensor.TYPE_GRAVITY, NONE, false)); + REGISTRY.put("gyroscope", new Def(".provider.gyroscope", "gyroscope", Sensor.TYPE_GYROSCOPE, NONE, false)); + REGISTRY.put("light", new Def(".provider.light", "light", Sensor.TYPE_LIGHT, NONE, false)); + REGISTRY.put("magnetometer", new Def(".provider.magnetometer", "magnetometer", Sensor.TYPE_MAGNETIC_FIELD, NONE, false)); + REGISTRY.put("proximity", new Def(".provider.proximity", "proximity", Sensor.TYPE_PROXIMITY, NONE, false)); + REGISTRY.put("rotation", new Def(".provider.rotation", "rotation", Sensor.TYPE_ROTATION_VECTOR, NONE, false)); + REGISTRY.put("temperature", new Def(".provider.temperature", "temperature", Sensor.TYPE_AMBIENT_TEMPERATURE, NONE, false)); + + REGISTRY.put("battery", new Def(".provider.battery", "battery", -1, NONE, false)); + REGISTRY.put("screen", new Def(".provider.screen", "screen", -1, NONE, false)); + REGISTRY.put("network", new Def(".provider.network", "network", -1, NONE, false)); + REGISTRY.put("processor", new Def(".provider.processor", "processor", -1, NONE, false)); + REGISTRY.put("timezone", new Def(".provider.timezone", "timezone", -1, NONE, false)); + REGISTRY.put("esm", new Def(".provider.esm", "esms", -1, NONE, false)); + + REGISTRY.put("locations", new Def(".provider.locations", "locations", -1, new String[]{Manifest.permission.ACCESS_FINE_LOCATION}, false)); + // WiFi scanning requires the OS-level Location toggle on system-wide, in addition to the + // ACCESS_FINE_LOCATION permission below — Android blocks WifiManager.startScan() with a + // SecurityException when Location services are off, regardless of granted permissions. + REGISTRY.put("wifi", new Def(".provider.wifi", "wifi", -1, new String[]{Manifest.permission.ACCESS_FINE_LOCATION}, false, true)); + REGISTRY.put("bluetooth", new Def(".provider.bluetooth", "bluetooth", -1, new String[]{Manifest.permission.ACCESS_FINE_LOCATION}, false)); + REGISTRY.put("communication", new Def(".provider.communication", "calls", -1, new String[]{Manifest.permission.READ_CALL_LOG}, false)); + REGISTRY.put("telephony", new Def(".provider.telephony", "telephony", -1, new String[]{Manifest.permission.READ_PHONE_STATE}, false)); + + // Accessibility-backed sensors + REGISTRY.put("applications", new Def(".provider.applications", "applications_foreground", -1, NONE, true)); + REGISTRY.put("screenshot", new Def(".provider.screenshot", "screenshot", -1, NONE, true)); + + status("accelerometer", Aware_Preferences.STATUS_ACCELEROMETER); + status("linear_accelerometer", Aware_Preferences.STATUS_LINEAR_ACCELEROMETER); + status("significant_motion", Aware_Preferences.STATUS_SIGNIFICANT_MOTION); + status("barometer", Aware_Preferences.STATUS_BAROMETER); + status("gravity", Aware_Preferences.STATUS_GRAVITY); + status("gyroscope", Aware_Preferences.STATUS_GYROSCOPE); + status("light", Aware_Preferences.STATUS_LIGHT); + status("magnetometer", Aware_Preferences.STATUS_MAGNETOMETER); + status("proximity", Aware_Preferences.STATUS_PROXIMITY); + status("rotation", Aware_Preferences.STATUS_ROTATION); + status("temperature", Aware_Preferences.STATUS_TEMPERATURE); + status("battery", Aware_Preferences.STATUS_BATTERY); + status("screen", Aware_Preferences.STATUS_SCREEN); + status("network", Aware_Preferences.STATUS_NETWORK_EVENTS, Aware_Preferences.STATUS_NETWORK_TRAFFIC); + status("processor", Aware_Preferences.STATUS_PROCESSOR); + status("timezone", Aware_Preferences.STATUS_TIMEZONE); + status("esm", Aware_Preferences.STATUS_ESM); + status("locations", Aware_Preferences.STATUS_LOCATION_GPS, + Aware_Preferences.STATUS_LOCATION_NETWORK, Aware_Preferences.STATUS_LOCATION_PASSIVE); + status("wifi", Aware_Preferences.STATUS_WIFI); + status("bluetooth", Aware_Preferences.STATUS_BLUETOOTH); + status("communication", Aware_Preferences.STATUS_COMMUNICATION_EVENTS, + Aware_Preferences.STATUS_CALLS, Aware_Preferences.STATUS_MESSAGES); + status("telephony", Aware_Preferences.STATUS_TELEPHONY); + status("applications", Aware_Preferences.STATUS_APPLICATIONS, + Aware_Preferences.STATUS_INSTALLATIONS, Aware_Preferences.STATUS_NOTIFICATIONS, + Aware_Preferences.STATUS_CRASHES, Aware_Preferences.STATUS_KEYBOARD, + Aware_Preferences.STATUS_SCREENTEXT); + status("screenshot", Aware_Preferences.STATUS_SCREENSHOT); + + sampled("accelerometer", Aware_Preferences.FREQUENCY_ACCELEROMETER, 200000, SensorFreshness.Unit.MICROSECONDS); + sampled("linear_accelerometer", Aware_Preferences.FREQUENCY_LINEAR_ACCELEROMETER, 200000, SensorFreshness.Unit.MICROSECONDS); + sampled("barometer", Aware_Preferences.FREQUENCY_BAROMETER, 200000, SensorFreshness.Unit.MICROSECONDS); + sampled("gravity", Aware_Preferences.FREQUENCY_GRAVITY, 200000, SensorFreshness.Unit.MICROSECONDS); + sampled("gyroscope", Aware_Preferences.FREQUENCY_GYROSCOPE, 200000, SensorFreshness.Unit.MICROSECONDS); + sampled("light", Aware_Preferences.FREQUENCY_LIGHT, 200000, SensorFreshness.Unit.MICROSECONDS); + sampled("magnetometer", Aware_Preferences.FREQUENCY_MAGNETOMETER, 200000, SensorFreshness.Unit.MICROSECONDS); + sampled("proximity", Aware_Preferences.FREQUENCY_PROXIMITY, 200000, SensorFreshness.Unit.MICROSECONDS); + sampled("rotation", Aware_Preferences.FREQUENCY_ROTATION, 200000, SensorFreshness.Unit.MICROSECONDS); + sampled("temperature", Aware_Preferences.FREQUENCY_TEMPERATURE, 200000, SensorFreshness.Unit.MICROSECONDS); + sampled("bluetooth", Aware_Preferences.FREQUENCY_BLUETOOTH, 60, SensorFreshness.Unit.SECONDS); + sampled("processor", Aware_Preferences.FREQUENCY_PROCESSOR, 10, SensorFreshness.Unit.SECONDS); + sampled("wifi", Aware_Preferences.FREQUENCY_WIFI, 60, SensorFreshness.Unit.SECONDS); + sampled("screenshot", Aware_Preferences.CAPTURE_TIME_INTERVAL, 60000, SensorFreshness.Unit.MILLISECONDS); + } + + private static void status(String category, String... settings) { + STATUS_SETTINGS.put(category, settings); + } + + private static void sampled( + String category, + String frequencySetting, + long defaultValue, + SensorFreshness.Unit unit + ) { + SAMPLED.put(category, new SampleDef(frequencySetting, defaultValue, unit)); + } + + /** True if the given preference key is a known sensor category. */ + public static boolean isSensor(String categoryKey) { + return REGISTRY.containsKey(categoryKey); + } + + // --- Status text (pure; shared by the locked-mode status dialog and the editable-mode status row) --- + + /** The one-line collecting / not-collecting headline. */ + public static String statusHeadline(boolean collecting) { + return collecting ? "● Collecting data" : "○ Not collecting"; + } + + public static String statusHeadline(Status status) { + return status.waitingForEvent ? "● Enabled — waiting for an event" + : statusHeadline(status.collecting); + } + + /** + * The detail block: why, what was captured, how much of it has reached the research database, + * and what to do. + * + * {@code lastData} and {@code lastDelivered} are pre-formatted relative times, passed in so this + * stays free of Android time formatting. They answer different questions and are shown together + * because the gap between them is the interesting part: capture is local, delivery is not, and a + * sensor can be collecting perfectly while nothing has left the device. A null + * {@code lastDelivered} means nothing has been delivered yet; {@code fixHint} may also be null. + */ + public static String statusDetail( + String reason, CharSequence lastData, + CharSequence lastDelivered, + String fixHint + ) { + StringBuilder msg = new StringBuilder(); + msg.append(reason); + msg.append("\n\nLast data: ").append(lastData); + msg.append("\nDelivered: ") + .append(lastDelivered == null ? "nothing yet" : "up to " + lastDelivered); + if (fixHint != null) msg.append("\n\nWhat to do: ").append(fixHint); + return msg.toString(); + } + + /** Full multi-line status text (headline + detail), as shown in the sensor status dialog. */ + public static String statusSummary(boolean collecting, String reason, CharSequence lastData, + CharSequence lastDelivered, String fixHint) { + return statusHeadline(collecting) + "\n\n" + + statusDetail(reason, lastData, lastDelivered, fixHint); + } + + public static String statusSummary(Status status, CharSequence lastData, + CharSequence lastDelivered) { + return statusHeadline(status) + "\n\n" + + statusDetail(status.reason, lastData, lastDelivered, status.fixHint); + } + + /** + * Computes the collection status for one sensor category. + * + * @param context any context + * @param categoryKey the sensor preference key (e.g. "battery") + * @param accessibilityEnabled whether AWARE's accessibility service is currently on + */ + public static Status getStatus(Context context, String categoryKey, boolean accessibilityEnabled) { + Def def = REGISTRY.get(categoryKey); + if (def == null) { + return new Status( + false, + 0, + "Unknown sensor", + null + ); + } + + long lastData = latestTimestamp(context, def); + + // Resolve permanent/permission blockers before freshness. A stale row from before a setting + // or permission change must not make a currently blocked sensor look healthy. + if (!isHardwareAvailable(context, categoryKey)) { + return new Status( + false, + lastData, + "This device has no " + prettyName(categoryKey) + " sensor", + null); + } + if (!isCategoryEnabled(context, categoryKey)) { + return new Status(false, lastData, "Sensor is disabled", "Turn on Activate to collect data"); + } + if (def.needsAccessibility && !accessibilityEnabled) { + return new Status( + false, + lastData, + "Accessibility service is off", + "Enable AWARE under Settings > Accessibility" + ); + } + if (def.needsLocationServices && !isLocationServicesEnabled(context)) { + return new Status( + false, + lastData, + "Location services are off", + "Enable Location under Settings > Location" + ); + } + String missing = firstMissingPermission(context, def.permissions); + if (missing != null) { + String p = shortPermission(missing); + return new Status( + false, + lastData, + "Missing permission: " + p, + "Grant the " + p + " permission in app settings" + ); + } + + long freshnessWindow = freshnessWindowMs(context, categoryKey); + if (freshnessWindow < 0) { + return new Status( + true, + true, + lastData, + "This sensor records data when an event occurs", + null); + } + + if (SensorFreshness.isFresh(System.currentTimeMillis(), lastData, freshnessWindow)) { + return new Status(true, lastData, "Collecting data", null); + } + if (lastData == 0) { + return new Status( + false, + 0, + "Waiting for the first sample", + "Expected within " + formatDuration(freshnessWindow) + ); + } + return new Status( + false, + lastData, + "Data is delayed", + "Expected at least one sample every " + formatDuration(freshnessWindow) + ); + } + + private static boolean isCategoryEnabled(Context context, String categoryKey) { + String[] settings = STATUS_SETTINGS.get(categoryKey); + if (settings == null) return true; + for (String setting : settings) { + if ("true".equals(Aware.getSetting(context, setting))) return true; + } + return false; + } + + /** + * Returns the recency window for sampled categories, or -1 for event-driven categories. + * Location shares one provider table, so use the shortest enabled GPS/network interval. + */ + private static long freshnessWindowMs(Context context, String categoryKey) { + if ("locations".equals(categoryKey)) { + long window = Long.MAX_VALUE; + if ("true".equals(Aware.getSetting(context, Aware_Preferences.STATUS_LOCATION_GPS))) { + window = Math.min(window, SensorFreshness.windowMs( + Aware.getSetting(context, Aware_Preferences.FREQUENCY_LOCATION_GPS), + 180, SensorFreshness.Unit.SECONDS)); + } + if ("true".equals(Aware.getSetting(context, Aware_Preferences.STATUS_LOCATION_NETWORK))) { + window = Math.min(window, SensorFreshness.windowMs( + Aware.getSetting(context, Aware_Preferences.FREQUENCY_LOCATION_NETWORK), + 300, SensorFreshness.Unit.SECONDS)); + } + return window == Long.MAX_VALUE ? -1 : window; + } + + SampleDef sample = SAMPLED.get(categoryKey); + if (sample == null) return -1; + return SensorFreshness.windowMs( + Aware.getSetting(context, sample.frequencySetting), sample.defaultValue, sample.unit); + } + + private static String formatDuration(long durationMs) { + long minutes = Math.max(1, durationMs / (60L * 1000L)); + if (minutes < 60) return minutes + (minutes == 1 ? " minute" : " minutes"); + long hours = minutes / 60; + return hours + (hours == 1 ? " hour" : " hours"); + } + + /** + * The most recent moment this sensor is known to have produced data. + * + * 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. Reading only the table then reports "never" beside a delivery time, and drives the status + * to "waiting for the first sample" for a sensor that has been working for hours. + * + * The bookmark is a truthful lower bound: if data up to T reached the database, data existed at T. + */ + private static long latestTimestamp(Context context, Def def) { + return Math.max(newestLocalRow(context, def), deliveredUpTo(context, def.table)); + } + + private static long newestLocalRow(Context context, Def def) { + Uri uri = Uri.parse("content://" + context.getPackageName() + def.authoritySuffix + "/" + def.table); + Cursor c = null; + try { + // Order by _id (rowid, indexed) rather than timestamp to stay fast on large tables; + // rows are inserted in time order, so the last rowid holds the newest timestamp. + c = context.getContentResolver().query(uri, new String[]{"timestamp"}, null, null, "_id DESC LIMIT 1"); + if (c != null && c.moveToFirst()) { + return (long) c.getDouble(0); + } + } catch (Exception e) { + // Provider missing / not readable — treat as no data. + } finally { + if (c != null) c.close(); + } + return 0; + } + + /** + * How far this sensor's data has reached the research database: the timestamp of the newest row + * the server has acknowledged, or 0 when none has been. + * + * Read from the upload bookmark rather than from the sensor's own table, so it reports what the + * server holds and not what the phone captured. The bookmark stores the timestamp of the last + * delivered row, not the clock time of the upload, which is why the value is the point the + * database has been brought up to. + * + * @param categoryKey the sensor preference key (e.g. "battery") + */ + public static long lastDeliveredMs(Context context, String categoryKey) { + Def def = REGISTRY.get(categoryKey); + if (def == null) return 0; + return deliveredUpTo(context, def.table); + } + + /** The timestamp this table's upload bookmark reports as delivered, or 0 when there is none. */ + 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 e) { + // No bookmark for this table yet — treat as nothing delivered. + } finally { + if (marker != null) marker.close(); + } + return 0; + } + + // Delegates to aware-core's SensorAvailability rather than calling SensorManager directly here + // too — that's now the single place in the codebase that talks to SensorManager for hardware + // presence, so the background config-sync drift check (StudyUtils, aware-core) and this UI + // status check can't drift apart on what "this device has no " means. + private static boolean hasHardware(Context context, int sensorType) { + return SensorAvailability.hasHardware(context, sensorType); + } + + /** + * Whether this category can exist on the current device. Categories that do not depend on a + * physical Android sensor are available by definition. + */ + public static boolean isHardwareAvailable(Context context, String categoryKey) { + Def def = REGISTRY.get(categoryKey); + return def == null || def.hardwareType == -1 || hasHardware(context, def.hardwareType); + } + + /** The OS-level Location toggle (Settings › Location) — distinct from the location permission. */ + public 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); + } + + 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; + } + + private static String prettyName(String categoryKey) { + return categoryKey.replace('_', ' '); + } + + // --------------------------------------------------------------------------------------------- + // Per-sensor consent (used by the post-join consent screen). + // + // Only sensors that need an explicit participant grant appear here — either a runtime permission + // dialog (location, call log, contacts, phone state, SMS) or the Accessibility Service toggle + // (applications, keyboard), which is granted via a Settings screen instead of a dialog. Install-time + // permissions (e.g. bluetooth/wifi/network *state*) never prompt and aren't listed. Each item lists + // the study settings that enable the sensor and either the runtime permissions to request or + // needsAccessibility=true. + // --------------------------------------------------------------------------------------------- + + /** One promptable sensor: what enables it, why we ask, and how the participant grants it. */ + public static final class ConsentItem { + public final String key; // stable id, e.g. "locations" + public final String label; // participant-facing name, e.g. "Location" + public final String reason; // one-line plain-language reason + public final String[] statusSettings; // any of these == "true" means the study enabled it + public final String[] permissions; // runtime permissions to request for this sensor + public final boolean needsAccessibility; // true if granted via Settings > Accessibility instead + + ConsentItem(String key, String label, String reason, String[] statusSettings, String[] permissions) { + this(key, label, reason, statusSettings, permissions, false); + } + + ConsentItem(String key, String label, String reason, String[] statusSettings, String[] permissions, boolean needsAccessibility) { + this.key = key; + this.label = label; + this.reason = reason; + this.statusSettings = statusSettings; + this.permissions = permissions; + this.needsAccessibility = needsAccessibility; + } + } + + // Referenced by string (not Manifest.permission.*) because they were added in API 31 and this + // module compiles against an older SDK. Requesting them still works at runtime on API 31+. + private static final String PERMISSION_BLUETOOTH_SCAN = "android.permission.BLUETOOTH_SCAN"; + private static final String PERMISSION_BLUETOOTH_CONNECT = "android.permission.BLUETOOTH_CONNECT"; + + /** + * Runtime permissions the Bluetooth sensor needs to scan and read the local adapter. On Android + * 12 (API 31) and up that's BLUETOOTH_SCAN + BLUETOOTH_CONNECT; below it, scanning is gated on + * location instead. Without the API-31 pair the sensor throws SecurityException on modern devices. + */ + private static String[] bluetoothPermissions() { + if (Build.VERSION.SDK_INT >= 31) { + return new String[]{ PERMISSION_BLUETOOTH_SCAN, PERMISSION_BLUETOOTH_CONNECT }; + } + return new String[]{ Manifest.permission.ACCESS_COARSE_LOCATION }; + } + + // Added in API 29; referenced by string because this module compiles against an older SDK. + private static final String PERMISSION_BACKGROUND_LOCATION = "android.permission.ACCESS_BACKGROUND_LOCATION"; + + /** + * True if AWARE can read location in the background ("Allow all the time"). Before Android 10 + * (API 29) there's no separate background-location permission, so it's always true. On API 29+ it + * requires ACCESS_BACKGROUND_LOCATION, which the participant grants as "Allow all the time" — the + * foreground ("while using the app") grant does NOT include it, and without it location logging + * stops whenever AWARE isn't in the foreground. + */ + public static boolean hasBackgroundLocation(Context context) { + if (Build.VERSION.SDK_INT < 29) return true; + return ContextCompat.checkSelfPermission(context, PERMISSION_BACKGROUND_LOCATION) + == PackageManager.PERMISSION_GRANTED; + } + + private static final ConsentItem[] CONSENTS = new ConsentItem[]{ + new ConsentItem( + "locations", + "Location", + "Record the places you visit.", + new String[]{ + Aware_Preferences.STATUS_LOCATION_GPS, + Aware_Preferences.STATUS_LOCATION_NETWORK + }, + new String[]{ + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + } + ), + new ConsentItem( + "wifi", + "Wi-Fi", + "Detect nearby Wi-Fi networks.", + new String[]{ + Aware_Preferences.STATUS_WIFI + }, + new String[]{ + Manifest.permission.ACCESS_COARSE_LOCATION + } + ), + new ConsentItem( + "bluetooth", + "Bluetooth", + "Detect nearby Bluetooth devices.", + new String[]{ + Aware_Preferences.STATUS_BLUETOOTH + }, + bluetoothPermissions() + ), + new ConsentItem( + "telephony", + "Telephony", + "Cell tower / network information.", + new String[]{ + Aware_Preferences.STATUS_TELEPHONY + }, + new String[]{ + Manifest.permission.READ_PHONE_STATE, + Manifest.permission.ACCESS_COARSE_LOCATION + } + ), + new ConsentItem( + "communication", + "Calls & messages", + "Call and message patterns (content is never read).", + new String[]{ + Aware_Preferences.STATUS_COMMUNICATION_EVENTS, + Aware_Preferences.STATUS_CALLS, + Aware_Preferences.STATUS_MESSAGES + }, + new String[]{ + Manifest.permission.READ_CALL_LOG, + Manifest.permission.READ_PHONE_STATE, + Manifest.permission.READ_SMS + } + ), + new ConsentItem( + "application", + "Applications usage", + "Foreground app activity, notifications, crashes, and app install/update/removal events.", + new String[]{ + Aware_Preferences.STATUS_APPLICATIONS, + Aware_Preferences.STATUS_NOTIFICATIONS, + Aware_Preferences.STATUS_CRASHES + }, + NONE, + true + ), + new ConsentItem( + "keyboard", + "Keyboard masked text", + "Keyboard usage patterns, the content is masked, no passwords and text exposed", + new String[]{ + Aware_Preferences.STATUS_KEYBOARD + }, + NONE, + true + ), + new ConsentItem( + "screenshot", + "Screenshots", + "Periodic screenshots of what's on screen.", + new String[]{ + Aware_Preferences.STATUS_SCREENSHOT + }, + NONE, + true + ), + new ConsentItem( + "ambient_noise", + "Ambient Noise plugin", + "Measure environmental sound levels.", + new String[]{ + Aware_Preferences.STATUS_PLUGIN_AMBIENT_NOISE + }, + new String[]{ + Manifest.permission.RECORD_AUDIO + } + ), + new ConsentItem( + "openweather", + "OpenWeather plugin", + "Use location to record local weather conditions.", + new String[]{ + Aware_Preferences.STATUS_PLUGIN_OPENWEATHER + }, + new String[]{ + Manifest.permission.ACCESS_COARSE_LOCATION + } + ) + }; + + /** + * The consent item covering a sensor-list category key (as used by the study sensor list and + * {@link #getStatus}), or null if that category needs no participant grant. Handles the one key + * that differs between the two vocabularies ("applications" category ↔ "application" consent). + */ + public static ConsentItem consentItemForCategory(String categoryKey) { + String consentKey = "applications".equals(categoryKey) ? "application" : categoryKey; + for (ConsentItem item : CONSENTS) { + if (item.key.equals(consentKey)) return item; + } + return null; + } + + /** + * The consent item whose status settings include this exact setting key, or null if the setting + * needs no participant grant. Lets a directly-toggled sensor checkbox (e.g. status_applications, + * status_keyboard) find the accessibility / permission grant it still requires. + */ + public static ConsentItem consentItemForSetting(String statusSetting) { + if (statusSetting == null) return null; + for (ConsentItem item : CONSENTS) { + for (String setting : item.statusSettings) { + if (setting.equals(statusSetting)) return item; + } + } + return null; + } + + /** + * Every setting controlled by a consent choice. Installation events do not technically require + * Accessibility, so they are not one of the settings that makes the Applications consent row + * appear. When that row is present, however, its disclosure explicitly includes installation + * events; accepting or declining it must therefore control installations as part of the same + * participant choice. + */ + public static List controlledSettings(ConsentItem item) { + List settings = new ArrayList<>(Arrays.asList(item.statusSettings)); + if ("application".equals(item.key)) { + settings.add(Aware_Preferences.STATUS_INSTALLATIONS); + } + return settings; + } + + /** + * Consent items represented by a top-level sensor category. Applications is special: its + * preference screen contains both application-usage and keyboard collection, even though they + * are independent consent choices backed by the same Accessibility Service toggle. + */ + public static List consentItemsForCategory(String categoryKey) { + List matches = new ArrayList<>(); + for (ConsentItem item : CONSENTS) { + if (item.key.equals(categoryKey) + || ("applications".equals(categoryKey) + && ("application".equals(item.key) || "keyboard".equals(item.key)))) { + matches.add(item); + } + } + return matches; + } + + /** + * Which of {@code candidates} the active study config actually enables (value == true). Used when + * a participant enables a consent group from a sensor's details, so only the sub-settings the + * study wants are turned on, not the whole group. + */ + public static List configEnabledSettings(JSONObject config, String[] candidates) { + List out = new ArrayList<>(); + JSONArray sensors = config == null ? null : config.optJSONArray("sensors"); + if (sensors == null) return out; + for (int i = 0; i < sensors.length(); i++) { + JSONObject sensor = sensors.optJSONObject(i); + if (sensor == null || !sensor.optBoolean("value", false)) continue; + String setting = sensor.optString("setting", ""); + for (String candidate : candidates) { + if (candidate.equals(setting)) out.add(candidate); + } + } + return out; + } + + /** + * The consent items a study's config would enable, regardless of whether their permissions are + * granted yet. Reads sensor enablement directly from the not-yet-applied config JSON, so this can + * run before {@code StudyUtils.applySettings} has flipped any status_* setting — consent must gate + * enabling, not just follow it. + */ + public static List enabledConsentsForConfig(JSONArray configs) { + List enabled = new ArrayList<>(); + for (ConsentItem item : CONSENTS) { + if (item.permissions.length == 0 && !item.needsAccessibility) continue; + if (isSensorEnabledInConfig(configs, item.statusSettings)) enabled.add(item); + } + return enabled; + } + + /** How (if at all) the participant must act for a sensor shown on the consent screen. */ + public enum ConsentBadge { PERMISSION, ACCESSIBILITY, AUTOMATIC } + public enum ConsentGroup { SENSOR, PLUGIN } + + /** + * One participant-facing row on the join consent screen. Either a grant-requiring sensor + * ({@link #grantItem} non-null — tap to grant a runtime permission or the Accessibility service) + * or an automatically-collected sensor ({@link #grantItem} null — disclosed for transparency, + * with nothing to grant). + */ + public static final class ConsentRow { + public final String label; + public final String description; + public final String emoji; + public final ConsentBadge badge; + public final ConsentGroup group; + public final ConsentItem grantItem; // non-null only for grant-requiring sensors + + ConsentRow(String label, String description, String emoji, ConsentBadge badge, + ConsentGroup group, ConsentItem grantItem) { + this.label = label; + this.description = description; + this.emoji = emoji; + this.badge = badge; + this.group = group; + this.grantItem = grantItem; + } + + public boolean requiresGrant() { return grantItem != null; } + } + + /** + * The full, ordered list of sensors a study collects, for the pre-join consent screen: every + * grant-requiring sensor the config enables (each a tap-to-grant row) followed by every + * automatically-collected sensor it enables (disclosed only). Built directly from the + * not-yet-applied config JSON, same as {@link #enabledConsentsForConfig}, so it can run before the + * study is applied. + */ + public static List consentRowsForConfig(JSONArray configs) { + List sensorRows = new ArrayList<>(); + List pluginRows = new ArrayList<>(); + Set covered = new HashSet<>(); + for (ConsentItem item : enabledConsentsForConfig(configs)) { + ConsentBadge badge = item.needsAccessibility ? ConsentBadge.ACCESSIBILITY : ConsentBadge.PERMISSION; + ConsentGroup group = consentGroup(item.statusSettings); + ConsentRow row = new ConsentRow( + item.label, item.reason, consentEmoji(item.key), badge, group, item); + (group == ConsentGroup.PLUGIN ? pluginRows : sensorRows).add(row); + covered.addAll(controlledSettings(item)); + } + + List automaticSensors = new ArrayList<>(); + List automaticPlugins = new ArrayList<>(); + for (String setting : enabledStatusSettings(configs)) { + if (covered.contains(setting) || consentItemForSetting(setting) != null) continue; + String name = setting.substring("status_".length()); + ConsentGroup group = setting.startsWith("status_plugin_") + ? ConsentGroup.PLUGIN : ConsentGroup.SENSOR; + ConsentRow row = new ConsentRow( + autoLabel(name), autoDescription(name), autoEmoji(name), + ConsentBadge.AUTOMATIC, group, null); + (group == ConsentGroup.PLUGIN ? automaticPlugins : automaticSensors).add(row); + } + Comparator byLabel = new Comparator() { + @Override + public int compare(ConsentRow a, ConsentRow b) { + return a.label.compareToIgnoreCase(b.label); + } + }; + Collections.sort(automaticSensors, byLabel); + Collections.sort(automaticPlugins, byLabel); + sensorRows.addAll(automaticSensors); + pluginRows.addAll(automaticPlugins); + + List rows = new ArrayList<>(); + rows.addAll(sensorRows); + rows.addAll(pluginRows); + return rows; + } + + /** A single grant-requiring display row for {@code item} — e.g. the mid-study update list. */ + public static ConsentRow rowFor(ConsentItem item) { + ConsentBadge badge = item.needsAccessibility ? ConsentBadge.ACCESSIBILITY : ConsentBadge.PERMISSION; + return new ConsentRow(item.label, item.reason, consentEmoji(item.key), badge, + consentGroup(item.statusSettings), item); + } + + private static ConsentGroup consentGroup(String[] statusSettings) { + for (String setting : statusSettings) { + if (setting != null && setting.startsWith("status_plugin_")) { + return ConsentGroup.PLUGIN; + } + } + return ConsentGroup.SENSOR; + } + + /** Every status_* setting the config enables (value == true), across all config elements. */ + private static Set enabledStatusSettings(JSONArray configs) { + Set out = new HashSet<>(); + if (configs == null) return out; + for (int i = 0; i < configs.length(); i++) { + JSONObject element = configs.optJSONObject(i); + JSONArray sensors = element == null ? null : element.optJSONArray("sensors"); + if (sensors == null) continue; + for (int j = 0; j < sensors.length(); j++) { + JSONObject sensor = sensors.optJSONObject(j); + if (sensor == null || !sensor.optBoolean("value", false)) continue; + String setting = sensor.optString("setting", ""); + if (setting.startsWith("status_")) out.add(setting); + } + } + return out; + } + + private static String consentEmoji(String consentKey) { + switch (consentKey) { + case "locations": return "📍"; // 📍 + case "wifi": return "📶"; // 📶 + case "bluetooth": return "🔵"; // 🔵 + case "telephony": return "📡"; // 📡 + case "communication": return "💬"; // 💬 + case "application": return "📊"; // 📊 + case "keyboard": return "⌨️"; // ⌨️ + case "screenshot": return "📸"; // 📸 + case "ambient_noise": return "🎤"; // 🎤 + case "openweather": return "🌦️"; // 🌦️ + default: return "🔒"; // 🔒 + } + } + + private static String autoEmoji(String name) { + if (name.startsWith("plugin_")) { + String plugin = name.substring("plugin_".length()); + if ("openweather".equals(plugin)) return "🌦️"; + if ("esm_scheduler".equals(plugin)) return "📝"; + if ("ambient_noise".equals(plugin)) return "🎤"; + return "🧩"; + } + switch (name) { + case "accelerometer": + case "linear_accelerometer": + case "significant_motion": return "🏃"; // 🏃 + case "gyroscope": return "🌀"; // 🌀 + case "magnetometer": + case "rotation": return "🧭"; // 🧭 + case "gravity": return "⬇️"; // ⬇️ + case "barometer": return "🌦️"; // 🌦️ + case "light": return "💡"; // 💡 + case "proximity": return "📏"; // 📏 + case "temperature": return "🌡️"; // 🌡️ + case "battery": return "🔋"; // 🔋 + case "screen": return "📱"; // 📱 + case "processor": return "⚙️"; // ⚙️ + case "network_traffic": return "📈"; // 📈 + case "timezone": return "🕐"; // 🕐 + default: return "📊"; // 📊 + } + } + + private static String autoLabel(String name) { + if (name.startsWith("plugin_")) { + String plugin = name.substring("plugin_".length()); + if ("openweather".equals(plugin)) return "OpenWeather plugin"; + if ("esm_scheduler".equals(plugin)) return "ESM Scheduler plugin"; + if ("ambient_noise".equals(plugin)) return "Ambient Noise plugin"; + String prettyPlugin = plugin.replace('_', ' '); + return (prettyPlugin.isEmpty() + ? "Plugin" + : Character.toUpperCase(prettyPlugin.charAt(0)) + prettyPlugin.substring(1)) + + " plugin"; + } + switch (name) { + case "linear_accelerometer": return "Linear accelerometer"; + case "significant_motion": return "Significant motion"; + case "network_traffic": return "Network traffic"; + case "installations": return "Application installations"; + case "processor": return "Processor (CPU)"; + case "timezone": return "Time zone"; + default: + String pretty = name.replace('_', ' '); + return pretty.isEmpty() ? pretty : Character.toUpperCase(pretty.charAt(0)) + pretty.substring(1); + } + } + + private static String autoDescription(String name) { + if (name.startsWith("plugin_")) { + String plugin = name.substring("plugin_".length()); + if ("openweather".equals(plugin)) return "Record local weather conditions."; + if ("esm_scheduler".equals(plugin)) return "Schedule study questionnaires."; + if ("ambient_noise".equals(plugin)) return "Measure environmental sound levels."; + return "Additional study data collection."; + } + switch (name) { + case "accelerometer": return "Device movement and orientation."; + case "linear_accelerometer": return "Movement with gravity removed."; + case "significant_motion": return "Detects notable movement."; + case "gyroscope": return "Rotation and turning of the device."; + case "magnetometer": return "Magnetic field / compass heading."; + case "rotation": return "Device orientation in space."; + case "gravity": return "Direction of gravity."; + case "barometer": return "Air pressure."; + case "light": return "Ambient light level."; + case "proximity": return "Whether something is close to the screen."; + case "temperature": return "Ambient temperature."; + case "battery": return "Charging state and battery level."; + case "screen": return "Screen on/off and lock/unlock."; + case "processor": return "Processor (CPU) load."; + case "network_traffic": return "Amount of data sent and received."; + case "installations": return "Application install, update, and removal events."; + case "timezone": return "Device time zone."; + default: return "Collected automatically."; + } + } + + /** + * True if the study config enables at least one sensor that still needs a permission grant or + * Accessibility Service enable the participant hasn't already given — i.e. there's actually + * something new to ask about. False when every promptable sensor the config enables is already + * satisfied, so the consent screen would have nothing left to show. + */ + public static boolean hasPendingConsents(Context context, JSONArray configs) { + for (ConsentItem item : enabledConsentsForConfig(configs)) { + if (!isAlreadyGranted(context, item)) return true; + } + return false; + } + + /** + * Fingerprint of what a consent for this study would cover: the study URL plus the sorted keys + * of every promptable sensor its config enables (like "https://…|bluetooth,locations,wifi"). + * Stored as {@link com.aware.Aware_Preferences#STUDY_CONSENT_RECORD} when the participant + * completes the consent screen; a mismatch (different study, or a changed sensor set) means + * their recorded agreement doesn't cover this join. + */ + public static String consentFingerprint(String studyUrl, JSONArray configs) { + List keys = new ArrayList<>(); + for (ConsentItem item : enabledConsentsForConfig(configs)) { + keys.add(item.key); + } + java.util.Collections.sort(keys); + return (studyUrl == null ? "" : studyUrl) + "|" + TextUtils.join(",", keys); + } + + /** + * True if the participant's recorded consent (if any) covers exactly this study and its current + * promptable-sensor set. False when there is no record — never consented, or the record was + * wiped by quitting the study — or when the set changed since they agreed. + */ + public static boolean hasMatchingConsentRecord(Context context, String studyUrl, JSONArray configs) { + String recorded = Aware.getSetting(context, Aware_Preferences.STUDY_CONSENT_RECORD); + return recorded.length() > 0 && recorded.equals(consentFingerprint(studyUrl, configs)); + } + + /** The set of status_* keys the participant has declined (persisted, comma-joined). */ + private static Set declinedSet(Context context) { + Set out = new HashSet<>(); + for (String key : Aware.getSetting(context, Aware_Preferences.STUDY_DECLINED_SENSORS).split(",")) { + if (key.trim().length() > 0) out.add(key.trim()); + } + return out; + } + + /** + * Promptable sensors the study config enables that are currently declined (held off) — i.e. the + * study wants them but the participant hasn't agreed. Used by the "study updated" re-consent + * prompt to show exactly what's waiting on agreement after a config change. + */ + public static List heldConsentsForConfig(Context context, JSONArray configs) { + Set declined = declinedSet(context); + List held = new ArrayList<>(); + for (ConsentItem item : enabledConsentsForConfig(configs)) { + for (String setting : item.statusSettings) { + if (declined.contains(setting)) { + held.add(item); + break; + } + } + } + return held; + } + + /** Held consent choices belonging to one top-level sensor category. */ + public static List heldConsentsForCategory(Context context, JSONArray configs, + String categoryKey) { + List categoryItems = consentItemsForCategory(categoryKey); + List held = new ArrayList<>(); + for (ConsentItem item : heldConsentsForConfig(context, configs)) { + if (categoryItems.contains(item)) held.add(item); + } + return held; + } + + /** + * Researcher-visible snapshot of the current consent state for every promptable sensor enabled + * by the study config. Unlike an isolated "sensor enabled" event, this supersedes ambiguity in + * an earlier consent row after the participant changes their choice during the study. + */ + public static String consentStateSummary(JSONArray configs, Set declinedSettings) { + List enabled = new ArrayList<>(); + List declined = new ArrayList<>(); + Set declinedKeys = declinedSettings == null + ? new HashSet() : declinedSettings; + for (ConsentItem item : enabledConsentsForConfig(configs)) { + boolean itemDeclined = false; + for (String setting : item.statusSettings) { + if (declinedKeys.contains(setting)) { + itemDeclined = true; + break; + } + } + (itemDeclined ? declined : enabled).add(item.label); + } + return "enabled=" + enabled + " declined=" + declined; + } + + /** True if the study config enables at least one promptable sensor currently declined/held off. */ + public static boolean hasHeldConsents(Context context, JSONArray configs) { + return !heldConsentsForConfig(context, configs).isEmpty(); + } + + /** + * READ_SMS and READ_CALL_LOG are Android "hard-restricted" permissions: the platform refuses to + * grant them to an app that isn't the device's default SMS/Dialer app (and isn't whitelisted by + * its installer), so the runtime dialog is effectively a no-op. Treating them as required would + * trap the Communication row on "Enable" forever, since checkSelfPermission can never return + * granted. They're best-effort instead — the sensor collects call/message metadata only if the + * platform does grant them, but consent doesn't hinge on it. + */ + private static boolean isBestEffortPermission(String permission) { + return Manifest.permission.READ_SMS.equals(permission) + || Manifest.permission.READ_CALL_LOG.equals(permission); + } + + /** + * True if a consent item's requirement is already satisfied — the Accessibility Service is on + * (for accessibility-backed items) or every one of its required runtime permissions is granted. + * Hard-restricted permissions (see {@link #isBestEffortPermission}) don't count against this, so a + * row isn't trapped waiting on a grant the platform will never give. Judged from the live + * permission state so it reflects reality regardless of the request callback's result array. Used + * both to decide whether the consent screen needs to appear ({@link #hasPendingConsents}) and, once + * shown, which rows are satisfied. + */ + public static boolean isAlreadyGranted(Context context, ConsentItem item) { + if (item.needsAccessibility) { + return isAccessibilityServiceEnabled(context); + } + for (String permission : item.permissions) { + if (isBestEffortPermission(permission)) continue; + if (ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED) { + return false; + } + } + return true; + } + + /** True if any of the given status settings is "value": true for some sensor entry in the config. */ + private static boolean isSensorEnabledInConfig(JSONArray configs, String[] statusSettings) { + for (int i = 0; i < configs.length(); i++) { + JSONObject element = configs.optJSONObject(i); + if (element == null) continue; + JSONArray sensors = element.optJSONArray("sensors"); + if (sensors == null) continue; + for (int j = 0; j < sensors.length(); j++) { + JSONObject sensor = sensors.optJSONObject(j); + if (sensor == null) continue; + String setting = sensor.optString("setting", ""); + if (!sensor.optBoolean("value", false)) continue; + for (String s : statusSettings) { + if (s.equals(setting)) return true; + } + } + } + return false; + } + + /** + * True if AWARE's Accessibility Service (backs Applications, Keyboard and Screenshot capture) is + * currently enabled. Reads the OS setting directly rather than {@link Applications#isAccessibilityServiceActive} + * so checking status here has no side effects (that method posts a notification when disabled). + */ + public 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-phone/src/main/java/com/aware/phone/ui/prefs/StudyCard.java b/aware-phone/src/main/java/com/aware/phone/ui/prefs/StudyCard.java new file mode 100644 index 0000000..74e132b --- /dev/null +++ b/aware-phone/src/main/java/com/aware/phone/ui/prefs/StudyCard.java @@ -0,0 +1,119 @@ +package com.aware.phone.ui.prefs; + +import android.content.ContentValues; +import android.content.Context; +import android.text.Html; +import android.util.Patterns; +import android.view.View; +import android.widget.TextView; + +import com.aware.phone.R; +import com.aware.phone.utils.AwareUtil; +import com.aware.providers.Aware_Provider; + +import java.text.DateFormat; +import java.util.Date; +import java.util.regex.Matcher; + +/** + * Binds the shared {@code R.layout.study_card} view from a study row. Used by both the current + * study (StudyInfoPref, study mode) and the joined-studies history (device mode) so the two + * always render consistently. Empty fields hide their row; the Email and Study link rows are + * tap-to-copy. + */ +public final class StudyCard { + + private StudyCard() {} + + /** + * Populates a {@code study_card} view from a study row's columns. + * + * @param context used for clipboard + toast on the copyable rows + * @param card an inflated {@code R.layout.study_card} + * @param study study columns (see {@link Aware_Provider.Aware_Studies}) + */ + public static void bind(final Context context, View card, ContentValues study) { + TextView name = card.findViewById(R.id.study_name); + name.setText(study.getAsString(Aware_Provider.Aware_Studies.STUDY_TITLE)); + + TextView description = card.findViewById(R.id.study_description); + String desc = study.getAsString(Aware_Provider.Aware_Studies.STUDY_DESCRIPTION); + if (desc != null && desc.trim().length() > 0) { + description.setText(Html.fromHtml(desc, null, null)); + description.setVisibility(View.VISIBLE); + } else { + description.setVisibility(View.GONE); + } + + // STUDY_PI is stored as "First Last\nContact: email@x.com": name under Contact, email + // in its own tappable (blue) row. + String pi = study.getAsString(Aware_Provider.Aware_Studies.STUDY_PI); + setRow(card, R.id.row_contact, R.id.study_contact, extractName(pi)); + + final String email = extractEmail(pi); + setRow(card, R.id.row_email, R.id.study_email, email); + bindCopy(context, card, R.id.study_email, "AWARE study contact", email); + + final String url = study.getAsString(Aware_Provider.Aware_Studies.STUDY_URL); + setRow(card, R.id.row_link, R.id.study_link, url); + bindCopy(context, card, R.id.study_link, "AWARE study link", url); + + Double joined = study.getAsDouble(Aware_Provider.Aware_Studies.STUDY_JOINED); + long joinedMs = joined != null ? joined.longValue() : 0; + setRow(card, R.id.row_joined, R.id.study_joined, + joinedMs > 0 ? DateFormat.getDateInstance().format(new Date(joinedMs)) : null); + + Double exit = study.getAsDouble(Aware_Provider.Aware_Studies.STUDY_EXIT); + boolean enrolled = exit == null || exit == 0; + setRow(card, R.id.row_status, R.id.study_status, enrolled ? "Enrolled" : "Left"); + } + + /** Wires a value view to copy the given text to the clipboard on tap (no-op if empty). */ + private static void bindCopy(final Context context, View card, int valueId, + final String label, final String text) { + TextView value = card.findViewById(valueId); + if (text != null && text.length() > 0) { + value.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + AwareUtil.copyToClipboard(context, label, text); + } + }); + } + } + + /** Sets a labeled row's value, hiding the whole row when the value is empty. */ + private static void setRow(View card, int rowId, int valueId, String value) { + View row = card.findViewById(rowId); + TextView valueView = card.findViewById(valueId); + if (value == null || value.trim().length() == 0) { + if (row != null) row.setVisibility(View.GONE); + } else { + if (row != null) row.setVisibility(View.VISIBLE); + valueView.setText(value); + } + } + + /** + * Researcher name from a contact/PI string: first line with any "Contact:" label and email + * removed. Null if nothing meaningful remains. + */ + static String extractName(String contact) { + if (contact == null) return null; + String name = contact; + int newline = name.indexOf('\n'); + if (newline >= 0) name = name.substring(0, newline); + name = name.replace("Contact:", ""); + String email = extractEmail(name); + if (email != null) name = name.replace(email, ""); + name = name.trim(); + return name.length() > 0 ? name : null; + } + + /** Email address found in a contact/PI string, or null. */ + static String extractEmail(String contact) { + if (contact == null) return null; + Matcher matcher = Patterns.EMAIL_ADDRESS.matcher(contact); + return matcher.find() ? matcher.group() : null; + } +} diff --git a/aware-phone/src/main/java/com/aware/phone/ui/prefs/StudyInfoPref.java b/aware-phone/src/main/java/com/aware/phone/ui/prefs/StudyInfoPref.java index 6d2d4cc..9810178 100644 --- a/aware-phone/src/main/java/com/aware/phone/ui/prefs/StudyInfoPref.java +++ b/aware-phone/src/main/java/com/aware/phone/ui/prefs/StudyInfoPref.java @@ -1,19 +1,17 @@ package com.aware.phone.ui.prefs; +import android.content.ContentValues; import android.content.Context; import android.database.Cursor; +import android.database.DatabaseUtils; import android.preference.Preference; -import android.text.Html; import android.util.AttributeSet; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.TextView; import com.aware.Aware; -import com.aware.Aware_Preferences; import com.aware.phone.R; -import com.aware.providers.Aware_Provider; public class StudyInfoPref extends Preference { @@ -38,22 +36,17 @@ protected View onCreateView(ViewGroup parent) { super.onCreateView(parent); LayoutInflater inflater = (LayoutInflater) getContext().getSystemService( Context.LAYOUT_INFLATER_SERVICE); - View view = inflater.inflate(R.layout.pref_study_info, parent, false); + View view = inflater.inflate(R.layout.study_card, parent, false); - TextView tvStudyName = view.findViewById(R.id.study_name); - TextView tvStudyDesc = view.findViewById(R.id.study_description); - TextView tvStudyContact = view.findViewById(R.id.study_contact); - - Cursor study = Aware.getStudy(getContext(), - Aware.getSetting(getContext(), Aware_Preferences.WEBSERVICE_SERVER)); + // Use the currently-enrolled study, independent of the WEBSERVICE_SERVER setting + // (which can drift from the study URL after a reset). See Aware.getActiveStudy(). + Cursor study = Aware.getActiveStudy(getContext()); if (study != null && study.moveToFirst()) { - tvStudyName.setText(study.getString( - study.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_TITLE))); - tvStudyDesc.setText(Html.fromHtml(study.getString(study.getColumnIndex( - Aware_Provider.Aware_Studies.STUDY_DESCRIPTION)), null, null)); - tvStudyContact.setText(study.getString(study.getColumnIndex( - Aware_Provider.Aware_Studies.STUDY_PI))); + ContentValues row = new ContentValues(); + DatabaseUtils.cursorRowToContentValues(study, row); + StudyCard.bind(getContext(), view, row); } + if (study != null && !study.isClosed()) study.close(); return view; } diff --git a/aware-phone/src/main/java/com/aware/phone/ui/prefs/StudySyncPref.java b/aware-phone/src/main/java/com/aware/phone/ui/prefs/StudySyncPref.java index 81260d6..8b7c785 100644 --- a/aware-phone/src/main/java/com/aware/phone/ui/prefs/StudySyncPref.java +++ b/aware-phone/src/main/java/com/aware/phone/ui/prefs/StudySyncPref.java @@ -42,6 +42,7 @@ public void onClick(View v) { Toast.makeText(getContext(), "Checking for updates...", Toast.LENGTH_SHORT).show(); Intent sync = new Intent(Aware.ACTION_AWARE_SYNC_CONFIG); sync.putExtra(Aware.SYNC_CONFIG_EXTRA_TOAST, true); + sync.putExtra(Aware.SYNC_CONFIG_EXTRA_MANUAL, true); getContext().sendBroadcast(sync); } }); diff --git a/aware-phone/src/main/java/com/aware/phone/utils/AwareUtil.java b/aware-phone/src/main/java/com/aware/phone/utils/AwareUtil.java index 9eda875..757904f 100644 --- a/aware-phone/src/main/java/com/aware/phone/utils/AwareUtil.java +++ b/aware-phone/src/main/java/com/aware/phone/utils/AwareUtil.java @@ -1,31 +1,23 @@ package com.aware.phone.utils; +import android.content.ClipData; +import android.content.ClipboardManager; import android.content.Context; -import android.database.Cursor; -import android.util.Log; 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.Jdbc; -import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; -import java.net.URL; -import java.util.Iterator; import java.util.regex.Matcher; import java.util.regex.Pattern; /** - * Utility class for AWARE-specific processes. + * Small stateless helpers for the phone UI: fetching a hosted study config, mapping a sensor + * setting key to its sensor name, and copying text to the clipboard. */ public class AwareUtil { - private static final String[] REQUIRED_STUDY_CONFIG_KEYS = {"database", "questions", - "schedules", "sensors", "study_info"}; /** * Retrieves a study config from a file hosted online. @@ -54,33 +46,6 @@ public static JSONObject getStudyConfig(String studyUrl) throws JSONException { return null; } - /** - * Validates that the study config has the correct JSON schema for AWARE. - * It needs to have the keys: "database", "sensors" and "study_info". - * - * @param context application context - * @param config JSON representing a study configuration - * @return true if the study config is valid, false otherwise - */ - public static boolean validateStudyConfig(Context context, JSONObject config) { - // TODO: Add validation for certificate URLs - for (String key: REQUIRED_STUDY_CONFIG_KEYS) { - if (!config.has(key)) return false; - } - - // 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.getBoolean("config_without_password"), - Aware.getSetting(context, Aware_Preferences.DB_PASSWORD)); - } catch (JSONException e) { - return false; - } - } - /** * Returns the mapping of a sensor setting (from study config) to its sensor's name. * (e.g. status_accelerometer -> accelerometer) @@ -93,84 +58,18 @@ public static String getSensorType(String setting) { return setting.replace("status_", ""); } -// public static void syncStudyConfig(Context context) { -// if (!Aware.isStudy(context)) return; -// -// boolean studyConfigChanged = false; -// String studyUrl = Aware.getSetting(context, Aware_Preferences.WEBSERVICE_SERVER); -// Cursor study = Aware.getStudy(context, -// Aware.getSetting(context, Aware_Preferences.WEBSERVICE_SERVER)); -// -// if (study != null && study.moveToFirst()) { -// try { -// JSONObject localConfig = new JSONArray(study.getString( -// study.getColumnIndex(Aware_Provider.Aware_Studies.STUDY_CONFIG))) -// .getJSONObject(0); -//// studyConfigChanged = -// } catch (JSONException e) { -// e.printStackTrace(); -// } -// } -// try { -// JSONObject config = getStudyConfig(studyUrl); -// if (!validateStudyConfig(context, config)) { -// Log.e(Aware.TAG, "Failed to sync study config, something is wrong with the config."); -// return; -// } -// -// } catch (JSONException e) { -// Log.e(Aware.TAG, "Failed to sync study config, error: " + e.getMessage()); -// } -// } -// -// /** -// * Compares two versions of the study config and return true if there is any difference between -// * them. -// * -// * @param oldConfig JSONObject representing the old study config -// * @param newConfig JSONObject representing the new study config -// * @return true if there are no changes between the old and new config, false otherwise. -// */ -// private static boolean compareStudyConfig(JSONObject oldConfig, JSONObject newConfig) { -// // Check database -// // Check study info -// // Check sensors -// // Check schedulers -// // Check ESM schedules -// // Check ESM questions -// try { -// JSONObject oldDb = oldConfig.getJSONObject("database"), -// oldSensors = oldConfig.getJSONObject("sensors"), -// oldStudyInfo = oldConfig.getJSONObject("study_info"), -// oldEsms = oldConfig.getJSONObject("study_info"), -// oldSchedules = oldConfig.getJSONObject("study_info"); -// JSONObject newDb = newConfig.getJSONObject("database"), -// newSensors = newConfig.getJSONObject("sensors"), -// newStudyInfo = newConfig.getJSONObject("study_info"), -// newEsms = newConfig.getJSONObject("study_info"), -// newSchedules = newConfig.getJSONObject("study_info"); -// -// for (Iterator keys = oldDb.keys(); keys.hasNext();) { -// String key = (String) keys.next(); -// if (!oldDb.getString(key).equals(newDb.getString(key))) return true; -// } -// -// for (Iterator keys = oldSensors.keys(); keys.hasNext();) { -// String key = (String) keys.next(); -// if (!oldSensors.getString(key).equals(newSensors.getString(key))) return true; -// } -// -// for (Iterator keys = oldDb.keys(); keys.hasNext();) { -// String key = (String) keys.next(); -// if (!oldDb.getString(key).equals(newDb.getString(key))) return true; -// } -// -//// for (String key : oldConfig.getJSONObject("database")) { -//// -//// } -// } catch (JSONException e) { -// e.printStackTrace(); -// } -// return false; -// } + /** + * Copies text to the system clipboard and shows a short confirmation toast. + * + * @param context + * @param label clipboard label (not user-visible on most launchers) + * @param text the text to copy; no-op if null/empty + */ + public static void copyToClipboard(Context context, String label, String text) { + if (text == null || text.length() == 0) return; + ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + if (clipboard == null) return; + clipboard.setPrimaryClip(ClipData.newPlainText(label, text)); + Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show(); + } } diff --git a/aware-phone/src/main/res/layout/activity_sensor_consent.xml b/aware-phone/src/main/res/layout/activity_sensor_consent.xml new file mode 100644 index 0000000..b355ff1 --- /dev/null +++ b/aware-phone/src/main/res/layout/activity_sensor_consent.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + +