From 76c61347158f78d23e15ecd58346918674ccb9c0 Mon Sep 17 00:00:00 2001 From: "K. Shankari" Date: Tue, 26 Jul 2016 14:18:11 -0700 Subject: [PATCH] Add support for turning on tracking only after user consent This, combined with https://github.com/e-mission/e-mission-phone/pull/87 should largely resolve https://github.com/e-mission/e-mission-phone/issues/43 It also provides additional functionality, including for turning off tracking on re-approval until the user re-consents. The steps for the changes are: - Add support for get/set consented to the config manager - Use that to trigger/defer state machine init on android and iOS - on iOS, if not consented, then the data collection plugin is not initialized - on android, if not consented, then all `initialize` transitions are ignored, so the state machine is never initialized. - new javascript interface that indicates when the user has approved, and an implementation that saves the consent to the usercache and turns on tracking On android, we are prompted for re-consent. On iOS, if this is a reconsent, I think that we will be prompted, because the user has already consented to receive notifications. But on iOS, if this is the initial consent, and the user disagrees, then we won't be prompted because the user would not have signed up for remote notifications. The inconsistency in prompting is tracked in https://github.com/e-mission/e-mission-data-collection/issues/121 --- src/android/ConfigManager.java | 12 +++++++++ src/android/DataCollectionPlugin.java | 18 +++++++++++-- .../TripDiaryStateMachineReceiver.java | 19 ++++++++++++- src/ios/BEMDataCollection.m | 27 +++++++++++++++++++ src/ios/ConfigManager.h | 3 +++ src/ios/ConfigManager.m | 16 +++++++++++ www/datacollection.js | 5 ++++ 7 files changed, 97 insertions(+), 3 deletions(-) diff --git a/src/android/ConfigManager.java b/src/android/ConfigManager.java index c28d6df7..a5c0174d 100644 --- a/src/android/ConfigManager.java +++ b/src/android/ConfigManager.java @@ -5,6 +5,7 @@ import java.util.HashMap; import edu.berkeley.eecs.emission.R; +import edu.berkeley.eecs.emission.cordova.tracker.wrapper.ConsentConfig; import edu.berkeley.eecs.emission.cordova.tracker.wrapper.LocationTrackingConfig; import edu.berkeley.eecs.emission.cordova.usercache.UserCacheFactory; @@ -37,4 +38,15 @@ protected static void updateConfig(Context context, LocationTrackingConfig newCo .putReadWriteDocument(R.string.key_usercache_sensor_config, newConfig); cachedConfig = newConfig; } + + public static boolean isConsented(Context context, String reqConsent) { + ConsentConfig currConfig = UserCacheFactory.getUserCache(context) + .getDocument(R.string.key_usercache_consent_config, ConsentConfig.class); + return reqConsent.equals(currConfig.getApproval_date()); + } + + public static void setConsented(Context context, ConsentConfig newConsent) { + UserCacheFactory.getUserCache(context) + .putReadWriteDocument(R.string.key_usercache_consent_config, newConsent); + } } diff --git a/src/android/DataCollectionPlugin.java b/src/android/DataCollectionPlugin.java index d8700825..41c7e116 100644 --- a/src/android/DataCollectionPlugin.java +++ b/src/android/DataCollectionPlugin.java @@ -20,6 +20,7 @@ import java.util.Map; import edu.berkeley.eecs.emission.*; +import edu.berkeley.eecs.emission.cordova.tracker.wrapper.ConsentConfig; import edu.berkeley.eecs.emission.cordova.tracker.wrapper.LocationTrackingConfig; import edu.berkeley.eecs.emission.cordova.tracker.location.TripDiaryStateMachineReceiver; import edu.berkeley.eecs.emission.cordova.unifiedlogger.Log; @@ -32,7 +33,9 @@ public void pluginInitialize() { final Activity myActivity = cordova.getActivity(); int connectionResult = GooglePlayServicesUtil.isGooglePlayServicesAvailable(myActivity); if (connectionResult == ConnectionResult.SUCCESS) { - Log.d(myActivity, TAG, "google play services available, initializing state machine"); + Log.d(myActivity, TAG, "google play services available, checking consent"); + String reqConsent = preferences.getString("emSensorDataCollectionProtocolApprovalDate", ""); + if (ConfigManager.isConsented(myActivity, reqConsent)) { // we want to run this in a separate thread, since it may take some time to get the // current location and create a geofence cordova.getThreadPool().execute(new Runnable() { @@ -41,18 +44,29 @@ public void run() { TripDiaryStateMachineReceiver.initOnUpgrade(myActivity); } }); + }; } else { Log.e(myActivity, TAG, "unable to connect to google play services"); } } - @Override public boolean execute(String action, JSONArray data, final CallbackContext callbackContext) throws JSONException { if (action.equals("launchInit")) { Log.d(cordova.getActivity(), TAG, "application launched, init is nop on android"); callbackContext.success(); return true; + } else if (action.equals("markConsented")) { + Log.d(cordova.getActivity(), TAG, "marking consent as done"); + Context ctxt = cordova.getActivity(); + JSONObject newConsent = data.getJSONObject(0); + ConsentConfig cfg = new Gson().fromJson(newConsent.toString(), ConsentConfig.class); + ConfigManager.setConsented(ctxt, cfg); + // Now, really initialize the state machine + TripDiaryStateMachineReceiver.initOnUpgrade(ctxt); + // TripDiaryStateMachineReceiver.restartCollection(ctxt); + callbackContext.success(); + return true; } else if (action.equals("getConfig")) { Context ctxt = cordova.getActivity(); LocationTrackingConfig cfg = ConfigManager.getConfig(ctxt); diff --git a/src/android/location/TripDiaryStateMachineReceiver.java b/src/android/location/TripDiaryStateMachineReceiver.java index e44d87b5..65e47465 100644 --- a/src/android/location/TripDiaryStateMachineReceiver.java +++ b/src/android/location/TripDiaryStateMachineReceiver.java @@ -8,6 +8,8 @@ import android.content.SharedPreferences; import android.preference.PreferenceManager; +import org.apache.cordova.ConfigXmlParser; + import java.util.Arrays; import java.util.HashSet; import java.util.Set; @@ -38,6 +40,7 @@ public class TripDiaryStateMachineReceiver extends BroadcastReceiver { public static Set validTransitions = null; private static String TAG = "TripDiaryStateMachineReceiver"; private static final String SETUP_COMPLETE_KEY = "setup_complete"; + private static final int STARTUP_IN_NUMBERS = 7827887; public TripDiaryStateMachineReceiver() { // The automatically created receiver needs a default constructor @@ -112,7 +115,17 @@ public static void initOnUpgrade(Context ctxt) { int currentCompleteVersion = sp.getInt(SETUP_COMPLETE_KEY, 0); if(currentCompleteVersion != BuildConfig.VERSION_CODE) { - Log.d(ctxt, TAG, "Setup not complete, sending initialize"); + Log.d(ctxt, TAG, "Setup not complete, checking consent before initialize"); + // this is the code that checks whether the native collection has been upgraded and + // restarts the data collection in that case. Without this, tracking is turned off + // until the user restarts the app. + // https://github.com/e-mission/e-mission-data-collection/commit/5544afd64b0c731e1633d1dd9f51a713fdea85fa + // Since every consent change is (presumably) tied to a native code change, we can + // just check for the consent here before reinitializing. + ConfigXmlParser parser = new ConfigXmlParser(); + parser.parse(ctxt); + String reqConsent = parser.getPreferences().getString("emSensorDataCollectionProtocolApprovalDate", null); + if (ConfigManager.isConsented(ctxt, reqConsent)) { ctxt.sendBroadcast(new Intent(ctxt.getString(R.string.transition_initialize))); SharedPreferences.Editor prefsEditor = sp.edit(); // TODO: This is supposed to be set from the javascript as part of the onboarding process. @@ -121,6 +134,10 @@ public static void initOnUpgrade(Context ctxt) { // some questions of the maintainer. For now, setting it here for the first time should be fine. prefsEditor.putInt(SETUP_COMPLETE_KEY, BuildConfig.VERSION_CODE); prefsEditor.commit(); + } else { + NotificationHelper.createNotification(ctxt, STARTUP_IN_NUMBERS, + "New data collection terms - collection paused until consent"); + } } else { Log.d(ctxt, TAG, "Setup complete, skipping initialize"); } diff --git a/src/ios/BEMDataCollection.m b/src/ios/BEMDataCollection.m index 75ae57ab..c809440d 100644 --- a/src/ios/BEMDataCollection.m +++ b/src/ios/BEMDataCollection.m @@ -19,11 +19,38 @@ - (void)pluginInitialize // I tried to move this into a background thread as part of a18f8f9385bdd9e37f7b412b386a911aee9a6ea0 and had // to revert it because even visit notification, which had been the bedrock of my existence so far, stopped // working although I made an explicit stop at the education library on the way to Soda. + NSString* reqConsent = self.commandDelegate.settings[@"emSensorDataCollectionProtocolApprovalDate"]; + if ([ConfigManager isConsented:reqConsent]) { self.tripDiaryStateMachine = [TripDiaryStateMachine instance]; + } else { + [LocalNotificationManager showNotification:@"New data collection terms - collection paused until consent"]; + } NSDictionary* emptyOptions = @{}; [AppDelegate didFinishLaunchingWithOptions:emptyOptions]; } +- (void)markConsented:(CDVInvokedUrlCommand*)command +{ + NSString* callbackId = [command callbackId]; + @try { + NSDictionary* newDict = [[command arguments] objectAtIndex:0]; + ConsentConfig* newCfg = [ConsentConfig new]; + [DataUtils dictToWrapper:newDict wrapper:newCfg]; + [ConfigManager setConsented:newCfg]; + self.tripDiaryStateMachine = [TripDiaryStateMachine instance]; + CDVPluginResult* result = [CDVPluginResult + resultWithStatus:CDVCommandStatus_OK]; + [self.commandDelegate sendPluginResult:result callbackId:callbackId]; + } + @catch (NSException *exception) { + NSString* msg = [NSString stringWithFormat: @"While updating settings, error %@", exception]; + CDVPluginResult* result = [CDVPluginResult + resultWithStatus:CDVCommandStatus_ERROR + messageAsString:msg]; + [self.commandDelegate sendPluginResult:result callbackId:callbackId]; + } +} + - (void)launchInit:(CDVInvokedUrlCommand*)command { NSString* callbackId = [command callbackId]; diff --git a/src/ios/ConfigManager.h b/src/ios/ConfigManager.h index 85a2042b..60f52c76 100644 --- a/src/ios/ConfigManager.h +++ b/src/ios/ConfigManager.h @@ -8,9 +8,12 @@ #import #import "LocationTrackingConfig.h" +#import "ConsentConfig.h" @interface ConfigManager : NSObject + (LocationTrackingConfig*) instance; + (void) updateConfig:(LocationTrackingConfig*) newConfig; ++ (BOOL) isConsented:(NSString*)reqConsent; ++ (void) setConsented:(ConsentConfig*) newConfig; @end diff --git a/src/ios/ConfigManager.m b/src/ios/ConfigManager.m index ebcff20b..babedb46 100644 --- a/src/ios/ConfigManager.m +++ b/src/ios/ConfigManager.m @@ -8,8 +8,10 @@ #import "ConfigManager.h" #import "BEMBuiltinUserCache.h" +#import "ConsentConfig.h" #define SENSOR_CONFIG_KEY @"key.usercache.sensor_config" +#define CONSENT_CONFIG_KEY @"key.usercache.consent_config" @implementation ConfigManager @@ -37,4 +39,18 @@ + (void) updateConfig:(LocationTrackingConfig*) newConfig { _instance = newConfig; } ++ (BOOL) isConsented:(NSString*)reqConsent { + ConsentConfig* currConfig = (ConsentConfig*)[[BuiltinUserCache database] getDocument:CONSENT_CONFIG_KEY wrapperClass:[ConsentConfig class]]; + if ([reqConsent isEqualToString:currConfig.approval_date]) { + return YES; + } else { + return NO; + } +} + ++ (void) setConsented:(ConsentConfig*)newConsent { + [[BuiltinUserCache database] putReadWriteDocument:CONSENT_CONFIG_KEY value:newConsent]; +} + + @end diff --git a/www/datacollection.js b/www/datacollection.js index e05e8b31..a29d962e 100644 --- a/www/datacollection.js +++ b/www/datacollection.js @@ -24,6 +24,11 @@ var DataCollection = { launchInit: function (successCallback, errorCallback) { exec(successCallback, errorCallback, "DataCollection", "launchInit", []); }, + markConsented: function (newConsent) { + return new Promise(function(resolve, reject) { + exec(resolve, reject, "DataCollection", "markConsented", [newConsent]); + }); + }, // Switching both the get and set config to a promise to experiment with promises!! getConfig: function () { return new Promise(function(resolve, reject) {