diff --git a/package.json b/package.json index ad9c2274..a955bf8e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cordova-plugin-em-datacollection", - "version": "1.6.0", + "version": "1.7.0", "description": "The main tracking for the e-mission platform", "license": "BSD-3-clause", "cordova": { diff --git a/plugin.xml b/plugin.xml index ce82bb2e..2931f91d 100644 --- a/plugin.xml +++ b/plugin.xml @@ -1,7 +1,7 @@ + version="1.7.0"> DataCollection Background data collection FTW! This is the part that I really @@ -117,9 +117,13 @@ - + - + + + + + @@ -144,6 +148,7 @@ + @@ -196,10 +201,16 @@ - + + + + + + + + - @@ -210,7 +221,9 @@ - + + + @@ -230,7 +243,9 @@ - + + + diff --git a/res/android/values/dc_strings.xml b/res/android/values/dc_strings.xml index 53f6f779..a3515634 100644 --- a/res/android/values/dc_strings.xml +++ b/res/android/values/dc_strings.xml @@ -17,9 +17,18 @@ Success moving to %1$s Failed moving to %1$s + Insufficient location permissions, please fix + Insufficient location permissions, please fix in app settings Location permission off, click to enable Background location permission off, click to enable + Activity permission off, please fix + Activity permission off, please fix in app settings Activity permission off, click to enable + Notifications blocked, please enable + Notifications paused. This can only be fixed by the app developer. Please report to your admin. + Please allow this app to read sensor data even if you launch it infrequently. + Incorrect app settings + Click to view and fix app status Error in location settings, click to resolve Unknown error while reading location, please check your settings In state %1$s @@ -28,6 +37,7 @@ Error reading stored tracking config, reset to defaults + It looks like you canceled the requested change. Please accept it instead. Unrecoverable error in tracking, report at Profile -> Upload Log Ready for your next trip diff --git a/res/ios/en.lproj/DCLocalizable.strings b/res/ios/en.lproj/DCLocalizable.strings index 28473105..cf5144de 100644 --- a/res/ios/en.lproj/DCLocalizable.strings +++ b/res/ios/en.lproj/DCLocalizable.strings @@ -2,14 +2,24 @@ "new-data-collections-terms" = "New data collection terms - collection paused until consent"; "error-reading-activities" = "Error while reading activities"; "travel-mode-unavailable" = "Travel mode detection may be unavailable."; -"activity-detection-unsupported" = "Activity detection unsupported"; -"activity-permission-problem" = "No 'Motion & Fitness' permission - automatic mode detection will not work. Turn it on (Settings -> app)"; -"activity-turned-off-problem" = "Motion & Fitness Service disabled - automatic mode detection will not work. Turn it on (Settings -> Privacy)"; +"activity-detection-unsupported" = "Activity detection unsupported. Please uninstall."; +"activity-permission-problem" = "'Motion & Fitness' permission off, please fix in app settings"; +"activity-turned-off-problem" = "Motion & Fitness Service disabled. Turn it on (Phone Settings -> Privacy) and *restart the app*"; "travel-mode-unknown" = "Travel mode detection unavailable - all trips will be UNKNOWN."; "bad-loc-tracking-problem" = "Background location accuracy is consistently poor - trip tracking may not work. Report problem."; -"location-turned-off-problem" = "Location Services are turned off - trip tracking will not work. Turn it on (Settings -> Privacy)"; +"location-turned-off-problem" = "Location Services are turned off. Turn it on (Settings -> Privacy)"; "fix-service-action-button" = "Launch Settings"; "permission-problem-title" = "Incorrect permission"; "location-permission-problem" = "The app does not have the 'always' permission - background trip tracking will not work."; "fix-permission-action-button" = "Fix permission"; "precise-location-problem" = "The app does not have permission to read 'precise' location - background trip tracking will not work."; +"notifications_blocked" = "Notifications blocked, please enable"; +"notifications_blocked_app_open" = "Notifications blocked, please fix in app settings and then refresh"; +"location_not_enabled" = "Location turned off, please turn on"; +"location_permission_off" = "Insufficient location permissions, please fix"; +"location_permission_off_app_open" = "Insufficient location permissions, please fix in app settings"; +"location_permission_off_enable" = "Insufficient location permissions, please enable"; +"activity_permission_off" = "Motion and Fitness permission off, please enable"; +"activity_permission_off_app_open" = "Motion and Fitness permission off, please fix in app settings"; +"fix_app_status_title" = "Incorrect app settings"; +"fix_app_status_text" = "Click to view and fix app status"; diff --git a/src/android/ConfigManager.java b/src/android/ConfigManager.java index c3065de6..7c36fc1b 100644 --- a/src/android/ConfigManager.java +++ b/src/android/ConfigManager.java @@ -37,7 +37,7 @@ public static LocationTrackingConfig getConfig(Context context) { } catch(JsonParseException e) { Log.e(context, TAG, "Found error " + e + "parsing sync config json, resetting to defaults"); NotificationHelper.createNotification(context, Constants.TRACKING_ERROR_ID, - context.getString(R.string.error_reading_stored_config)); + null, context.getString(R.string.error_reading_stored_config)); cachedConfig = new LocationTrackingConfig(); updateConfig(context, cachedConfig); } diff --git a/src/android/DataCollectionPlugin.java b/src/android/DataCollectionPlugin.java index d399f918..8b8bea88 100644 --- a/src/android/DataCollectionPlugin.java +++ b/src/android/DataCollectionPlugin.java @@ -1,5 +1,7 @@ package edu.berkeley.eecs.emission.cordova.tracker; +import edu.berkeley.eecs.emission.R; + import org.apache.cordova.CordovaPlugin; import org.apache.cordova.CordovaInterface; import org.apache.cordova.CallbackContext; @@ -62,15 +64,59 @@ public boolean execute(String action, JSONArray data, final CallbackContext call JSONObject newConsent = data.getJSONObject(0); ConsentConfig cfg = new Gson().fromJson(newConsent.toString(), ConsentConfig.class); ConfigManager.setConsented(ctxt, cfg); - TripDiaryStateMachineForegroundService.startProperly(cordova.getActivity().getApplication()); + // TripDiaryStateMachineForegroundService.startProperly(cordova.getActivity().getApplication()); // Now, really initialize the state machine // Note that we don't call initOnUpgrade so that we can handle the case where the // user deleted the consent and re-consented, but didn't upgrade the app - mControlDelegate.checkAndPromptPermissions(); - // ctxt.sendBroadcast(new ExplicitIntent(ctxt, R.string.transition_initialize)); + // mControlDelegate.checkAndPromptPermissions(); + ctxt.sendBroadcast(new ExplicitIntent(ctxt, R.string.transition_initialize)); // TripDiaryStateMachineReceiver.restartCollection(ctxt); callbackContext.success(); return true; + } else if (action.equals("fixLocationSettings")) { + Log.d(cordova.getActivity(), TAG, "fixing location settings"); + mControlDelegate.checkAndPromptLocationSettings(callbackContext); + return true; + } else if (action.equals("isValidLocationSettings")) { + Log.d(cordova.getActivity(), TAG, "checking location settings"); + mControlDelegate.checkLocationSettings(callbackContext); + return true; + } else if (action.equals("fixLocationPermissions")) { + Log.d(cordova.getActivity(), TAG, "fixing location permissions"); + mControlDelegate.checkAndPromptLocationPermissions(callbackContext); + return true; + } else if (action.equals("isValidLocationPermissions")) { + Log.d(cordova.getActivity(), TAG, "checking location permissions"); + mControlDelegate.checkLocationPermissions(callbackContext); + return true; + } else if (action.equals("fixFitnessPermissions")) { + Log.d(cordova.getActivity(), TAG, "fixing fitness permissions"); + mControlDelegate.checkAndPromptMotionActivityPermissions(callbackContext); + return true; + } else if (action.equals("isValidFitnessPermissions")) { + Log.d(cordova.getActivity(), TAG, "checking fitness permissions"); + mControlDelegate.checkMotionActivityPermissions(callbackContext); + return true; + } else if (action.equals("fixShowNotifications")) { + Log.d(cordova.getActivity(), TAG, "fixing notification enable"); + mControlDelegate.checkAndPromptShowNotificationsEnabled(callbackContext); + return true; + } else if (action.equals("isValidShowNotifications")) { + Log.d(cordova.getActivity(), TAG, "checking notification enable"); + mControlDelegate.checkShowNotificationsEnabled(callbackContext); + return true; + } else if (action.equals("isNotificationsUnpaused")) { + Log.d(cordova.getActivity(), TAG, "checking notification unpause"); + mControlDelegate.checkPausedNotifications(callbackContext); + return true; + } else if (action.equals("fixUnusedAppRestrictions")) { + Log.d(cordova.getActivity(), TAG, "fixing unused app restrictions"); + mControlDelegate.checkAndPromptUnusedAppsUnrestricted(callbackContext); + return true; + } else if (action.equals("isUnusedAppUnrestricted")) { + Log.d(cordova.getActivity(), TAG, "checking unused app restrictions"); + mControlDelegate.checkUnusedAppsUnrestricted(callbackContext); + return true; } else if (action.equals("storeBatteryLevel")) { Context ctxt = cordova.getActivity(); TripDiaryStateMachineReceiver.saveBatteryAndSimulateUser(ctxt); diff --git a/src/android/enable_api_31.gradle b/src/android/enable_api_31.gradle new file mode 100644 index 00000000..0f7b8e43 --- /dev/null +++ b/src/android/enable_api_31.gradle @@ -0,0 +1,7 @@ +ext.postBuildExtras = { + android { + defaultConfig { + compileSdkVersion 31 + } + } +} diff --git a/src/android/location/ActivityRecognitionChangeIntentService.java b/src/android/location/ActivityRecognitionChangeIntentService.java index 3ac469d4..1103e9d2 100644 --- a/src/android/location/ActivityRecognitionChangeIntentService.java +++ b/src/android/location/ActivityRecognitionChangeIntentService.java @@ -49,7 +49,7 @@ protected void onHandleIntent(Intent intent) { DetectedActivity mostProbableActivity = result.getMostProbableActivity(); Log.i(this, TAG, "Detected new activity "+mostProbableActivity); if (ConfigManager.getConfig(this).isSimulateUserInteraction()) { - NotificationHelper.createNotification(this, ACTIVITY_IN_NUMBERS,this.getString(R.string.detected_new_activity, activityType2Name(mostProbableActivity.getType(), this))); + NotificationHelper.createNotification(this, ACTIVITY_IN_NUMBERS, null, this.getString(R.string.detected_new_activity, activityType2Name(mostProbableActivity.getType(), this))); } // TODO: Do we want to compare activity and only store when different? // Can easily do that by getting the last activity diff --git a/src/android/location/ForegroundServiceComm.java b/src/android/location/ForegroundServiceComm.java index bce42e37..97aa1e12 100644 --- a/src/android/location/ForegroundServiceComm.java +++ b/src/android/location/ForegroundServiceComm.java @@ -45,7 +45,7 @@ public void setNewState(String newState) { mCtxt.bindService(fsi, connection, 0); } else { NotificationHelper.createNotification(mCtxt, Constants.TRACKING_ERROR_ID, - mCtxt.getString(R.string.unable_resolve_issue)); + null, mCtxt.getString(R.string.unable_resolve_issue)); Log.e(mCtxt, TAG, "Too many recursions, generating notification"); } } diff --git a/src/android/location/TripDiaryStateMachineForegroundService.java b/src/android/location/TripDiaryStateMachineForegroundService.java index cd62d1d2..8f082f51 100644 --- a/src/android/location/TripDiaryStateMachineForegroundService.java +++ b/src/android/location/TripDiaryStateMachineForegroundService.java @@ -73,7 +73,7 @@ public int onStartCommand(Intent intent, int flags, int startId) { " flags = " + flags + " and startId = " + startId); String message = humanizeState(this, TripDiaryStateMachineService.getState(this)); if (intent == null) { - SensorControlBackgroundChecker.checkLocationSettingsAndPermissions(this); + SensorControlBackgroundChecker.checkAppState(this); message = humanizeState(this, TripDiaryStateMachineService.getState(this)); } handleStart(message, intent, flags, startId); @@ -133,8 +133,8 @@ public void setStateMessage(String newState) { private Notification getNotification(String msg) { NotificationManager nMgr = (NotificationManager)this.getSystemService(Context.NOTIFICATION_SERVICE); - Notification.Builder builder = NotificationHelper.getNotificationBuilderForApp(this, - nMgr, msg); + Notification.Builder builder = NotificationHelper.getNotificationBuilderForApp(this, null, + msg); builder.setOngoing(true); Intent activityIntent = new Intent(this, MainActivity.class); @@ -172,7 +172,7 @@ private static Intent getForegroundServiceIntent(Context ctxt) { } public static void checkForegroundNotification(Context ctxt) { - if(Build.VERSION.SDK_INT >Build.VERSION_CODES.O) { + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { NotificationManager mgr = (NotificationManager) ctxt.getSystemService(Context.NOTIFICATION_SERVICE); StatusBarNotification[] activeNotifications = mgr.getActiveNotifications(); Log.d(ctxt, TAG, "In checkForegroundNotification, found " + activeNotifications.length + " active notifications"); diff --git a/src/android/location/TripDiaryStateMachineReceiver.java b/src/android/location/TripDiaryStateMachineReceiver.java index 7c5076d7..8ad0cb13 100644 --- a/src/android/location/TripDiaryStateMachineReceiver.java +++ b/src/android/location/TripDiaryStateMachineReceiver.java @@ -97,7 +97,7 @@ public void onReceive(Context context, Intent intent) { if (introDoneResult != null) { Log.i(context, TAG, reqConsent + " is not the current consented version, skipping init..."); NotificationHelper.createNotification(context, STARTUP_IN_NUMBERS, - context.getString(R.string.new_data_collection_terms)); + null, context.getString(R.string.new_data_collection_terms)); return; } else { Log.i(context, TAG, "onboarding is not complete, skipping prompt"); @@ -129,7 +129,7 @@ public static void performPeriodicActivity(Context ctxt) { } public static void checkLocationStillAvailable(Context ctxt) { - SensorControlBackgroundChecker.checkLocationSettingsAndPermissions(ctxt); + SensorControlBackgroundChecker.checkAppState(ctxt); } public static void validateAndCleanupState(Context ctxt) { @@ -173,7 +173,7 @@ public static void saveBatteryAndSimulateUser(Context ctxt) { Battery currInfo = BatteryUtils.getBatteryInfo(ctxt); UserCacheFactory.getUserCache(ctxt).putSensorData(R.string.key_usercache_battery, currInfo); if (ConfigManager.getConfig(ctxt).isSimulateUserInteraction()) { - NotificationHelper.createNotification(ctxt, 1234, ctxt.getString(R.string.battery_level, + NotificationHelper.createNotification(ctxt, 1234, null, ctxt.getString(R.string.battery_level, currInfo.getBatteryLevelPct())); } } diff --git a/src/android/location/TripDiaryStateMachineService.java b/src/android/location/TripDiaryStateMachineService.java index df553d04..4276f8e0 100644 --- a/src/android/location/TripDiaryStateMachineService.java +++ b/src/android/location/TripDiaryStateMachineService.java @@ -116,7 +116,7 @@ public void setNewState(String newState, boolean doChecks) { // This makes the rest of the code much simpler, allows us to catch issues as quickly as possible, // and if (doChecks) { - SensorControlBackgroundChecker.checkLocationSettingsAndPermissions(TripDiaryStateMachineService.this); + SensorControlBackgroundChecker.checkAppState(TripDiaryStateMachineService.this); } stopSelf(); } @@ -240,7 +240,7 @@ public void handleTripStart(Context ctxt, final String actionString) { if (TripDiaryStateMachineService.isAllSuccessful(resultList)) { if (ConfigManager.getConfig(fCtxt).isSimulateUserInteraction()) { NotificationHelper.createNotification(fCtxt, STATE_IN_NUMBERS, - fCtxt.getString(R.string.success_moving_new_state, newState)); + null, fCtxt.getString(R.string.success_moving_new_state, newState)); } setNewState(newState, true); } else { @@ -256,7 +256,7 @@ public void handleTripStart(Context ctxt, final String actionString) { } if (ConfigManager.getConfig(fCtxt).isSimulateUserInteraction()) { NotificationHelper.createNotification(fCtxt, STATE_IN_NUMBERS, - fCtxt.getString(R.string.failed_moving_new_state,newState)); + null, fCtxt.getString(R.string.failed_moving_new_state,newState)); } // both branches have called setState or are waiting for sth else }); // listener end return; // handled the transition, returning @@ -319,7 +319,7 @@ public void run() { if (TripDiaryStateMachineService.isAllSuccessful(resultList)) { if (ConfigManager.getConfig(ctxt).isSimulateUserInteraction()) { NotificationHelper.createNotification(fCtxt, STATE_IN_NUMBERS, - fCtxt.getString(R.string.success_moving_new_state, newState)); + null, fCtxt.getString(R.string.success_moving_new_state, newState)); } setNewState(newState, true); } else { @@ -338,12 +338,12 @@ public void run() { // "Error " + batchResult.getStatus().getStatusCode()+" while creating geofence"); // let's mark this operation as done since the other one is static // markOngoingOperationFinished(); - SensorControlBackgroundChecker.checkLocationSettingsAndPermissions(TripDiaryStateMachineService.this); + SensorControlBackgroundChecker.checkAppState(TripDiaryStateMachineService.this); // will wait for async call to complete } if (ConfigManager.getConfig(ctxt).isSimulateUserInteraction()) { NotificationHelper.createNotification(fCtxt, STATE_IN_NUMBERS, - fCtxt.getString(R.string.failed_moving_new_state, newState)); + null, fCtxt.getString(R.string.failed_moving_new_state, newState)); } }); @@ -429,7 +429,7 @@ public void run() { setNewState(newState, true); if (ConfigManager.getConfig(ctxt).isSimulateUserInteraction()) { NotificationHelper.createNotification(fCtxt, STATE_IN_NUMBERS, - fCtxt.getString(R.string.success_moving_new_state, newState)); + null, fCtxt.getString(R.string.success_moving_new_state, newState)); } } else { Log.e(fCtxt, TAG, "error while creating geofence, staying in the current state"); @@ -443,7 +443,7 @@ public void run() { } if (ConfigManager.getConfig(fCtxt).isSimulateUserInteraction()) { NotificationHelper.createNotification(fCtxt, STATE_IN_NUMBERS, - fCtxt.getString(R.string.failed_moving_new_state, newState)); + null, fCtxt.getString(R.string.failed_moving_new_state, newState)); } }); } else { @@ -451,7 +451,7 @@ public void run() { // own state change // let's mark this operation as done since the other one is static // markOngoingOperationFinished(); - SensorControlBackgroundChecker.checkLocationSettingsAndPermissions(fCtxt); + SensorControlBackgroundChecker.checkAppState(fCtxt); } } }).start(); @@ -468,16 +468,16 @@ private void deleteGeofence(Context ctxt, final String targetState) { setNewState(newState, true); if (ConfigManager.getConfig(fCtxt).isSimulateUserInteraction()) { NotificationHelper.createNotification(fCtxt, STATE_IN_NUMBERS, - fCtxt.getString(R.string.success_moving_new_state, newState)); + null, fCtxt.getString(R.string.success_moving_new_state, newState)); } } else { setNewState(mCurrState, true); // markOngoingOperationFinished(); - SensorControlBackgroundChecker.checkLocationSettingsAndPermissions(TripDiaryStateMachineService.this); + SensorControlBackgroundChecker.checkAppState(TripDiaryStateMachineService.this); } if (ConfigManager.getConfig(fCtxt).isSimulateUserInteraction()) { NotificationHelper.createNotification(fCtxt, STATE_IN_NUMBERS, - fCtxt.getString(R.string.failed_moving_new_state, newState)); + null, fCtxt.getString(R.string.failed_moving_new_state, newState)); } }); } @@ -496,13 +496,13 @@ private void stopAll(Context ctxt, final String targetState) { if (TripDiaryStateMachineService.isAllSuccessful(resultList)) { if (ConfigManager.getConfig(fCtxt).isSimulateUserInteraction()) { NotificationHelper.createNotification(fCtxt, STATE_IN_NUMBERS, - fCtxt.getString(R.string.success_moving_new_state, newState)); + null, fCtxt.getString(R.string.success_moving_new_state, newState)); } setNewState(newState, false); } else { if (ConfigManager.getConfig(fCtxt).isSimulateUserInteraction()) { NotificationHelper.createNotification(fCtxt, STATE_IN_NUMBERS, - fCtxt.getString(R.string.failed_moving_new_state, newState)); + null, fCtxt.getString(R.string.failed_moving_new_state, newState)); } if (!resultList.get(1).isSuccessful()) { diff --git a/src/android/location/TripDiaryStateMachineServiceOngoing.java b/src/android/location/TripDiaryStateMachineServiceOngoing.java index af2019ad..8457a7d6 100644 --- a/src/android/location/TripDiaryStateMachineServiceOngoing.java +++ b/src/android/location/TripDiaryStateMachineServiceOngoing.java @@ -244,12 +244,12 @@ private void startEverything(final Context ctxt, String actionString) { setNewState(newState); if (ConfigManager.getConfig(ctxt).isSimulateUserInteraction()) { NotificationHelper.createNotification(fCtxt, STATE_IN_NUMBERS, - fCtxt.getString(R.string.success_moving_new_state, newState)); + null, fCtxt.getString(R.string.success_moving_new_state, newState)); } } else { if (ConfigManager.getConfig(ctxt).isSimulateUserInteraction()) { NotificationHelper.createNotification(fCtxt, STATE_IN_NUMBERS, - fCtxt.getString(R.string.failed_moving_new_state,newState)); + null, fCtxt.getString(R.string.failed_moving_new_state,newState)); } } }); @@ -266,12 +266,12 @@ private void stopEverything(final Context ctxt, final String targetState) { setNewState(newState); if (ConfigManager.getConfig(fCtxt).isSimulateUserInteraction()) { NotificationHelper.createNotification(fCtxt, STATE_IN_NUMBERS, - fCtxt.getString(R.string.success_moving_new_state, newState)); + null, fCtxt.getString(R.string.success_moving_new_state, newState)); } } else { if (ConfigManager.getConfig(fCtxt).isSimulateUserInteraction()) { NotificationHelper.createNotification(fCtxt, STATE_IN_NUMBERS, - fCtxt.getString(R.string.failed_moving_new_state,newState)); + null, fCtxt.getString(R.string.failed_moving_new_state,newState)); } } }); diff --git a/src/android/location/actions/GeofenceActions.java b/src/android/location/actions/GeofenceActions.java index a8d3878c..a630cfa1 100644 --- a/src/android/location/actions/GeofenceActions.java +++ b/src/android/location/actions/GeofenceActions.java @@ -202,7 +202,7 @@ public void notifyFailure() { Log.w(mCtxt, TAG, "Unable to detect current location even after forcing, will retry at next sync"); NotificationHelper.createNotification(mCtxt, GEOFENCE_IN_NUMBERS, - mCtxt.getString(R.string.unable_detect_current_location)); + null, mCtxt.getString(R.string.unable_detect_current_location)); } /* diff --git a/src/android/verification/SensorControlBackgroundChecker.java b/src/android/verification/SensorControlBackgroundChecker.java index 77134dae..7fcc4293 100644 --- a/src/android/verification/SensorControlBackgroundChecker.java +++ b/src/android/verification/SensorControlBackgroundChecker.java @@ -1,5 +1,7 @@ package edu.berkeley.eecs.emission.cordova.tracker.verification; // Auto fixed by post-plugin hook +import edu.berkeley.eecs.emission.R; +// Auto fixed by post-plugin hook import android.app.PendingIntent; import android.content.Context; import android.content.Intent; @@ -15,17 +17,22 @@ import com.google.android.gms.location.LocationSettingsRequest; import com.google.android.gms.location.LocationSettingsResponse; import com.google.android.gms.location.LocationSettingsStatusCodes; +import com.google.android.gms.tasks.OnCompleteListener; import com.google.android.gms.tasks.Task; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Arrays; + import edu.berkeley.eecs.emission.cordova.tracker.Constants; import edu.berkeley.eecs.emission.cordova.tracker.ExplicitIntent; import edu.berkeley.eecs.emission.cordova.tracker.location.TripDiaryStateMachineService; import edu.berkeley.eecs.emission.cordova.tracker.location.actions.LocationTrackingActions; import edu.berkeley.eecs.emission.cordova.unifiedlogger.Log; import edu.berkeley.eecs.emission.cordova.unifiedlogger.NotificationHelper; -import gov.colorado.energyoffice.emission.MainActivity; -// Auto fixed by post-plugin hook -import gov.colorado.energyoffice.emission.R; + + /* * Deals with settings and resolutions from the background as a service. @@ -39,6 +46,25 @@ public class SensorControlBackgroundChecker { private static int STATE_IN_NUMBERS = 78283; + private static JSONObject OPEN_APP_STATUS_PAGE(Context ctxt) { + try { + JSONObject config = new JSONObject(); + config.put("id", SensorControlConstants.OPEN_APP_STATUS_PAGE); + config.put("title", ctxt.getString(R.string.fix_app_status_title)); + config.put("text", ctxt.getString(R.string.fix_app_status_text)); + JSONObject redirectData = new JSONObject(); + redirectData.put("redirectTo", "root.main.control"); + JSONObject redirectParams = new JSONObject(); + redirectParams.put("launchAppStatusModal", true); + redirectData.put("redirectParams", redirectParams); + config.put("data", redirectData); + return config; + } catch (JSONException e) { + e.printStackTrace(); + } + return null; + } + public static void restartFSMIfStartState(Context ctxt) { String START_STATE = ctxt.getString(R.string.state_start); String currState = TripDiaryStateMachineService.getState(ctxt); @@ -49,153 +75,64 @@ public static void restartFSMIfStartState(Context ctxt) { } } - public static void checkLocationSettingsAndPermissions(final Context ctxt) { - LocationRequest request = new LocationTrackingActions(ctxt).getLocationRequest(); - Log.d(ctxt, TAG, "Checking location settings and permissions for request "+request); - // let's do the permission check first since it is synchronous - if (checkLocationPermissions(ctxt, request)) { - Log.d(ctxt, TAG, "checkLocationPermissions returned true, checking background permission"); - if ((Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) || - checkBackgroundLocPermissions(ctxt, request)) { - Log.d(ctxt, TAG, "checkBackgroundLocPermissions returned true, checking location settings"); - checkLocationSettings(ctxt, request); - } else { - Log.d(ctxt, TAG, "check background permissions returned false, no point checking settings"); - ctxt.sendBroadcast(new ExplicitIntent(ctxt, R.string.transition_tracking_error)); + public static void checkAppState(final Context ctxt) { + NotificationHelper.cancelNotification(ctxt, + SensorControlConstants.OPEN_APP_STATUS_PAGE); + SensorControlChecks.checkLocationSettings(ctxt, resultTask -> { + try { + LocationSettingsResponse response = resultTask.getResult(ApiException.class); + // All location settings are satisfied. The client can initialize location + // requests here. + Log.i(ctxt, TAG, "All settings are valid, checking current state"); + Log.i(ctxt, TAG, "Current location settings are "+response); + + // Now that we know that the location settings are correct, we start the permission checks + boolean[] allOtherChecks = new boolean[]{ + SensorControlChecks.checkLocationPermissions(ctxt), + SensorControlChecks.checkMotionActivityPermissions(ctxt), + SensorControlChecks.checkNotificationsEnabled(ctxt), + SensorControlChecks.checkUnusedAppsUnrestricted(ctxt) + }; + boolean allOtherChecksPass = true; + for (boolean check: allOtherChecks) { + allOtherChecksPass = allOtherChecksPass && check; } - // final state will be set in this async call - } else { - Log.d(ctxt, TAG, "check location permissions returned false, no point checking settings"); - ctxt.sendBroadcast(new ExplicitIntent(ctxt, R.string.transition_tracking_error)); - } - - if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) && checkMotionActivityPermissions(ctxt)) { - Log.d(ctxt, TAG, "checkMotionActivityPermissions returned true, nothing more yet"); - } else { - Log.d(ctxt, TAG, "checkMotionActivityPermissions returned false, but that's not a tracking error"); - } - } - - private static boolean checkLocationPermissions(final Context ctxt, - final LocationRequest request) { - // Ideally, we would use the request accuracy to figure out the permissions requested - // but I can't find an authoritative mapping, and I'm running out of time for - // fancy stuff - int result = ContextCompat.checkSelfPermission(ctxt, SensorControlConstants.LOCATION_PERMISSION); - Log.d(ctxt, TAG, "checkSelfPermission returned "+result); - if (PackageManager.PERMISSION_GRANTED == result) { - return true; - } else { - generateLocationEnableNotification(ctxt); - return false; - } - } - - public static void generateLocationEnableNotification(Context ctxt) { - Intent activityIntent = new Intent(ctxt, MainActivity.class); - activityIntent.setAction(SensorControlConstants.ENABLE_LOCATION_PERMISSION_ACTION); - PendingIntent pi = PendingIntent.getActivity(ctxt, SensorControlConstants.ENABLE_LOCATION_PERMISSION, - activityIntent, PendingIntent.FLAG_UPDATE_CURRENT); - NotificationHelper.createNotification(ctxt, SensorControlConstants.ENABLE_LOCATION_PERMISSION, - ctxt.getString(R.string.location_permission_off_enable), - pi); - } - - private static boolean checkBackgroundLocPermissions(final Context ctxt, - final LocationRequest request) { - int result = ContextCompat.checkSelfPermission(ctxt, SensorControlConstants.BACKGROUND_LOC_PERMISSION); - Log.d(ctxt, TAG, "checkSelfPermission returned "+result); - if (PackageManager.PERMISSION_GRANTED == result) { - return true; - } else { - generateBackgroundLocEnableNotification(ctxt); - return false; - } - } - - public static void generateBackgroundLocEnableNotification(Context ctxt) { - Intent activityIntent = new Intent(ctxt, MainActivity.class); - activityIntent.setAction(SensorControlConstants.ENABLE_BACKGROUND_LOC_PERMISSION_ACTION); - PendingIntent pi = PendingIntent.getActivity(ctxt, SensorControlConstants.ENABLE_BACKGROUND_LOC_PERMISSION, - activityIntent, PendingIntent.FLAG_UPDATE_CURRENT); - NotificationHelper.createNotification(ctxt, SensorControlConstants.ENABLE_BACKGROUND_LOC_PERMISSION, - ctxt.getString(R.string.background_loc_permission_off_enable), - pi); - } - private static boolean checkMotionActivityPermissions(final Context ctxt) { - int result = ContextCompat.checkSelfPermission(ctxt, SensorControlConstants.MOTION_ACTIVITY_PERMISSION); - Log.d(ctxt, TAG, "checkSelfPermission returned "+result); - if (PackageManager.PERMISSION_GRANTED == result) { - return true; - } else { - generateMotionActivityEnableNotification(ctxt); - return false; + /* + Using index-based iteration since we need to start from index 1 instead of 0 and array slices + are hard in Java + */ + boolean nonLocChecksPass = true; + for (int i = 1; i < allOtherChecks.length; i++) { + nonLocChecksPass = nonLocChecksPass && allOtherChecks[i]; + } + + if (allOtherChecksPass) { + Log.d(ctxt, TAG, "All settings valid, nothing to prompt"); + restartFSMIfStartState(ctxt); + } + else if (allOtherChecks[0]) { + Log.i(ctxt, TAG, "all checks = "+allOtherChecksPass+" but location permission status "+allOtherChecks[0]+" should be true "+ + " so one of the non-location checks must be false: loc permission, motion permission, notification, unused apps" + Arrays.toString(allOtherChecks)); + Log.i(ctxt, TAG, "a non-local check failed, generating only user visible notification"); + generateOpenAppSettingsNotification(ctxt); + } + else { + Log.i(ctxt, TAG, "location settings are valid, but location permission is not, generating tracking error and visible notification"); + Log.i(ctxt, TAG, "curr status check results = " + + " loc permission, motion permission, notification, unused apps "+ Arrays.toString(allOtherChecks)); + ctxt.sendBroadcast(new ExplicitIntent(ctxt, R.string.transition_tracking_error)); + generateOpenAppSettingsNotification(ctxt); } - } - - public static void generateMotionActivityEnableNotification(Context ctxt) { - Intent activityIntent = new Intent(ctxt, MainActivity.class); - activityIntent.setAction(SensorControlConstants.ENABLE_MOTION_ACTIVITY_PERMISSION_ACTION); - PendingIntent pi = PendingIntent.getActivity(ctxt, SensorControlConstants.ENABLE_MOTION_ACTIVITY_PERMISSION, - activityIntent, PendingIntent.FLAG_UPDATE_CURRENT); - NotificationHelper.createNotification(ctxt, SensorControlConstants.ENABLE_MOTION_ACTIVITY_PERMISSION, - ctxt.getString(R.string.activity_permission_off_enable), - pi); - } - - private static void checkLocationSettings(final Context ctxt, - final LocationRequest request) { - LocationSettingsRequest.Builder builder = new LocationSettingsRequest.Builder() - .addLocationRequest(request); - - Task task = - LocationServices.getSettingsClient(ctxt).checkLocationSettings(builder.build()); - Log.d(ctxt, TAG, "Got back result "+task); - task.addOnCompleteListener(resultTask -> { - try { - LocationSettingsResponse response = task.getResult(ApiException.class); - // All location settings are satisfied. The client can initialize location - // requests here. - Log.i(ctxt, TAG, "All settings are valid, checking current state"); - Log.i(ctxt, TAG, "Current location settings are "+response); - NotificationHelper.cancelNotification(ctxt, Constants.TRACKING_ERROR_ID); - restartFSMIfStartState(ctxt); } catch (ApiException exception) { - switch (exception.getStatusCode()) { - case LocationSettingsStatusCodes.RESOLUTION_REQUIRED: - Log.i(ctxt, TAG, "location settings are not valid, but could be fixed by showing the user a dialog"); - // Location settings are not satisfied. But could be fixed by showing the - // user a dialog. - try { - // Cast to a resolvable exception. - ResolvableApiException resolvable = (ResolvableApiException) exception; - // Show the dialog by calling startResolutionForResult(), - // and check the result in onActivityResult(). - NotificationHelper.createResolveNotification(ctxt, Constants.TRACKING_ERROR_ID, - ctxt.getString(R.string.error_location_settings), - resolvable.getResolution()); - ctxt.sendBroadcast(new ExplicitIntent(ctxt, R.string.transition_tracking_error)); - } catch (ClassCastException e) { - // Ignore, should be an impossible error. - } - break; - case LocationSettingsStatusCodes.SETTINGS_CHANGE_UNAVAILABLE: - // Location settings are not satisfied. However, we have no way to fix the - // settings so we won't show the dialog. - Log.i(ctxt, TAG, "location settings are not valid, but cannot be fixed by showing a dialog"); - NotificationHelper.createNotification(ctxt, Constants.TRACKING_ERROR_ID, - ctxt.getString(R.string.error_location_settings, exception.getStatusCode())); - ctxt.sendBroadcast(new ExplicitIntent(ctxt, R.string.transition_tracking_error)); - break; - default: - Log.i(ctxt, TAG, "unkown error reading location"); - NotificationHelper.createNotification(ctxt, Constants.TRACKING_ERROR_ID, - ctxt.getString(R.string.unknown_error_location_settings)); - ctxt.sendBroadcast(new ExplicitIntent(ctxt, R.string.transition_tracking_error)); - - } + Log.i(ctxt, TAG, "location settings are invalid, generating tracking error and visible notification"); + ctxt.sendBroadcast(new ExplicitIntent(ctxt, R.string.transition_tracking_error)); + generateOpenAppSettingsNotification(ctxt); } }); } + + public static void generateOpenAppSettingsNotification(Context ctxt) { + NotificationHelper.schedulePluginCompatibleNotification(ctxt, OPEN_APP_STATUS_PAGE(ctxt), null); + } } diff --git a/src/android/verification/SensorControlChecks.java b/src/android/verification/SensorControlChecks.java new file mode 100644 index 00000000..40e8fc77 --- /dev/null +++ b/src/android/verification/SensorControlChecks.java @@ -0,0 +1,123 @@ +package edu.berkeley.eecs.emission.cordova.tracker.verification; + +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.location.Location; +import android.os.Build; + +import androidx.annotation.RequiresApi; +import androidx.core.app.NotificationManagerCompat; +import androidx.core.content.ContextCompat; +import androidx.core.content.PermissionChecker; +import androidx.core.content.PackageManagerCompat; +import androidx.core.content.UnusedAppRestrictionsConstants; + +import com.google.android.gms.common.api.ApiException; +import com.google.android.gms.common.api.ResolvableApiException; +import com.google.android.gms.location.LocationRequest; +import com.google.android.gms.location.LocationServices; +import com.google.android.gms.location.LocationSettingsRequest; +import com.google.android.gms.location.LocationSettingsResponse; +import com.google.android.gms.location.LocationSettingsStates; +import com.google.android.gms.location.LocationSettingsStatusCodes; +import com.google.android.gms.tasks.OnCompleteListener; +import com.google.android.gms.tasks.Task; +import com.google.common.util.concurrent.ListenableFuture; + + +import java.util.List; +import java.util.concurrent.ExecutionException; + +import edu.berkeley.eecs.emission.cordova.tracker.Constants; +import edu.berkeley.eecs.emission.cordova.tracker.ExplicitIntent; +import edu.berkeley.eecs.emission.cordova.tracker.location.actions.LocationTrackingActions; +import edu.berkeley.eecs.emission.cordova.unifiedlogger.Log; +import edu.berkeley.eecs.emission.cordova.unifiedlogger.NotificationHelper; + +public class SensorControlChecks { + public static final String TAG = "SensorControlChecks"; + + public static void checkLocationSettings(final Context ctxt, + OnCompleteListener callback) { + Log.i(ctxt, TAG, "About to check location settings"); + LocationRequest request = new LocationTrackingActions(ctxt).getLocationRequest(); + Log.d(ctxt, TAG, "Checking location settings for request "+request); + LocationSettingsRequest.Builder builder = new LocationSettingsRequest.Builder() + .addLocationRequest(request); + + Task task = + LocationServices.getSettingsClient(ctxt).checkLocationSettings(builder.build()); + Log.d(ctxt, TAG, "Got back result "+task); + task.addOnCompleteListener(callback); + } + + // TODO: Figure out how to integrate this with the background code + // https://github.com/e-mission/e-mission-docs/issues/680#issuecomment-953403832 + public static boolean checkLocationPermissions(final Context ctxt) { + boolean foregroundPerm = ContextCompat.checkSelfPermission(ctxt, SensorControlConstants.LOCATION_PERMISSION) == PermissionChecker.PERMISSION_GRANTED; + // the background permission is only valid for Q+ + boolean backgroundPerm = true; + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + backgroundPerm = ContextCompat.checkSelfPermission(ctxt, SensorControlConstants.BACKGROUND_LOC_PERMISSION) == PermissionChecker.PERMISSION_GRANTED; + } + return foregroundPerm && backgroundPerm; + } + + // TODO: Figure out how to integrate this with the background code + // https://github.com/e-mission/e-mission-docs/issues/680#issuecomment-953403832 + public static boolean checkMotionActivityPermissions(final Context ctxt) { + // apps before version 29 did not need to prompt for dynamic permissions related + // to motion activity + boolean version29Check = Build.VERSION.SDK_INT < Build.VERSION_CODES.Q; + boolean permCheck = ContextCompat.checkSelfPermission(ctxt, SensorControlConstants.MOTION_ACTIVITY_PERMISSION) == PermissionChecker.PERMISSION_GRANTED; + return version29Check || permCheck; + } + + public static boolean checkNotificationsEnabled(final Context ctxt) { + NotificationManagerCompat nMgr = NotificationManagerCompat.from(ctxt); + boolean appDisabled = nMgr.areNotificationsEnabled(); + boolean channelsDisabled = false; + // notification channels did not exist before oreo, so they + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + List channels = nMgr.getNotificationChannels(); + for (NotificationChannel c : channels) { + boolean currChannelDisabled = false; + currChannelDisabled = (c.getImportance() == NotificationManager.IMPORTANCE_NONE); + channelsDisabled = channelsDisabled || currChannelDisabled; + } + } + return appDisabled || channelsDisabled; + } + + public static boolean checkNotificationsUnpaused(final Context ctxt) { + NotificationManager nMgr = (NotificationManager) ctxt.getSystemService(Context.NOTIFICATION_SERVICE); + boolean appUnpaused = true; + // app notification pausing apparently did not exist before API 29, so we return unpaused = true + // by default + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + appUnpaused = !(nMgr.areNotificationsPaused()); + } + return appUnpaused; + } + + public static boolean checkUnusedAppsUnrestricted(final Context ctxt) { + ListenableFuture future = PackageManagerCompat.getUnusedAppRestrictionsStatus(ctxt); + try { + Integer appRestrictionStatus = future.get(); + switch(appRestrictionStatus) { + case UnusedAppRestrictionsConstants.ERROR: return false; + case UnusedAppRestrictionsConstants.FEATURE_NOT_AVAILABLE: return true; + case UnusedAppRestrictionsConstants.DISABLED: return true; + case UnusedAppRestrictionsConstants.API_30_BACKPORT: + case UnusedAppRestrictionsConstants.API_30: + case UnusedAppRestrictionsConstants.API_31: + return false; + } + } catch (ExecutionException | InterruptedException e) { + return false; + } + return false; + } +} diff --git a/src/android/verification/SensorControlConstants.java b/src/android/verification/SensorControlConstants.java index e0e4c9ad..72b66f30 100644 --- a/src/android/verification/SensorControlConstants.java +++ b/src/android/verification/SensorControlConstants.java @@ -9,13 +9,18 @@ public class SensorControlConstants { public static String MOTION_ACTIVITY_PERMISSION = Manifest.permission.ACTIVITY_RECOGNITION; public static final int ENABLE_LOCATION_SETTINGS = 362253738; + public static final int ENABLE_LOCATION_SETTINGS_MANUAL = 362253736; public static final int ENABLE_LOCATION_PERMISSION = 362253737; public static final int ENABLE_BACKGROUND_LOC_PERMISSION = 362253739; public static final int ENABLE_BOTH_PERMISSION = 362253740; public static final int ENABLE_MOTION_ACTIVITY_PERMISSION = 362253741; + public static final int ENABLE_NOTIFICATIONS = 362253742; + public static final int REMOVE_UNUSED_APP_RESTRICTIONS = 362253743; + public static final int OPEN_APP_STATUS_PAGE = 362253744; public static final String ENABLE_LOCATION_PERMISSION_ACTION = "ENABLE_LOCATION_PERMISSION"; public static final String ENABLE_BACKGROUND_LOC_PERMISSION_ACTION = "ENABLE_BACKGROUND_LOC_PERMISSION"; public static final String ENABLE_MOTION_ACTIVITY_PERMISSION_ACTION = "ENABLE_MOTION_ACTIVITY_PERMISSION"; + public static final String OPEN_APP_STATUS_PAGE_ACTION ="OPEN_APP_STATUS_PAGE"; } diff --git a/src/android/verification/SensorControlForegroundDelegate.java b/src/android/verification/SensorControlForegroundDelegate.java index ff317238..b7cf3db4 100644 --- a/src/android/verification/SensorControlForegroundDelegate.java +++ b/src/android/verification/SensorControlForegroundDelegate.java @@ -1,9 +1,11 @@ package edu.berkeley.eecs.emission.cordova.tracker.verification; // Auto fixed by post-plugin hook +import edu.berkeley.eecs.emission.R; + + import edu.berkeley.eecs.emission.cordova.tracker.Constants; -import edu.berkeley.eecs.emission.cordova.tracker.location.TripDiaryStateMachineService; import edu.berkeley.eecs.emission.cordova.unifiedlogger.Log; -import gov.colorado.energyoffice.emission.R; + /* * Deals with settings and resolutions when the app activity is visible. @@ -13,9 +15,11 @@ * - Dealing with user responses */ +import org.apache.cordova.CallbackContext; import org.apache.cordova.CordovaInterface; import org.apache.cordova.CordovaPlugin; import org.json.JSONException; +import org.json.JSONObject; import android.app.Activity; import android.content.Context; @@ -28,9 +32,19 @@ import android.provider.Settings; +import androidx.core.app.ActivityCompat; +import androidx.core.content.IntentCompat; + +import com.google.android.gms.common.api.ApiException; +import com.google.android.gms.common.api.ResolvableApiException; +import com.google.android.gms.location.LocationSettingsResponse; import com.google.android.gms.location.LocationSettingsStates; +import com.google.android.gms.location.LocationSettingsStatusCodes; import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; import edu.berkeley.eecs.emission.cordova.unifiedlogger.NotificationHelper; @@ -39,74 +53,360 @@ public class SensorControlForegroundDelegate { private CordovaPlugin plugin = null; private CordovaInterface cordova = null; + private CallbackContext cordovaCallback = null; + private PermissionPopupChecker permissionChecker = null; + private Map permissionCheckerMap = new HashMap<>(); + + class PermissionPopupChecker { + int permissionStatusConstant = -1; + boolean shouldShowRequestRationaleBefore = false; + String deniedString; + String retryString; + boolean openAppSettings; + + String[] currPermissions; - public SensorControlForegroundDelegate(CordovaPlugin iplugin, CordovaInterface icordova) { - plugin = iplugin; - cordova = icordova; + public PermissionPopupChecker(int permissionStatusConstant, + String[] permissions, + String retryString, + String deniedString) { + this.permissionStatusConstant = permissionStatusConstant; + currPermissions = permissions; + this.deniedString = deniedString; + this.retryString = retryString; + } + + private boolean shouldShowRequestForCurrPermissions() { + boolean ssrrb = false; + for (String cp: currPermissions){ + boolean css = ActivityCompat.shouldShowRequestPermissionRationale(cordova.getActivity(), cp); + Log.d(cordova.getActivity(), TAG, "For permission "+cp+" shouldShowRequest = " + css); + ssrrb |= css; + } + return ssrrb; + } + + void requestPermission() { + shouldShowRequestRationaleBefore = shouldShowRequestForCurrPermissions(); + Log.d(cordova.getActivity(), TAG, + String.format("After iterating over all entries in %s shouldShowRequest = %s", currPermissions, shouldShowRequestRationaleBefore)); + if (openAppSettings) { + SensorControlForegroundDelegate.this.openAppSettingsPage(cordovaCallback, permissionStatusConstant); + } else { + if (currPermissions.length > 1) { + cordova.requestPermissions(plugin, permissionStatusConstant, currPermissions); + } else { + Log.e(cordova.getActivity(), TAG, "currPermissions.length = " + currPermissions.length); + cordova.requestPermission(plugin, permissionStatusConstant, currPermissions[0]); + } + } + } + + void generateErrorCallback() { + if (cordovaCallback == null) { + NotificationHelper.createNotification(cordova.getActivity(), Constants.TRACKING_ERROR_ID, null, "Please upload log and report issue in generateErrorCallback"); + return; + } + boolean shouldShowRequestRationaleAfter = shouldShowRequestForCurrPermissions(); + Log.d(cordova.getActivity(), TAG, "In permission prompt, error callback,"+ + " before = "+shouldShowRequestRationaleBefore+" after = "+shouldShowRequestRationaleAfter); + // see the issue for more details + // https://github.com/e-mission/e-mission-docs/issues/680#issuecomment-958438153 + + if (!shouldShowRequestRationaleBefore && !shouldShowRequestRationaleAfter) { + // before = FALSE, after = FALSE => user had denied it earlier + openAppSettings = true; + cordovaCallback.error(deniedString); + } + if (!shouldShowRequestRationaleBefore && shouldShowRequestRationaleAfter) { + // before = FALSE, after = TRUE => first time ask + cordovaCallback.error(retryString); + } + if (shouldShowRequestRationaleBefore && !shouldShowRequestRationaleAfter) { + // before = TRUE, after = FALSE => popup was shown, user hit don't ask + openAppSettings = true; + cordovaCallback.error(deniedString); + } + if (shouldShowRequestRationaleBefore && shouldShowRequestRationaleAfter) { + // before = TRUE, after = TRUE => popup was shown, user hit deny ONLY + cordovaCallback.error(retryString); + } + } } - public void checkAndPromptPermissions() { - checkAndPromptLocationPermissions(); - checkAndPromptMotionActivityPermissions(); + public SensorControlForegroundDelegate(CordovaPlugin inPlugin, + CordovaInterface inCordova) { + plugin = inPlugin; + cordova = inCordova; } - private void checkAndPromptLocationPermissions() { + private PermissionPopupChecker getPermissionChecker(int permissionStatusConstant, + String permission, + String retryString, + String deniedString) { + return getPermissionChecker(permissionStatusConstant, new String[]{permission}, retryString, deniedString); + } + private PermissionPopupChecker getPermissionChecker(int permissionStatusConstant, + String[] permissions, + String retryString, + String deniedString) { + PermissionPopupChecker pc = permissionCheckerMap.get(Integer.valueOf(permissionStatusConstant)); + if (pc == null) { + pc = new PermissionPopupChecker(permissionStatusConstant, + permissions, retryString, deniedString); + permissionCheckerMap.put(Integer.valueOf(permissionStatusConstant), pc); + } + return pc; + } + + // Invokes the callback with a boolean indicating whether + // the location settings are correct or not + public void checkLocationSettings(CallbackContext cordovaCallback) { + Activity currActivity = cordova.getActivity(); + SensorControlChecks.checkLocationSettings(currActivity, + resultTask -> { + try { + LocationSettingsResponse response = resultTask.getResult(ApiException.class); + // All location settings are satisfied. The client can initialize location + // requests here. + Log.i(currActivity, TAG, "All settings are valid, checking current state"); + Log.i(currActivity, TAG, "Current location settings are "+response.getLocationSettingsStates()); + cordovaCallback.success(Objects.requireNonNull(response.getLocationSettingsStates()).toString()); + } catch (ApiException exception) { + Log.i(currActivity, TAG, "Settings are not valid, returning "+exception.getMessage()); + cordovaCallback.error(exception.getLocalizedMessage()); + } + }); + } + + public void checkAndPromptLocationSettings(CallbackContext callbackContext) { + Activity currActivity = cordova.getActivity(); + SensorControlChecks.checkLocationSettings(currActivity, resultTask -> { + try { + LocationSettingsResponse response = resultTask.getResult(ApiException.class); + // All location settings are satisfied. The client can initialize location + // requests here. + Log.i(currActivity, TAG, "All settings are valid, checking current state"); + JSONObject lssJSON = statesToJSON(response.getLocationSettingsStates()); + Log.i(currActivity, TAG, "Current location settings are "+lssJSON); + SensorControlBackgroundChecker.restartFSMIfStartState(cordova.getActivity()); + callbackContext.success(lssJSON); + } catch (ApiException exception) { + switch (exception.getStatusCode()) { + case LocationSettingsStatusCodes.RESOLUTION_REQUIRED: + Log.i(currActivity, TAG, "location settings are not valid, but could be fixed by showing the user a dialog"); + // Location settings are not satisfied. But could be fixed by showing the + // user a dialog. + try { + // Cast to a resolvable exception. + ResolvableApiException resolvable = (ResolvableApiException) exception; + // Show the dialog by calling startResolutionForResult(), + // and check the result in onActivityResult(). + // Experiment with "send" instead of this resolution code + this.cordovaCallback = callbackContext; + cordova.setActivityResultCallback(plugin); + resolvable.startResolutionForResult(currActivity, SensorControlConstants.ENABLE_LOCATION_SETTINGS); + } catch (IntentSender.SendIntentException | ClassCastException sie) { + callbackContext.error(sie.getLocalizedMessage()); + } + break; + case LocationSettingsStatusCodes.SETTINGS_CHANGE_UNAVAILABLE: + // Location settings are not satisfied. However, we have no way to fix the + // settings so we won't show the dialog. + Log.i(currActivity, TAG, "location settings are not valid, but cannot be fixed by showing a dialog"); + openLocationSettingsPage(callbackContext); + break; + default: + Log.i(currActivity, TAG, "unknown error reading location"); + openLocationSettingsPage(callbackContext); + } + } catch (JSONException e) { + callbackContext.error(e.getLocalizedMessage()); + } + }); + } + + private static JSONObject statesToJSON(LocationSettingsStates lss) throws JSONException { + // TODO: Does this need to be internationalized? + if (lss == null) { + throw new JSONException("null input"); + } + JSONObject jo = new JSONObject(); + jo.put("Bluetooth", lss.isBleUsable()); + jo.put("GPS", lss.isGpsUsable()); + jo.put("Network", lss.isNetworkLocationUsable()); + jo.put("location", lss.isLocationUsable()); + return jo; + } + + private void openLocationSettingsPage(CallbackContext callbackContext) { + this.cordovaCallback = callbackContext; + cordova.setActivityResultCallback(plugin); + Intent locSettingsIntent = new Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS); + cordova.getActivity().startActivityForResult(locSettingsIntent, + SensorControlConstants.ENABLE_LOCATION_SETTINGS_MANUAL); + } + + private void openAppSettingsPage(CallbackContext callbackContext, int requestCode) { + Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); + intent.setData(Uri.fromParts("package", cordova.getActivity().getPackageName(), null)); + this.cordovaCallback = callbackContext; + cordova.setActivityResultCallback(plugin); + cordova.getActivity().startActivityForResult(intent, requestCode); + } + + public void checkLocationPermissions(CallbackContext cordovaCallback) { + boolean validPerms = SensorControlChecks.checkLocationPermissions(cordova.getActivity()); + if(validPerms) { + cordovaCallback.success(); + } else { + cordovaCallback.error(cordova.getActivity().getString(R.string.location_permission_off)); + } + } + + public void checkAndPromptLocationPermissions(CallbackContext cordovaCallback) { if(cordova.hasPermission(SensorControlConstants.LOCATION_PERMISSION) && cordova.hasPermission(SensorControlConstants.BACKGROUND_LOC_PERMISSION)) { SensorControlBackgroundChecker.restartFSMIfStartState(cordova.getActivity()); - return; + cordovaCallback.success(); } // If this is android 11 (API 30), we want to launch the app settings instead of prompting for permission // because the default permission prompting does not offer "always" as an option // https://github.com/e-mission/e-mission-docs/issues/608 // we don't really care about which level of permission is missing since the prompt doesn't // do anything anyway. If either permission is missing, we just open the app settings - // Note also that we should actually check for VERSION_CODES.R - // but since we are not targeting API 30 yet, we can't do that - // so we use Q (29) + 1 instead. I think that is more readable than 30 - if ((Build.VERSION.SDK_INT >= (Build.VERSION_CODES.Q + 1)) && + if ((Build.VERSION.SDK_INT >= (Build.VERSION_CODES.R)) && (!cordova.hasPermission(SensorControlConstants.LOCATION_PERMISSION) || !cordova.hasPermission(SensorControlConstants.BACKGROUND_LOC_PERMISSION))) { - Activity mAct = cordova.getActivity(); String msgString = " LOC = "+cordova.hasPermission(SensorControlConstants.LOCATION_PERMISSION)+ " BACKGROUND LOC "+ cordova.hasPermission(SensorControlConstants.BACKGROUND_LOC_PERMISSION)+ " Android R+, so opening app settings anyway"; Log.i(cordova.getActivity(), TAG, msgString); - // These are to hopefully help us get a callback once the settings are changed - Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); - intent.setData(Uri.fromParts("package", mAct.getPackageName(), null)); - cordova.setActivityResultCallback(plugin); - mAct.startActivityForResult(intent, SensorControlConstants.ENABLE_BOTH_PERMISSION); + openAppSettingsPage(cordovaCallback, SensorControlConstants.ENABLE_BOTH_PERMISSION); return; } if(!cordova.hasPermission(SensorControlConstants.LOCATION_PERMISSION) && - (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) && + (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) && !cordova.hasPermission(SensorControlConstants.BACKGROUND_LOC_PERMISSION)) { Log.i(cordova.getActivity(), TAG, "Both permissions missing, requesting both"); - cordova.requestPermissions(plugin, SensorControlConstants.ENABLE_BOTH_PERMISSION, - new String[]{SensorControlConstants.LOCATION_PERMISSION, SensorControlConstants.BACKGROUND_LOC_PERMISSION}); + this.cordovaCallback = cordovaCallback; + this.permissionChecker = getPermissionChecker( + SensorControlConstants.ENABLE_BOTH_PERMISSION, + new String[]{SensorControlConstants.BACKGROUND_LOC_PERMISSION, SensorControlConstants.LOCATION_PERMISSION}, + cordova.getActivity().getString(R.string.location_permission_off), + cordova.getActivity().getString(R.string.location_permission_off_app_open)); + this.permissionChecker.requestPermission(); return; } if(!cordova.hasPermission(SensorControlConstants.LOCATION_PERMISSION)) { + Log.i(cordova.getActivity(), TAG, "before call shouldShowRequestPermissionRationale = "+ ActivityCompat.shouldShowRequestPermissionRationale(cordova.getActivity(), SensorControlConstants.LOCATION_PERMISSION)); Log.i(cordova.getActivity(), TAG, "Only location permission missing, requesting it"); - cordova.requestPermission(plugin, SensorControlConstants.ENABLE_LOCATION_PERMISSION, SensorControlConstants.LOCATION_PERMISSION); + this.cordovaCallback = cordovaCallback; + this.permissionChecker = getPermissionChecker( + SensorControlConstants.ENABLE_LOCATION_PERMISSION, + SensorControlConstants.LOCATION_PERMISSION, + cordova.getActivity().getString(R.string.location_permission_off), + cordova.getActivity().getString(R.string.location_permission_off_app_open)); + this.permissionChecker.requestPermission(); return; } if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && !cordova.hasPermission(SensorControlConstants.BACKGROUND_LOC_PERMISSION)) { Log.i(cordova.getActivity(), TAG, "Only background permission missing, requesting it"); - cordova.requestPermission(plugin, SensorControlConstants.ENABLE_BACKGROUND_LOC_PERMISSION, SensorControlConstants.BACKGROUND_LOC_PERMISSION); + this.cordovaCallback = cordovaCallback; + this.permissionChecker = getPermissionChecker( + SensorControlConstants.ENABLE_BACKGROUND_LOC_PERMISSION, + SensorControlConstants.BACKGROUND_LOC_PERMISSION, + cordova.getActivity().getString(R.string.location_permission_off), + cordova.getActivity().getString(R.string.location_permission_off_app_open)); + this.permissionChecker.requestPermission(); return; } } - private void checkAndPromptMotionActivityPermissions() { - if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && !cordova.hasPermission(SensorControlConstants.MOTION_ACTIVITY_PERMISSION)) { - Log.i(cordova.getActivity(), TAG, "Only motion activity permission missing, requesting it"); - cordova.requestPermission(plugin, SensorControlConstants.ENABLE_MOTION_ACTIVITY_PERMISSION, SensorControlConstants.MOTION_ACTIVITY_PERMISSION); - return; + public void checkMotionActivityPermissions(CallbackContext cordovaCallback) { + boolean validPerms = SensorControlChecks.checkMotionActivityPermissions(cordova.getActivity()); + if(validPerms) { + cordovaCallback.success(); + } else { + cordovaCallback.error(cordova.getActivity().getString(R.string.activity_permission_off)); + } + } + + public void checkAndPromptMotionActivityPermissions(CallbackContext cordovaCallback) { + boolean validPerms = SensorControlChecks.checkMotionActivityPermissions(cordova.getActivity()); + if(validPerms) { + SensorControlBackgroundChecker.restartFSMIfStartState(cordova.getActivity()); + cordovaCallback.success(); + } else { + Log.i(cordova.getActivity(), TAG, "before call shouldShowRequestPermissionRationale = "+ ActivityCompat.shouldShowRequestPermissionRationale(cordova.getActivity(), SensorControlConstants.MOTION_ACTIVITY_PERMISSION)); + Log.i(cordova.getActivity(), TAG, "Motion activity permission missing, requesting it"); + this.cordovaCallback = cordovaCallback; + this.permissionChecker = getPermissionChecker( + SensorControlConstants.ENABLE_MOTION_ACTIVITY_PERMISSION, + SensorControlConstants.MOTION_ACTIVITY_PERMISSION, + cordova.getActivity().getString(R.string.activity_permission_off), + cordova.getActivity().getString(R.string.activity_permission_off_app_open)); + this.permissionChecker.requestPermission(); } } + public void checkShowNotificationsEnabled(CallbackContext cordovaCallback) { + boolean validPerms = SensorControlChecks.checkNotificationsEnabled(cordova.getActivity()); + if(validPerms) { + cordovaCallback.success(); + } else { + cordovaCallback.error(cordova.getActivity().getString(R.string.activity_permission_off)); + } + } + + public void checkAndPromptShowNotificationsEnabled(CallbackContext cordovaCallback) { + boolean validPerms = SensorControlChecks.checkNotificationsEnabled(cordova.getActivity()); + if(validPerms) { + SensorControlBackgroundChecker.restartFSMIfStartState(cordova.getActivity()); + cordovaCallback.success(); + } else { + Log.i(cordova.getActivity(), TAG, "Notifications not enabled, opening app page"); + // TODO: switch to Settings.ACTION_APP_NOTIFICATION_SETTINGS instead of the app page + // once our min SDK goes up to oreo + openAppSettingsPage(cordovaCallback, SensorControlConstants.ENABLE_NOTIFICATIONS); + } + } + + public void checkPausedNotifications(CallbackContext cordovaCallback) { + boolean unpaused = SensorControlChecks.checkNotificationsUnpaused(cordova.getActivity()); + if(unpaused) { + cordovaCallback.success(); + } else { + Log.i(cordova.getActivity(), TAG, "Notifications paused, asking user to report"); + cordovaCallback.error(cordova.getActivity().getString(R.string.notifications_paused)); + } + } + + public void checkUnusedAppsUnrestricted(CallbackContext cordovaCallback) { + boolean unrestricted = SensorControlChecks.checkUnusedAppsUnrestricted(cordova.getActivity()); + if (unrestricted) { + cordovaCallback.success(); + } else { + Log.i(cordova.getActivity(), TAG, "Unused apps restricted, asking user to unrestrict"); + cordovaCallback.error(cordova.getActivity().getString(R.string.unused_apps_restricted)); + } + } + + + public void checkAndPromptUnusedAppsUnrestricted(CallbackContext cordovaCallback) { + boolean unrestricted = SensorControlChecks.checkUnusedAppsUnrestricted(cordova.getActivity()); + if (unrestricted) { + SensorControlBackgroundChecker.restartFSMIfStartState(cordova.getActivity()); + cordovaCallback.success(); + } else { + Log.i(cordova.getActivity(), TAG, "Unused apps restricted, asking user to unrestrict"); + this.cordovaCallback = cordovaCallback; + cordova.setActivityResultCallback(plugin); + Intent intent = IntentCompat.createManageUnusedAppRestrictionsIntent(cordova.getActivity(), cordova.getActivity().getPackageName()); + cordova.getActivity().startActivityForResult(intent, SensorControlConstants.REMOVE_UNUSED_APP_RESTRICTIONS); + } + } + private void displayResolution(PendingIntent resolution) { if (resolution != null) { try { @@ -114,29 +414,13 @@ private void displayResolution(PendingIntent resolution) { cordova.getActivity().startIntentSenderForResult(resolution.getIntentSender(), SensorControlConstants.ENABLE_LOCATION_SETTINGS, null, 0, 0, 0, null); } catch (IntentSender.SendIntentException e) { Context mAct = cordova.getActivity(); - NotificationHelper.createNotification(mAct, Constants.TRACKING_ERROR_ID, mAct.getString(R.string.unable_resolve_issue)); + NotificationHelper.createNotification(mAct, Constants.TRACKING_ERROR_ID, null, mAct.getString(R.string.unable_resolve_issue)); } } } public void onNewIntent(Intent intent) { - if(SensorControlConstants.ENABLE_LOCATION_PERMISSION_ACTION.equals(intent.getAction()) || - SensorControlConstants.ENABLE_BACKGROUND_LOC_PERMISSION_ACTION.equals(intent.getAction())) { - checkAndPromptLocationPermissions(); - return; - } - - if(SensorControlConstants.ENABLE_MOTION_ACTIVITY_PERMISSION_ACTION.equals(intent.getAction())) { - checkAndPromptMotionActivityPermissions(); - return; - } - if (NotificationHelper.DISPLAY_RESOLUTION_ACTION.equals(intent.getAction())) { - PendingIntent piFromIntent = intent.getParcelableExtra( - NotificationHelper.RESOLUTION_PENDING_INTENT_KEY); - displayResolution(piFromIntent); - return; - } - Log.i(cordova.getActivity(), TAG, "Action "+intent.getAction()+" unknown, ignoring "); + Log.i(cordova.getActivity(), TAG, "onNewIntent("+intent+") received, ignoring"); } public void onRequestPermissionResult(int requestCode, String[] permissions, @@ -145,6 +429,7 @@ public void onRequestPermissionResult(int requestCode, String[] permissions, Log.i(cordova.getActivity(), TAG, "onRequestPermissionResult called with "+requestCode); Log.i(cordova.getActivity(), TAG, "permissions are "+ Arrays.toString(permissions)); Log.i(cordova.getActivity(), TAG, "grantResults are "+Arrays.toString(grantResults)); + /* Let us figure out if we want to sent a javascript callback with the error. This is currently only called from markConsented, and I don't think we listen to failures there @@ -157,85 +442,122 @@ public void onRequestPermissionResult(int requestCode, String[] permissions, } } */ + if (this.permissionChecker == null) { + NotificationHelper.createNotification(cordova.getActivity(), Constants.TRACKING_ERROR_ID, null, "Please upload log and report issue in requestPermissionResult"); + return; + } switch(requestCode) { case SensorControlConstants.ENABLE_BOTH_PERMISSION: + Log.i(cordova.getActivity(), TAG, "in callback shouldShowRequestPermissionRationale = "+ ActivityCompat.shouldShowRequestPermissionRationale(cordova.getActivity(), SensorControlConstants.LOCATION_PERMISSION)); if ((grantResults[0] == PackageManager.PERMISSION_GRANTED) && (grantResults[1] == PackageManager.PERMISSION_GRANTED)) { - NotificationHelper.cancelNotification(cordova.getActivity(), SensorControlConstants.ENABLE_BOTH_PERMISSION); SensorControlBackgroundChecker.restartFSMIfStartState(cordova.getActivity()); - } else if (grantResults[0] == PackageManager.PERMISSION_DENIED) { - SensorControlBackgroundChecker.generateLocationEnableNotification(cordova.getActivity()); - } else if (grantResults[1] == PackageManager.PERMISSION_DENIED) { - SensorControlBackgroundChecker.generateBackgroundLocEnableNotification(cordova.getActivity()); + cordovaCallback.success(); + } else if (grantResults[0] == PackageManager.PERMISSION_DENIED || grantResults[1] == PackageManager.PERMISSION_DENIED) { + this.permissionChecker.generateErrorCallback(); } + this.permissionChecker = null; break; case SensorControlConstants.ENABLE_LOCATION_PERMISSION: - if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { - NotificationHelper.cancelNotification(cordova.getActivity(), SensorControlConstants.ENABLE_LOCATION_PERMISSION); - SensorControlBackgroundChecker.restartFSMIfStartState(cordova.getActivity()); - } else if (grantResults[0] == PackageManager.PERMISSION_DENIED) { - SensorControlBackgroundChecker.generateLocationEnableNotification(cordova.getActivity()); - } - break; case SensorControlConstants.ENABLE_BACKGROUND_LOC_PERMISSION: - if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { - NotificationHelper.cancelNotification(cordova.getActivity(), SensorControlConstants.ENABLE_BACKGROUND_LOC_PERMISSION); - SensorControlBackgroundChecker.restartFSMIfStartState(cordova.getActivity()); - } else if (grantResults[0] == PackageManager.PERMISSION_DENIED) { - SensorControlBackgroundChecker.generateBackgroundLocEnableNotification(cordova.getActivity()); - } - break; case SensorControlConstants.ENABLE_MOTION_ACTIVITY_PERMISSION: + // Code for all these is the same. We ask for a single permission + // and if it is denied, we generate the error callback + // the exact message is stored in the permission checker object if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { - NotificationHelper.cancelNotification(cordova.getActivity(), SensorControlConstants.ENABLE_MOTION_ACTIVITY_PERMISSION); - // motion activity does not affect the FSM + SensorControlBackgroundChecker.restartFSMIfStartState(cordova.getActivity()); + cordovaCallback.success(); } else if (grantResults[0] == PackageManager.PERMISSION_DENIED) { - SensorControlBackgroundChecker.generateMotionActivityEnableNotification(cordova.getActivity()); + this.permissionChecker.generateErrorCallback(); } + this.permissionChecker = null; break; default: Log.e(cordova.getActivity(), TAG, "Unknown permission code "+requestCode+" ignoring"); } } - public void onActivityResult(int requestCode, int resultCode, Intent data) { Activity mAct = cordova.getActivity(); + cordova.setActivityResultCallback(null); switch (requestCode) { case SensorControlConstants.ENABLE_LOCATION_SETTINGS: Log.d(mAct, TAG, requestCode + " is our code, handling callback"); - cordova.setActivityResultCallback(null); final LocationSettingsStates states = LocationSettingsStates.fromIntent(data); - Log.d(cordova.getActivity(), TAG, "at this point, isLocationUsable = " + states.isLocationUsable()); + Log.d(cordova.getActivity(), TAG, "at this point, isLocationUsable = " + (states != null && states.isLocationUsable())); switch (resultCode) { case Activity.RESULT_OK: // All required changes were successfully made Log.i(cordova.getActivity(), TAG, "All changes successfully made, reinitializing"); - NotificationHelper.cancelNotification(mAct, Constants.TRACKING_ERROR_ID); - SensorControlBackgroundChecker.restartFSMIfStartState(mAct); + try { + SensorControlBackgroundChecker.restartFSMIfStartState(cordova.getActivity()); + cordovaCallback.success(statesToJSON(states)); + } catch (JSONException e) { + cordovaCallback.error(mAct.getString(R.string.unknown_error_location_settings)); + } break; case Activity.RESULT_CANCELED: - // The user was asked to change settings, but chose not to - Log.e(cordova.getActivity(), TAG, "User chose not to change settings, dunno what to do"); + Log.i(cordova.getActivity(), TAG, "request " + requestCode + " cancelled, failing"); + cordova.setActivityResultCallback(null); + cordovaCallback.error(cordova.getActivity().getString(R.string.user_rejected_setting)); break; default: + cordovaCallback.error(mAct.getString(R.string.unable_resolve_issue)); Log.e(cordova.getActivity(), TAG, "Unknown result code while enabling location " + resultCode); break; } + break; + case SensorControlConstants.ENABLE_LOCATION_SETTINGS_MANUAL: + Log.d(mAct, TAG, requestCode + " is our code, handling callback"); + // this will call the callback with success or error + checkLocationSettings(cordovaCallback); + break; case SensorControlConstants.ENABLE_BOTH_PERMISSION: + case SensorControlConstants.ENABLE_LOCATION_PERMISSION: + case SensorControlConstants.ENABLE_BACKGROUND_LOC_PERMISSION: + Log.d(mAct, TAG, requestCode + " is our code, handling callback"); + Log.d(mAct, TAG, "Got permission callback from launching app settings when prompt failed"); + if (SensorControlChecks.checkLocationPermissions(cordova.getActivity())) { + SensorControlBackgroundChecker.restartFSMIfStartState(cordova.getActivity()); + cordovaCallback.success(); + } else { + // this is the activity result callback, so only launched when the app settings are used + // so we don't need to use the permission checker, we know that the only option + // is to launch the settings + cordovaCallback.error(cordova.getActivity().getString(R.string.location_permission_off_app_open)); + } + break; + case SensorControlConstants.ENABLE_MOTION_ACTIVITY_PERMISSION: Log.d(mAct, TAG, requestCode + " is our code, handling callback"); - cordova.setActivityResultCallback(null); Log.d(mAct, TAG, "Got permission callback from launching app settings"); - if (cordova.hasPermission(SensorControlConstants.LOCATION_PERMISSION)) { - // location permission enabled, cancelling notification - NotificationHelper.cancelNotification(cordova.getActivity(), SensorControlConstants.ENABLE_LOCATION_PERMISSION); + if (SensorControlChecks.checkMotionActivityPermissions(cordova.getActivity())) { + SensorControlBackgroundChecker.restartFSMIfStartState(cordova.getActivity()); + cordovaCallback.success(); + } else { + permissionChecker.generateErrorCallback(); } - if (cordova.hasPermission(SensorControlConstants.BACKGROUND_LOC_PERMISSION)) { - // background location permission enabled, cancelling notification - NotificationHelper.cancelNotification(cordova.getActivity(), SensorControlConstants.ENABLE_BACKGROUND_LOC_PERMISSION); + permissionChecker = null; + break; + case SensorControlConstants.ENABLE_NOTIFICATIONS: + Log.d(mAct, TAG, requestCode + " is our code, handling callback"); + Log.d(mAct, TAG, "Got notification callback from launching app settings"); + if (SensorControlChecks.checkNotificationsEnabled(cordova.getActivity())) { + SensorControlBackgroundChecker.restartFSMIfStartState(cordova.getActivity()); + cordovaCallback.success(); + } else { + cordovaCallback.error(cordova.getActivity().getString(R.string.notifications_blocked)); + } + break; + case SensorControlConstants.REMOVE_UNUSED_APP_RESTRICTIONS: + Log.d(mAct, TAG, requestCode + " is our code, handling callback"); + Log.d(mAct, TAG, "Got unused app restrictions callback from launching app settings"); + if (SensorControlChecks.checkUnusedAppsUnrestricted(cordova.getActivity())) { + SensorControlBackgroundChecker.restartFSMIfStartState(cordova.getActivity()); + cordovaCallback.success(); + } else { + cordovaCallback.error(cordova.getActivity().getString(R.string.unused_apps_restricted)); } - SensorControlBackgroundChecker.restartFSMIfStartState(cordova.getActivity()); break; default: Log.d(cordova.getActivity(), TAG, "Got unsupported request code " + requestCode + " , ignoring..."); diff --git a/src/ios/BEMAppDelegate.h b/src/ios/BEMAppDelegate.h index 9af4c70d..3f0553ea 100644 --- a/src/ios/BEMAppDelegate.h +++ b/src/ios/BEMAppDelegate.h @@ -10,6 +10,7 @@ #import "TripDiaryStateMachine.h" #import "AppDelegate.h" #import "BEMServerSyncCommunicationHelper.h" +#define NotificationCallback @"NOTIFICATION_CALLBACK" @interface AppDelegate (datacollection) diff --git a/src/ios/BEMDataCollection.h b/src/ios/BEMDataCollection.h index c3c74def..eb1a4e4e 100644 --- a/src/ios/BEMDataCollection.h +++ b/src/ios/BEMDataCollection.h @@ -4,6 +4,17 @@ @interface BEMDataCollection: CDVPlugin - (void) pluginInitialize; +- (void) markConsented:(CDVInvokedUrlCommand*)command; +- (void) fixLocationSettings:(CDVInvokedUrlCommand*)command; +- (void) isValidLocationSettings:(CDVInvokedUrlCommand*)command; +- (void) fixLocationPermissions:(CDVInvokedUrlCommand*)command; +- (void) isValidLocationPermissions:(CDVInvokedUrlCommand*)command; +- (void) fixFitnessPermissions:(CDVInvokedUrlCommand*)command; +- (void) isValidFitnessPermissions:(CDVInvokedUrlCommand*)command; +- (void) fixShowNotifications:(CDVInvokedUrlCommand*)command; +- (void) isValidShowNotifications:(CDVInvokedUrlCommand*)command; +- (void) isNotificationsUnpaused:(CDVInvokedUrlCommand*)command; +- (void) fixOEMBackgroundRestrictions: (CDVInvokedUrlCommand*) command; - (void) launchInit:(CDVInvokedUrlCommand*)command; - (void) getConfig:(CDVInvokedUrlCommand*)command; - (void) setConfig:(CDVInvokedUrlCommand*)command; diff --git a/src/ios/BEMDataCollection.m b/src/ios/BEMDataCollection.m index 6bb59408..ba5510ad 100644 --- a/src/ios/BEMDataCollection.m +++ b/src/ios/BEMDataCollection.m @@ -6,7 +6,7 @@ #import "DataUtils.h" #import "StatsEvent.h" #import "BEMBuiltinUserCache.h" -#import "TripDiarySettingsCheck.h" +#import "SensorControlForegroundDelegate.h" #import @implementation BEMDataCollection @@ -65,7 +65,9 @@ - (void)markConsented:(CDVInvokedUrlCommand*)command // which is actually easier ("always allow") // so in that case, we continue calling the init code in TripDiaryStateMachine [self initWithConsent]; + /* [TripDiarySettingsCheck checkMotionSettingsAndPermission:FALSE]; + */ CDVPluginResult* result = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK]; [self.commandDelegate sendPluginResult:result callbackId:callbackId]; @@ -79,17 +81,80 @@ - (void)markConsented:(CDVInvokedUrlCommand*)command } } -- (void)storeBatteryLevel:(CDVInvokedUrlCommand*)command +- (void)fixLocationSettings:(CDVInvokedUrlCommand*)command +{ + [[[SensorControlForegroundDelegate alloc] initWithDelegate:self.commandDelegate + forCommand:command] checkAndPromptLocationSettings]; +} + +- (void)isValidLocationSettings:(CDVInvokedUrlCommand*)command +{ + [[[SensorControlForegroundDelegate alloc] initWithDelegate:self.commandDelegate + forCommand:command] checkLocationSettings]; + +} + +- (void)fixLocationPermissions:(CDVInvokedUrlCommand*)command +{ + [[[SensorControlForegroundDelegate alloc] initWithDelegate:self.commandDelegate + forCommand:command] checkAndPromptLocationPermissions]; +} + +- (void)isValidLocationPermissions:(CDVInvokedUrlCommand*)command +{ + [[[SensorControlForegroundDelegate alloc] initWithDelegate:self.commandDelegate + forCommand:command] checkLocationPermissions]; +} + +- (void)fixFitnessPermissions:(CDVInvokedUrlCommand*)command +{ + [[[SensorControlForegroundDelegate alloc] initWithDelegate:self.commandDelegate + forCommand:command] checkAndPromptFitnessPermissions]; +} + +- (void)isValidFitnessPermissions:(CDVInvokedUrlCommand*)command +{ + [[[SensorControlForegroundDelegate alloc] initWithDelegate:self.commandDelegate + forCommand:command] checkMotionActivityPermissions]; + +} + +- (void)fixShowNotifications:(CDVInvokedUrlCommand*)command +{ + [[[SensorControlForegroundDelegate alloc] initWithDelegate:self.commandDelegate + forCommand:command] checkAndPromptNotificationPermission]; +} + + +- (void)isValidShowNotifications:(CDVInvokedUrlCommand*)command +{ + [[[SensorControlForegroundDelegate alloc] initWithDelegate:self.commandDelegate + forCommand:command] checkNotificationsEnabled]; +} + +- (void)isNotificationsUnpaused:(CDVInvokedUrlCommand*)command +{ + [self NOP_RETURN_TRUE:command forMethod:@"isNotificationsUnpaused"]; +} + +- (void)fixOEMBackgroundRestrictions: (CDVInvokedUrlCommand*) command +{ + [self NOP_RETURN_TRUE:command forMethod:@"fixOEMBackgroundRestrictions"]; +} + +- (void)NOP_RETURN_TRUE:(CDVInvokedUrlCommand*) command forMethod:(NSString*) methodName { NSString* callbackId = [command callbackId]; + @try { - [DataUtils saveBatteryAndSimulateUser]; + [LocalNotificationManager addNotification:[NSString stringWithFormat: + @"%@ called, is NOP on iOS", methodName] showUI:FALSE]; CDVPluginResult* result = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK]; [self.commandDelegate sendPluginResult:result callbackId:callbackId]; } @catch (NSException *exception) { - NSString* msg = [NSString stringWithFormat: @"While storing battery, error %@", exception]; + NSString* msg = [NSString stringWithFormat: @"While getting settings, error %@", exception]; CDVPluginResult* result = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR messageAsString:msg]; @@ -97,20 +162,17 @@ - (void)storeBatteryLevel:(CDVInvokedUrlCommand*)command } } - -- (void)launchInit:(CDVInvokedUrlCommand*)command +- (void)storeBatteryLevel:(CDVInvokedUrlCommand*)command { NSString* callbackId = [command callbackId]; - @try { - [LocalNotificationManager addNotification:[NSString stringWithFormat: - @"launchInit called, is NOP on iOS"] showUI:FALSE]; + [DataUtils saveBatteryAndSimulateUser]; CDVPluginResult* result = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK]; [self.commandDelegate sendPluginResult:result callbackId:callbackId]; } @catch (NSException *exception) { - NSString* msg = [NSString stringWithFormat: @"While getting settings, error %@", exception]; + NSString* msg = [NSString stringWithFormat: @"While storing battery, error %@", exception]; CDVPluginResult* result = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR messageAsString:msg]; @@ -118,6 +180,12 @@ - (void)launchInit:(CDVInvokedUrlCommand*)command } } + +- (void)launchInit:(CDVInvokedUrlCommand*)command +{ + [self NOP_RETURN_TRUE:command forMethod:@"launchInit"]; +} + - (void)getConfig:(CDVInvokedUrlCommand *)command { NSString* callbackId = [command callbackId]; diff --git a/src/ios/BEMRemotePushNotificationHandler.h b/src/ios/BEMRemotePushNotificationHandler.h index 8cf45b49..4163f3c7 100644 --- a/src/ios/BEMRemotePushNotificationHandler.h +++ b/src/ios/BEMRemotePushNotificationHandler.h @@ -3,7 +3,6 @@ @interface BEMRemotePushNotificationHandler : NSObject + (BEMRemotePushNotificationHandler*) instance; + (void) performPeriodicActivity; -+ (void) validateAndCleanupState; - (void) handleNotifications:(NSNotification*)note; @property NSMutableArray* silentPushHandlerList; diff --git a/src/ios/BEMRemotePushNotificationHandler.m b/src/ios/BEMRemotePushNotificationHandler.m index bc81b772..ae2ad9f1 100644 --- a/src/ios/BEMRemotePushNotificationHandler.m +++ b/src/ios/BEMRemotePushNotificationHandler.m @@ -1,6 +1,6 @@ #import "BEMRemotePushNotificationHandler.h" #import "TripDiaryStateMachine.h" -#import "TripDiarySettingsCheck.h" +#import "SensorControlBackgroundChecker.h" #import "LocalNotificationManager.h" @implementation BEMRemotePushNotificationHandler @@ -100,22 +100,8 @@ - (void)handleNotifications:(NSNotification*)note { + (void) performPeriodicActivity { - [TripDiarySettingsCheck checkSettingsAndPermission]; - [BEMRemotePushNotificationHandler validateAndCleanupState]; -} - -+ (void) validateAndCleanupState -{ - NSUInteger currState = [TripDiaryStateMachine instance].currState; - if (currState == kStartState) { - [LocalNotificationManager addNotification:[NSString stringWithFormat: - @"Still in start state, sending initialize..."] showUI:TRUE]; - [[NSNotificationCenter defaultCenter] postNotificationName:CFCTransitionNotificationName - object:CFCTransitionInitialize]; - } else { - [LocalNotificationManager addNotification:[NSString stringWithFormat: - @"In valid state %@, nothing to do...", [TripDiaryStateMachine getStateName:currState]] showUI:FALSE]; - } + [SensorControlBackgroundChecker checkAppState]; + [SensorControlBackgroundChecker restartFSMIfStartState]; } @end diff --git a/src/ios/Location/TripDiaryDelegate.m b/src/ios/Location/TripDiaryDelegate.m index ca4c58fd..a9ffb835 100644 --- a/src/ios/Location/TripDiaryDelegate.m +++ b/src/ios/Location/TripDiaryDelegate.m @@ -15,7 +15,7 @@ #import "LocationTrackingConfig.h" #import "ConfigManager.h" #import "BEMAppDelegate.h" -#import "TripDiarySettingsCheck.h" +#import "SensorControlBackgroundChecker.h" #define ACCURACY_THRESHOLD 200 @@ -228,9 +228,11 @@ - (void)locationManager:(CLLocationManager *)manager // and the background call to checkLocationSettingsAndPermission from the remote push code // doesn't have that reference. Can simplify this after we stop supporting iOS13. if ([CLLocationManager authorizationStatus] == kCLAuthorizationStatusNotDetermined) { - [TripDiarySettingsCheck promptForPermission:manager]; + // STATUS SCREEN: handle with checkAndFix + // [TripDiarySettingsCheck promptForPermission:manager]; } else { - [TripDiarySettingsCheck checkLocationSettingsAndPermission:FALSE]; + // STATUS SCREEN: handle with checkAndFix + // [TripDiarySettingsCheck checkLocationSettingsAndPermission:FALSE]; } if (_tdsm.currState == kStartState) { diff --git a/src/ios/Location/TripDiarySettingsCheck.h b/src/ios/Location/TripDiarySettingsCheck.h deleted file mode 100644 index df0f7103..00000000 --- a/src/ios/Location/TripDiarySettingsCheck.h +++ /dev/null @@ -1,15 +0,0 @@ -#import -#import -#import -#import - -@interface TripDiarySettingsCheck: NSObject - -+(void)checkSettingsAndPermission; -+(void)checkLocationSettingsAndPermission:(BOOL)inBackground; -+(void)checkMotionSettingsAndPermission:(BOOL)inBackground; -+(void)promptForPermission:(CLLocationManager*)locMgr; -+(void)openAppSettings; -+(void)showSettingsAlert:(UIAlertController*)alert; - -@end diff --git a/src/ios/Location/TripDiarySettingsCheck.m b/src/ios/Location/TripDiarySettingsCheck.m deleted file mode 100644 index b367dc71..00000000 --- a/src/ios/Location/TripDiarySettingsCheck.m +++ /dev/null @@ -1,143 +0,0 @@ -#import "TripDiarySettingsCheck.h" -#import "LocalNotificationManager.h" -#import "BEMAppDelegate.h" -#import "BEMActivitySync.h" - -#import - -@implementation TripDiarySettingsCheck - -+(void)checkSettingsAndPermission { - [TripDiarySettingsCheck checkLocationSettingsAndPermission:TRUE]; - [TripDiarySettingsCheck checkMotionSettingsAndPermission:TRUE]; -} - -+(void)checkLocationSettingsAndPermission:(BOOL)inBackground { - if (![CLLocationManager locationServicesEnabled]) { - // first, check to see if location services are enabled - [LocalNotificationManager addNotification:[NSString stringWithFormat:@"in checkLocationSettingsAndPermissions, locationService is not enabled"]]; - - NSString* errorDescription = NSLocalizedStringFromTable(@"location-turned-off-problem", @"DCLocalizable", nil); - if (inBackground) { - [LocalNotificationManager showNotificationAfterSecs:errorDescription withUserInfo:NULL secsLater:60]; - } - } else { - // next, check to see if it is "always" - if ([CLLocationManager authorizationStatus] != kCLAuthorizationStatusAuthorizedAlways) { - [LocalNotificationManager addNotification:[NSString stringWithFormat:@"in checkLocationSettingsAndPermissions, locationService is enabled, but the permission is %d", [CLLocationManager authorizationStatus]]]; - - NSString* errorDescription = NSLocalizedStringFromTable(@"location-permission-problem", @"DCLocalizable", nil); - if (inBackground) { - [LocalNotificationManager showNotificationAfterSecs:errorDescription withUserInfo:NULL secsLater:60]; - } - [TripDiarySettingsCheck showLaunchSettingsAlert:@"permission-problem-title" withMessage:@"location-permission-problem" button:@"fix-permission-action-button"]; - } else { - // finally, check to see if it is "precise" - // we currently check these in a cascade, since generating multiple alerts results in - // "Attempt to present on (from ) which is already presenting ." - CLLocationManager* currLocMgr = [TripDiaryStateMachine instance].locMgr; - if (@available(iOS 14.0, *)) { - CLAccuracyAuthorization preciseOrNot = [currLocMgr accuracyAuthorization]; - [LocalNotificationManager addNotification:[NSString stringWithFormat:@"in checkLocationSettingsAndPermissions, locationService is enabled, permission is 'always', accuracy status is %ld", preciseOrNot]]; - if (preciseOrNot != CLAccuracyAuthorizationFullAccuracy) { - [TripDiarySettingsCheck showLaunchSettingsAlert:@"permission-problem-title" withMessage:@"precise-location-problem" button:@"fix-permission-action-button"]; - } - } else { - [LocalNotificationManager addNotification:[NSString stringWithFormat:@"No precise location check needed for iOS < 14"]]; - } - } - } -} - -+(void)promptForPermission:(CLLocationManager*)locMgr { - if (IsAtLeastiOSVersion(@"13.0")) { - NSLog(@"iOS 13+ detected, launching UI settings to easily enable always"); - [TripDiarySettingsCheck openAppSettings]; - } - else if ([CLLocationManager instancesRespondToSelector:@selector(requestAlwaysAuthorization)]) { - NSLog(@"Current location authorization = %d, always = %d, requesting always", - [CLLocationManager authorizationStatus], kCLAuthorizationStatusAuthorizedAlways); - [locMgr requestAlwaysAuthorization]; - } else { - // TODO: should we remove this? Not sure when it will ever be called, given that - // requestAlwaysAuthorization is available in iOS8+ - [LocalNotificationManager addNotification:@"Don't need to request authorization, system will automatically prompt for it"]; - } -} - -+(void)checkMotionSettingsAndPermission:(BOOL)inBackground { - if ([CMMotionActivityManager isActivityAvailable] == YES) { - [LocalNotificationManager addNotification:@"Motion activity available, checking auth status"]; - CMAuthorizationStatus currAuthStatus = [CMMotionActivityManager authorizationStatus]; - [LocalNotificationManager addNotification:[NSString stringWithFormat:@"Auth status = %ld", currAuthStatus]]; - - if (currAuthStatus == CMAuthorizationStatusRestricted) { - [LocalNotificationManager addNotification:[NSString stringWithFormat:@"Activity detection not enabled, prompting user to change Settings"]]; - if (inBackground) { - NSString* errorDescription = NSLocalizedStringFromTable(@"activity-turned-off-problem", @"DCLocalizable", nil); - [LocalNotificationManager showNotificationAfterSecs:errorDescription withUserInfo:NULL secsLater:60]; - } - [TripDiarySettingsCheck showLaunchSettingsAlert:@"activity-detection-unsupported" withMessage:@"activity-turned-off-problem" button:@"fix-service-action-button"]; - } - if (currAuthStatus == CMAuthorizationStatusNotDetermined) { - [LocalNotificationManager addNotification:[NSString stringWithFormat:@"Activity status not determined, initializing to get regular prompt"]]; - [BEMActivitySync initWithConsent]; - } - if ([CMMotionActivityManager authorizationStatus] == CMAuthorizationStatusDenied) { - [LocalNotificationManager addNotification:[NSString stringWithFormat:@"Activity status denied, opening app settings to enable"]]; - NSString* errorDescription = NSLocalizedStringFromTable(@"activity-permission-problem", @"DCLocalizable", nil); - if (inBackground) { - [LocalNotificationManager showNotificationAfterSecs:errorDescription withUserInfo:NULL secsLater:60]; - } - [TripDiarySettingsCheck showLaunchSettingsAlert:@"permission-problem-title" withMessage:@"activity-permission-problem" button:@"fix-permission-action-button"]; - } - } else { - [LocalNotificationManager addNotification:[NSString stringWithFormat:@"Activity detection unsupported, all trips will be UNKNOWN"]]; - NSString* title = NSLocalizedStringFromTable(@"activity-detection-unsupported", @"DCLocalizable", nil); - NSString* message = NSLocalizedStringFromTable(@"travel-mode-unknown", @"DCLocalizable", nil); - - UIAlertController* alert = [UIAlertController alertControllerWithTitle:title - message:message - preferredStyle:UIAlertControllerStyleAlert]; - - UIAlertAction* defaultAction = [UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault - handler:^(UIAlertAction * action) { - }]; - [alert addAction:defaultAction]; - [TripDiarySettingsCheck showSettingsAlert:alert]; - } -} - -+(void)showLaunchSettingsAlert:(NSString*)titleTag withMessage:(NSString*)messageTag button:(NSString*)buttonTag { - NSString* title = NSLocalizedStringFromTable(titleTag, @"DCLocalizable", nil); - NSString* message = NSLocalizedStringFromTable(messageTag, @"DCLocalizable", nil); - NSString* errorAction = NSLocalizedStringFromTable(buttonTag, @"DCLocalizable", nil); - - UIAlertController* alert = [UIAlertController alertControllerWithTitle:title - message:message - preferredStyle:UIAlertControllerStyleAlert]; - - UIAlertAction* defaultAction = [UIAlertAction actionWithTitle:errorAction style:UIAlertActionStyleDefault - handler:^(UIAlertAction * action) { - [TripDiarySettingsCheck openAppSettings]; - }]; - [alert addAction:defaultAction]; - [TripDiarySettingsCheck showSettingsAlert:alert]; -} - -+(void) openAppSettings { - [[UIApplication sharedApplication] openURL:[NSURL URLWithString:UIApplicationOpenSettingsURLString] options:@{} completionHandler:^(BOOL success) { - if (success) { - NSLog(@"Opened url"); - } else { - NSLog(@"Failed open"); - }}]; -} - -+(void) showSettingsAlert:(UIAlertController*)alert { - CDVAppDelegate *ad = [[UIApplication sharedApplication] delegate]; - CDVViewController *vc = ad.viewController; - [vc presentViewController:alert animated:YES completion:nil]; -} - -@end diff --git a/src/ios/Location/TripDiaryStateMachine.h b/src/ios/Location/TripDiaryStateMachine.h index 0d65e644..2dd99de1 100644 --- a/src/ios/Location/TripDiaryStateMachine.h +++ b/src/ios/Location/TripDiaryStateMachine.h @@ -60,6 +60,7 @@ typedef void(^GeofenceStatusCallback)(NSString* geofenceStatus); @interface TripDiaryStateMachine : NSObject + (TripDiaryStateMachine*) instance; ++ (id) delegate; -(void)registerForNotifications; diff --git a/src/ios/Location/TripDiaryStateMachine.m b/src/ios/Location/TripDiaryStateMachine.m index 5d84b75d..a49b6435 100644 --- a/src/ios/Location/TripDiaryStateMachine.m +++ b/src/ios/Location/TripDiaryStateMachine.m @@ -9,7 +9,7 @@ #import "TripDiaryStateMachine.h" #import "TripDiaryActions.h" #import "TripDiaryDelegate.h" -#import "TripDiarySettingsCheck.h" +#import "SensorControlBackgroundChecker.h" #import "LocalNotificationManager.h" @@ -62,6 +62,11 @@ + (TripDiaryStateMachine*) instance { return sharedInstance; } ++ (TripDiaryDelegate*) delegate { + return [self instance]->_locDelegate; +} + + - (id) init { NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; @@ -99,25 +104,38 @@ - (id) init { // would be good to test, though. } - + // STATUS SCREEN: Figure out how to fix recursion when [TripDiaryStateMachine instance] is called + // [SensorControlBackgroundChecker checkAppState]; + /* The only times we should get here are: + * - if we re-install a previously installed app, and so it is already authorized for background location collection BUT is in the start state, or + * - another option might be a re-launch of the app when the user has manually stopped tracking. + * It would be bad to automatically restart the tracking if the user has manully stopped tracking. + * One way to deal with this would be to have separate states for "start" and for "tracking suspended". + * Another way would be to just remove this transition from here... + * TODO: Figure out how to deal with it. + */ + // STATUS SCREEN: Figure out how to fix recursion when [TripDiaryStateMachine instance] is called + // [SensorControlBackgroundChecker restartFSMIfStartState]; + /* if ([CLLocationManager authorizationStatus] != kCLAuthorizationStatusAuthorizedAlways) { [TripDiarySettingsCheck promptForPermission:self.locMgr]; } else { NSLog(@"Current location authorization = %d, always = %d", [CLLocationManager authorizationStatus], kCLAuthorizationStatusAuthorizedAlways); - /* The only times we should get here are: + The only times we should get here are: * - if we re-install a previously installed app, and so it is already authorized for background location collection BUT is in the start state, or * - another option might be a re-launch of the app when the user has manually stopped tracking. * It would be bad to automatically restart the tracking if the user has manully stopped tracking. * One way to deal with this would be to have separate states for "start" and for "tracking suspended". * Another way would be to just remove this transition from here... * TODO: Figure out how to deal with it. - */ + if (self.currState == kStartState) { [[NSNotificationCenter defaultCenter] postNotificationName:CFCTransitionNotificationName object:CFCTransitionInitialize]; } } + */ if (![ConfigManager instance].is_duty_cycling && self.currState != kTrackingStoppedState) { /* If we are not using geofencing, and the tracking is not manually turned off, then we don't need to listen @@ -125,7 +143,7 @@ - (id) init { it easier for us to ignore silent push as well as the transitions generated from here. */ [TripDiaryActions oneTimeInitTracking:CFCTransitionInitialize withLocationMgr:self.locMgr]; - [self setState:kOngoingTripState]; + [self setState:kOngoingTripState withChecks:TRUE]; [TripDiaryActions startTracking:CFCTransitionExitedGeofence withLocationMgr:self.locMgr]; } @@ -193,7 +211,7 @@ -(void)handleTransition:(NSString*) transition withUserInfo:(NSDictionary*) user } } --(void)setState:(TripDiaryStates) newState { +-(void)setState:(TripDiaryStates) newState withChecks:(BOOL) doChecks { NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; [defaults setInteger:newState forKey:kCurrState]; @@ -203,6 +221,9 @@ -(void)setState:(TripDiaryStates) newState { [TripDiaryStateMachine getStateName:newState]]]; self.currState = newState; + if (doChecks) { + [SensorControlBackgroundChecker checkAppState]; +} } /* @@ -232,7 +253,7 @@ -(void) handleStart:(NSString*) transition withUserInfo:(NSDictionary*) userInfo // but if tracking is stopped, we can skip that, and then if we turn it on again, we // need to turn everything on here as well [TripDiaryActions oneTimeInitTracking:transition withLocationMgr:self.locMgr]; - [self setState:kOngoingTripState]; + [self setState:kOngoingTripState withChecks:TRUE]; [TripDiaryActions startTracking:transition withLocationMgr:self.locMgr]; } } else if ([transition isEqualToString:CFCTransitionRecievedSilentPush]) { @@ -241,26 +262,26 @@ -(void) handleStart:(NSString*) transition withUserInfo:(NSDictionary*) userInfo } else if ([transition isEqualToString:CFCTransitionInitComplete]) { // Geofence has been successfully created and we are inside it so we are about to move to // the WAITING_FOR_TRIP_START state. - [self setState:kWaitingForTripStartState]; + [self setState:kWaitingForTripStartState withChecks:TRUE]; } else if ([transition isEqualToString:CFCTransitionGeofenceCreationError]) { // if we get a geofence creation error, we stay in the start state. NSLog(@"Got transition %@ in state %@, staying in %@ state", transition, [TripDiaryStateMachine getStateName:self.currState], [TripDiaryStateMachine getStateName:self.currState]); - [TripDiarySettingsCheck checkSettingsAndPermission]; + [self setState:kStartState withChecks:FALSE]; } else if ([transition isEqualToString:CFCTransitionExitedGeofence]) { [TripDiaryActions startTracking:transition withLocationMgr:self.locMgr]; [TripDiaryActions deleteGeofence:self.locMgr]; [[NSNotificationCenter defaultCenter] postNotificationName:CFCTransitionNotificationName object:CFCTransitionTripStarted]; } else if ([transition isEqualToString:CFCTransitionTripStarted]) { - [self setState:kOngoingTripState]; + [self setState:kOngoingTripState withChecks:TRUE]; } else if ([transition isEqualToString:CFCTransitionForceStopTracking]) { // One would think that we don't need to deal with anything other than starting from the start // state, but we can be stuck in the start state for a while if it turns out that the geofence is // not created correctly. If the user forces us to stop tracking then, we still need to do it. - [self setState:kTrackingStoppedState]; + [self setState:kTrackingStoppedState withChecks:TRUE]; } else if ([transition isEqualToString:CFCTransitionStartTracking]) { // One would think that we don't need to deal with anything other than starting from the start // state, but we can be stuck in the start state for a while if it turns out that the geofence is @@ -298,7 +319,7 @@ - (void) handleWaitingForTripStart:(NSString*) transition withUserInfo:(NSDicti object:CFCTransitionTripStarted]; } } else if ([transition isEqualToString:CFCTransitionTripStarted]) { - [self setState:kOngoingTripState]; + [self setState:kOngoingTripState withChecks:TRUE]; } else if ([transition isEqualToString:CFCTransitionRecievedSilentPush]) { // Let's push any pending changes since we know that the trip has ended [[TripDiaryActions pushTripToServer] continueWithBlock:^id(BFTask *task) { @@ -316,7 +337,7 @@ - (void) handleWaitingForTripStart:(NSString*) transition withUserInfo:(NSDicti } else if ([transition isEqualToString:CFCTransitionForceStopTracking]) { [TripDiaryActions resetFSM:transition withLocationMgr:self.locMgr]; } else if ([transition isEqualToString:CFCTransitionTrackingStopped]) { - [self setState:kTrackingStoppedState]; + [self setState:kTrackingStoppedState withChecks:TRUE]; } else { NSLog(@"Got unexpected transition %@ in state %@, ignoring", transition, @@ -386,7 +407,7 @@ - (void) handleOngoingTrip:(NSString*) transition withUserInfo:(NSDictionary*) u // so we can use visits for the trip start detection, so we are also // about to move to the WAITING_FOR_TRIP_START state // TODO: Should this be here, or in EndTripTracking - [self setState:kWaitingForTripStartState]; + [self setState:kWaitingForTripStartState withChecks:TRUE]; [[BEMServerSyncCommunicationHelper backgroundSync] continueWithBlock:^id(BFTask *task) { [[BuiltinUserCache database] checkAfterPull]; [LocalNotificationManager addNotification:[NSString stringWithFormat: @@ -397,12 +418,12 @@ - (void) handleOngoingTrip:(NSString*) transition withUserInfo:(NSDictionary*) u return nil; }]; } else if ([transition isEqualToString:CFCTransitionGeofenceCreationError]) { - [self setState:kStartState]; - [TripDiarySettingsCheck checkSettingsAndPermission]; + // setState will call SensorControlBackgroundChecker checkAppState by default + [self setState:kStartState withChecks:FALSE]; } else if ([transition isEqualToString:CFCTransitionForceStopTracking]) { [TripDiaryActions resetFSM:transition withLocationMgr:self.locMgr]; } else if ([transition isEqualToString:CFCTransitionTrackingStopped]) { - [self setState:kTrackingStoppedState]; + [self setState:kTrackingStoppedState withChecks:TRUE]; } else { NSLog(@"Got unexpected transition %@ in state %@, ignoring", transition, @@ -422,14 +443,14 @@ - (void) handleTrackingStopped:(NSString*) transition withUserInfo:(NSDictionary [LocalNotificationManager addNotification:[NSString stringWithFormat: @"Found unexpected VISIT_STARTED transition while tracking was stopped, stop everything again"] showUI:TRUE]; - [self setState:kTrackingStoppedState]; + [self setState:kTrackingStoppedState withChecks:TRUE]; [TripDiaryActions resetFSM:transition withLocationMgr:self.locMgr]; } else if ([transition isEqualToString:CFCTransitionForceStopTracking]) { [TripDiaryActions resetFSM:transition withLocationMgr:self.locMgr]; } else if ([transition isEqualToString:CFCTransitionTrackingStopped]) { - [self setState:kTrackingStoppedState]; + [self setState:kTrackingStoppedState withChecks:TRUE]; } else if ([transition isEqualToString:CFCTransitionStartTracking]) { - [self setState:kStartState]; + [self setState:kStartState withChecks:TRUE]; // This will run the one time init tracking as well as try to create a geofence // if we are moving, then we will be outside the geofence... the existing state machine will take over. [[NSNotificationCenter defaultCenter] postNotificationName:CFCTransitionNotificationName diff --git a/src/ios/Verification/SensorControlBackgroundChecker.h b/src/ios/Verification/SensorControlBackgroundChecker.h new file mode 100644 index 00000000..7c565024 --- /dev/null +++ b/src/ios/Verification/SensorControlBackgroundChecker.h @@ -0,0 +1,11 @@ +#import +#import +#import +#import + +@interface SensorControlBackgroundChecker: NSObject + ++(void)restartFSMIfStartState; ++(void)checkAppState; + +@end diff --git a/src/ios/Verification/SensorControlBackgroundChecker.m b/src/ios/Verification/SensorControlBackgroundChecker.m new file mode 100644 index 00000000..51aad2a3 --- /dev/null +++ b/src/ios/Verification/SensorControlBackgroundChecker.m @@ -0,0 +1,90 @@ +#import "SensorControlBackgroundChecker.h" +#import "TripDiarySensorControlChecks.h" +#import "TripDiaryStateMachine.h" +#import "LocalNotificationManager.h" +#import "BEMAppDelegate.h" +#import "BEMActivitySync.h" + +#import +#define OPEN_APP_STATUS_PAGE_ID @362253744 + +@implementation SensorControlBackgroundChecker + ++(NSDictionary*)OPEN_APP_STATUS_PAGE +{ + NSDictionary* config = @{ + @"id": OPEN_APP_STATUS_PAGE_ID, + @"title": NSLocalizedStringFromTable(@"fix_app_status_title", @"DCLocalizable", nil), + @"text": NSLocalizedStringFromTable(@"fix_app_status_text", @"DCLocalizable", nil), + @"data": @{ + @"redirectTo": @"root.main.control", + @"redirectParams": @{ + @"launchAppStatusModal": @true + } + } + }; + return config; +} + ++(void)restartFSMIfStartState +{ + NSUInteger currState = [TripDiaryStateMachine instance].currState; + if (currState == kStartState) { + [LocalNotificationManager addNotification:[NSString stringWithFormat:@"Still in start state, sending initialize..."] showUI:TRUE]; + [[NSNotificationCenter defaultCenter] postNotificationName:CFCTransitionNotificationName + object:CFCTransitionInitialize]; + } else { + [LocalNotificationManager addNotification:[NSString stringWithFormat:@"In valid state %@, nothing to do...", [TripDiaryStateMachine getStateName:currState]] showUI:FALSE]; + } +} + ++(void)checkAppState +{ + [LocalNotificationManager cancelNotification:OPEN_APP_STATUS_PAGE_ID]; + + NSArray* allChecks = @[ + @([TripDiarySensorControlChecks checkLocationSettings]), + @([TripDiarySensorControlChecks checkLocationPermissions]), + @([TripDiarySensorControlChecks checkMotionActivitySettings]), + @([TripDiarySensorControlChecks checkMotionActivityPermissions]), + @([TripDiarySensorControlChecks checkNotificationsEnabled]) + ]; + BOOL allChecksPass = TRUE; + for (NSNumber* check in allChecks) { + allChecksPass = allChecksPass && check.boolValue; + } + + BOOL locChecksPass = allChecks[0].boolValue && allChecks[1].boolValue; + + if (allChecksPass) { + [LocalNotificationManager addNotification:[NSString stringWithFormat:@"All settings valid, nothing to prompt"]]; + [self restartFSMIfStartState]; + } + else if (locChecksPass) { + /* + Log.i(ctxt, TAG, "all checks = "+allOtherChecksPass+" but location permission status "+allOtherChecks[0]+" should be true "+ + " so one of the non-location checks must be false: loc permission, motion permission, notification, unused apps" + Arrays.toString(allOtherChecks)); + Log.i(ctxt, TAG, "a non-local check failed, generating only user visible notification"); + */ + [self generateOpenAppSettingsNotification]; + } + else { + /* + Log.i(ctxt, TAG, "location settings are valid, but location permission is not, generating tracking error and visible notification"); + Log.i(ctxt, TAG, "curr status check results = " + + " loc permission, motion permission, notification, unused apps "+ Arrays.toString(allOtherChecks)); + */ + // Should replace with TRACKING_ERROR but looks like we + // don't have any + [[NSNotificationCenter defaultCenter] + postNotificationName:CFCTransitionNotificationName + object:CFCTransitionGeofenceCreationError]; + [self generateOpenAppSettingsNotification]; + } +} + ++(void)generateOpenAppSettingsNotification +{ + [LocalNotificationManager schedulePluginCompatibleNotification:[self OPEN_APP_STATUS_PAGE] withNewData:NULL]; +} +@end diff --git a/src/ios/Verification/SensorControlForegroundDelegate.h b/src/ios/Verification/SensorControlForegroundDelegate.h new file mode 100644 index 00000000..0a359ea5 --- /dev/null +++ b/src/ios/Verification/SensorControlForegroundDelegate.h @@ -0,0 +1,45 @@ +#import +#import +#import +#import +#import +#import "TripDiaryDelegate.h" +#import "AppDelegate.h" + +@interface SensorControlForegroundDelegate: NSObject + +- (id)initWithDelegate:(id)delegate forCommand:(CDVInvokedUrlCommand*)command;; +- (void)checkLocationSettings; +- (void)checkLocationPermissions; +- (void)checkMotionActivitySettings; +- (void)checkMotionActivityPermissions; +- (void)checkNotificationsEnabled; + +- (void) checkAndPromptLocationSettings; +- (void) checkAndPromptLocationPermissions; +- (void) didChangeAuthorizationStatus:(CLAuthorizationStatus)status; + +- (void) checkAndPromptFitnessPermissions; +- (void) didRecieveFitnessPermission:(BOOL)isPermitted; + +- (void) checkAndPromptNotificationPermission; +- (void) didRegisterUserNotificationSettings:(UIUserNotificationSettings*)isPermitted; +@end + +@interface TripDiaryDelegate (TripDiaryDelegatePermissions) +- (void)registerForegroundDelegate:(SensorControlForegroundDelegate*) foregroundDelegate; +- (void)locationManager:(CLLocationManager *)manager + didChangeAuthorizationStatus:(CLAuthorizationStatus)status; + +@end + +@interface MotionActivityPermissionDelegate: NSObject ++ (void)registerForegroundDelegate:(SensorControlForegroundDelegate*) foregroundDelegate; ++ (void)readAndPromptForPermission; +@end + +@interface AppDelegate (AppDelegate) ++ (void)registerForegroundDelegate:(SensorControlForegroundDelegate*) foregroundDelegate; ++ (void)readAndPromptForPermission; +@end + diff --git a/src/ios/Verification/SensorControlForegroundDelegate.m b/src/ios/Verification/SensorControlForegroundDelegate.m new file mode 100644 index 00000000..22f9af0b --- /dev/null +++ b/src/ios/Verification/SensorControlForegroundDelegate.m @@ -0,0 +1,441 @@ +#import "SensorControlForegroundDelegate.h" +#import "TripDiarySensorControlChecks.h" +#import "SensorControlBackgroundChecker.h" +#import "LocalNotificationManager.h" +#import "BEMAppDelegate.h" +#import "BEMActivitySync.h" + +#import + +@interface SensorControlForegroundDelegate() { + id commandDelegate; + CDVInvokedUrlCommand* command; +} +@end + +@implementation SensorControlForegroundDelegate + +/* + * BEGIN: "check" implementations + */ + +// typedef BOOL (*CheckFnType)(void); + +-(void) sendCheckResult:(BOOL)result + errorKey:(NSString*)localizableErrorKey +{ + NSString* callbackId = [command callbackId]; + @try { + if (result) { + CDVPluginResult* result = [CDVPluginResult + resultWithStatus:CDVCommandStatus_OK]; + [commandDelegate sendPluginResult:result callbackId:callbackId]; + } else { + NSString* msg = NSLocalizedStringFromTable(localizableErrorKey, @"DCLocalizable", nil); + CDVPluginResult* result = [CDVPluginResult + resultWithStatus:CDVCommandStatus_ERROR + messageAsString:msg]; + [commandDelegate sendPluginResult:result callbackId:callbackId]; + } + } + @catch (NSException *exception) { + NSString* msg = [NSString stringWithFormat: @"While getting settings, error %@", exception]; + CDVPluginResult* result = [CDVPluginResult + resultWithStatus:CDVCommandStatus_ERROR + messageAsString:msg]; + [commandDelegate sendPluginResult:result callbackId:callbackId]; + } +} + +-(id)initWithDelegate:(id)delegate + forCommand:(CDVInvokedUrlCommand *)command +{ + self->command = command; + self->commandDelegate = delegate; + return [super init]; +} + + +-(void)checkLocationSettings +{ + BOOL result = [TripDiarySensorControlChecks checkLocationSettings]; + [self sendCheckResult:result + errorKey:@"location_not_enabled"]; +} + +-(void)checkLocationPermissions +{ + BOOL result = [TripDiarySensorControlChecks checkLocationPermissions]; + [self sendCheckResult:result + errorKey:@"location_permission_off"]; +} + +-(void)checkMotionActivitySettings +{ + BOOL result = [TripDiarySensorControlChecks checkMotionActivitySettings]; + [self sendCheckResult:result + errorKey:@"activity_settings_off"]; +} + + +-(void)checkMotionActivityPermissions +{ + BOOL result = [TripDiarySensorControlChecks checkMotionActivityPermissions]; + [self sendCheckResult:result + errorKey:@"activity_permission_off"]; +} + +-(void)checkNotificationsEnabled +{ + BOOL result = [TripDiarySensorControlChecks checkNotificationsEnabled]; + [self sendCheckResult:result + errorKey:@"notifications_blocked"]; +} + +/* + * END: "check" implementations + */ + +/* + * BEGIN: "fix" implementations + */ + +/* + * In iOS, we cannot open overall settings, only the app settings. + * Should we open it anyway? Or just tell the user what to do? + * If we open it, we don't appear to get any callbacks when the location services are enabled. + * there are callbacks only for the auth/permission changes. + * https://developer.apple.com/documentation/corelocation/cllocationmanagerdelegate?language=objc + * So let's just prompt the user. Which means that we can reuse the check with a different error message + */ + +-(void) checkAndPromptLocationSettings +{ + BOOL result = [TripDiarySensorControlChecks checkLocationSettings]; + [self sendCheckResult:result + errorKey:@"location-turned-off-problem"]; +} + +-(void) checkAndPromptLocationPermissions +{ + NSString* callbackId = [command callbackId]; + @try { + BOOL result = [TripDiarySensorControlChecks checkLocationPermissions]; + + [LocalNotificationManager addNotification:[NSString stringWithFormat:@"in checkLocationSettingsAndPermissions, locationService is enabled, but the permission is %d", [CLLocationManager authorizationStatus]]]; + + if (result) { + CDVPluginResult* result = [CDVPluginResult + resultWithStatus:CDVCommandStatus_OK]; + [commandDelegate sendPluginResult:result callbackId:callbackId]; + } else { + [self promptForPermission:[TripDiaryStateMachine instance].locMgr]; + } + } + @catch (NSException *exception) { + NSString* msg = [NSString stringWithFormat: @"While getting settings, error %@", exception]; + CDVPluginResult* result = [CDVPluginResult + resultWithStatus:CDVCommandStatus_ERROR + messageAsString:msg]; + [commandDelegate sendPluginResult:result callbackId:callbackId]; + } +} + +- (void) didChangeAuthorizationStatus:(CLAuthorizationStatus)status +{ + [[TripDiaryStateMachine instance].locMgr stopUpdatingLocation]; + NSString* callbackId = [command callbackId]; + @try { + if (status == kCLAuthorizationStatusAuthorizedAlways) { + CDVPluginResult* result = [CDVPluginResult + resultWithStatus:CDVCommandStatus_OK]; + [commandDelegate sendPluginResult:result callbackId:callbackId]; + } else { + NSString* msg = NSLocalizedStringFromTable(@"location_permission_off_app_open", @"DCLocalizable", nil); + CDVPluginResult* result = [CDVPluginResult + resultWithStatus:CDVCommandStatus_ERROR + messageAsString:msg]; + [commandDelegate sendPluginResult:result callbackId:callbackId]; + } + } + @catch (NSException *exception) { + NSString* msg = [NSString stringWithFormat: @"While handling auth callback, error %@", exception]; + CDVPluginResult* result = [CDVPluginResult + resultWithStatus:CDVCommandStatus_ERROR + messageAsString:msg]; + [commandDelegate sendPluginResult:result callbackId:callbackId]; + } + +} + +- (void)checkAndPromptFitnessPermissions { + NSString* callbackId = [command callbackId]; +#if TARGET_OS_SIMULATOR + CDVPluginResult* result = [CDVPluginResult + resultWithStatus:CDVCommandStatus_OK]; + [commandDelegate sendPluginResult:result callbackId:callbackId]; +#else + @try { + if ([CMMotionActivityManager isActivityAvailable] == YES) { + [LocalNotificationManager addNotification:@"Motion activity available, checking auth status"]; + CMAuthorizationStatus currAuthStatus = [CMMotionActivityManager authorizationStatus]; + [LocalNotificationManager addNotification:[NSString stringWithFormat:@"Auth status = %ld", currAuthStatus]]; + + if (currAuthStatus == CMAuthorizationStatusAuthorized) { + CDVPluginResult* result = [CDVPluginResult + resultWithStatus:CDVCommandStatus_OK]; + [commandDelegate sendPluginResult:result callbackId:callbackId]; + } + + if (currAuthStatus == CMAuthorizationStatusNotDetermined) { + [LocalNotificationManager addNotification:[NSString stringWithFormat:@"Activity status not determined, initializing to get regular prompt"]]; + [MotionActivityPermissionDelegate registerForegroundDelegate:self]; + [MotionActivityPermissionDelegate readAndPromptForPermission]; + } + + if (currAuthStatus == CMAuthorizationStatusRestricted) { + /* + It looked like this status is read when the app starts and cached after that. This is not resolvable from the code, so we just change the resulting message to highlight that the app needs to be restarted. + Gory details at: https://github.com/e-mission/e-mission-docs/issues/680#issuecomment-1040948835 + */ + [LocalNotificationManager addNotification:[NSString stringWithFormat:@"Activity detection not enabled, prompting user to change Settings"]]; + NSString* msg = NSLocalizedStringFromTable(@"activity-turned-off-problem", @"DCLocalizable", nil); + CDVPluginResult* result = [CDVPluginResult + resultWithStatus:CDVCommandStatus_ERROR + messageAsString:msg]; + [commandDelegate sendPluginResult:result callbackId:callbackId]; + } + + + if ([CMMotionActivityManager authorizationStatus] == CMAuthorizationStatusDenied) { + [LocalNotificationManager addNotification:[NSString stringWithFormat:@"Activity status denied, opening app settings to enable"]]; + + NSString* msg = NSLocalizedStringFromTable(@"activity-permission-problem", @"DCLocalizable", nil); + CDVPluginResult* result = [CDVPluginResult + resultWithStatus:CDVCommandStatus_ERROR + messageAsString:msg]; + [commandDelegate sendPluginResult:result callbackId:callbackId]; + [self openAppSettings]; + } + } else { + [LocalNotificationManager addNotification:[NSString stringWithFormat:@"Activity detection unsupported, all trips will be UNKNOWN"]]; + NSString* msg = NSLocalizedStringFromTable(@"activity-detection-unsupported", @"DCLocalizable", nil); + CDVPluginResult* result = [CDVPluginResult + resultWithStatus:CDVCommandStatus_ERROR + messageAsString:msg]; + [commandDelegate sendPluginResult:result callbackId:callbackId]; + } + } + @catch (NSException *exception) { + NSString* msg = [NSString stringWithFormat: @"While getting settings, error %@", exception]; + CDVPluginResult* result = [CDVPluginResult + resultWithStatus:CDVCommandStatus_ERROR + messageAsString:msg]; + [commandDelegate sendPluginResult:result callbackId:callbackId]; + } +#endif +} + +-(void) didRecieveFitnessPermission:(BOOL)isPermitted +{ + [self sendCheckResult:isPermitted + errorKey:@"activity-permission-problem"]; +} + +- (void)checkAndPromptNotificationPermission { + NSString* callbackId = [command callbackId]; + @try { + if ([UIApplication instancesRespondToSelector:@selector(registerUserNotificationSettings:)]) { + UIUserNotificationSettings* requestedSettings = [TripDiarySensorControlChecks REQUESTED_NOTIFICATION_TYPES]; + [AppDelegate registerForegroundDelegate:self]; + [[UIApplication sharedApplication] registerUserNotificationSettings:requestedSettings]; + } + } + @catch (NSException *exception) { + NSString* msg = [NSString stringWithFormat: @"While getting settings, error %@", exception]; + CDVPluginResult* result = [CDVPluginResult + resultWithStatus:CDVCommandStatus_ERROR + messageAsString:msg]; + [commandDelegate sendPluginResult:result callbackId:callbackId]; + } +} + +- (void) didRegisterUserNotificationSettings:(UIUserNotificationSettings*)newSettings { + NSString* callbackId = [command callbackId]; + UIUserNotificationSettings* requestedSettings = [TripDiarySensorControlChecks REQUESTED_NOTIFICATION_TYPES]; + if (requestedSettings.types == newSettings.types) { + CDVPluginResult* result = [CDVPluginResult + resultWithStatus:CDVCommandStatus_OK]; + [commandDelegate sendPluginResult:result callbackId:callbackId]; + } else { + NSString* msg = NSLocalizedStringFromTable(@"notifications_blocked_app_open", @"DCLocalizable", nil); + [self openAppSettings]; + CDVPluginResult* result = [CDVPluginResult + resultWithStatus:CDVCommandStatus_ERROR + messageAsString:msg]; + [commandDelegate sendPluginResult:result callbackId:callbackId]; + } +} + +-(void)promptForPermission:(CLLocationManager*)locMgr { + if (IsAtLeastiOSVersion(@"13.0")) { + NSLog(@"iOS 13+ detected, launching UI settings to easily enable always"); + // we want to leave the registration in the prompt for permission, since we don't want to register callbacks when we open the app settings for other reasons + [[TripDiaryStateMachine delegate] registerForegroundDelegate:self]; + [[TripDiaryStateMachine instance].locMgr startUpdatingLocation]; + [self openAppSettings]; + } + else { + if ([CLLocationManager authorizationStatus] == kCLAuthorizationStatusNotDetermined) { + if ([CLLocationManager instancesRespondToSelector:@selector(requestAlwaysAuthorization)]) { + NSLog(@"Current location authorization = %d, always = %d, requesting always", + [CLLocationManager authorizationStatus], kCLAuthorizationStatusAuthorizedAlways); + [[TripDiaryStateMachine delegate] registerForegroundDelegate:self]; + [locMgr requestAlwaysAuthorization]; + } else { + // TODO: should we remove this? Not sure when it will ever be called, given that + // requestAlwaysAuthorization is available in iOS8+ + [LocalNotificationManager addNotification:@"Don't need to request authorization, system will automatically prompt for it"]; + } + } else { + // we want to leave the registration in the prompt for permission, since we don't want to register callbacks when we open the app settings for other reasons + [[TripDiaryStateMachine delegate] registerForegroundDelegate:self]; + [self openAppSettings]; + } + } +} + +-(void) openAppSettings { + [[UIApplication sharedApplication] openURL:[NSURL URLWithString:UIApplicationOpenSettingsURLString] options:@{} completionHandler:^(BOOL success) { + if (success) { + NSLog(@"Opened url"); + } else { + NSLog(@"Failed open"); + }}]; +} + +@end + +@implementation TripDiaryDelegate (TripDiaryDelegatePermissions) + +NSMutableArray* foregroundDelegateList; + +/* + * This is a bit tricky since this function is called whenever the authorization is changed + * Design decisions are at: + * https://github.com/e-mission/e-mission-docs/issues/680#issuecomment-1035972636 + * https://github.com/e-mission/e-mission-docs/issues/680#issuecomment-1035976420 + * https://github.com/e-mission/e-mission-docs/issues/680#issuecomment-1035984060 + */ + +- (void)registerForegroundDelegate:(SensorControlForegroundDelegate*) foregroundDelegate +{ + if (foregroundDelegateList == nil) { + foregroundDelegateList = [NSMutableArray new]; + } + [foregroundDelegateList addObject:foregroundDelegate]; +} + +- (void)locationManager:(CLLocationManager *)manager + didChangeAuthorizationStatus:(CLAuthorizationStatus)status { + [LocalNotificationManager addNotification:[NSString stringWithFormat:@"In checker's didChangeAuthorizationStatus, new authorization status = %d, always = %d", status, kCLAuthorizationStatusAuthorizedAlways]]; + + [LocalNotificationManager addNotification:[NSString stringWithFormat:@"Calling TripDiarySettingsCheck from didChangeAuthorizationStatus to verify location service status and permission"]]; + if (foregroundDelegateList.count > 0) { + for (id currDelegate in foregroundDelegateList) { + [currDelegate didChangeAuthorizationStatus:(CLAuthorizationStatus)status]; + } + [foregroundDelegateList removeAllObjects]; + } else { + [SensorControlBackgroundChecker checkAppState]; + } +} +@end + +@implementation MotionActivityPermissionDelegate +NSMutableArray* foregroundDelegateList; + +/* + * This is a bit tricky since this function is called whenever the authorization is changed + * Design decisions are at: + * https://github.com/e-mission/e-mission-docs/issues/680#issuecomment-1035972636 + * https://github.com/e-mission/e-mission-docs/issues/680#issuecomment-1035976420 + * https://github.com/e-mission/e-mission-docs/issues/680#issuecomment-1035984060 + */ + ++(void)registerForegroundDelegate:(SensorControlForegroundDelegate*) foregroundDelegate +{ + if (foregroundDelegateList == nil) { + foregroundDelegateList = [NSMutableArray new]; + } + [foregroundDelegateList addObject:foregroundDelegate]; +} + ++(void)readAndPromptForPermission { + CMMotionActivityManager* activityMgr = [[CMMotionActivityManager alloc] init]; + NSOperationQueue* mq = [NSOperationQueue mainQueue]; + NSDate* startDate = [NSDate new]; + NSTimeInterval dayAgoSecs = 24 * 60 * 60; + NSDate* endDate = [NSDate dateWithTimeIntervalSinceNow:-(dayAgoSecs)]; + /* This queryActivity call is the one that prompt the user for permission */ + [activityMgr queryActivityStartingFromDate:startDate toDate:endDate toQueue:mq withHandler:^(NSArray *activities, NSError *error) { + if (error == nil) { + [LocalNotificationManager addNotification:@"activity recognition works fine"]; + if (foregroundDelegateList.count > 0) { + for (id currDelegate in foregroundDelegateList) { + [currDelegate didRecieveFitnessPermission:TRUE]; + } + [foregroundDelegateList removeAllObjects]; + } else { + [LocalNotificationManager addNotification:[NSString stringWithFormat:@"no foreground delegate callbacks found for fitness sensors, ignoring success..."]]; + } + } else { + [LocalNotificationManager addNotification:[NSString stringWithFormat:@"Error %@ while reading activities, travel mode detection may be unavailable", error]]; + if (foregroundDelegateList.count > 0) { + for (id currDelegate in foregroundDelegateList) { + [currDelegate didRecieveFitnessPermission:FALSE]; + } + [foregroundDelegateList removeAllObjects]; + } else { + [LocalNotificationManager addNotification:[NSString stringWithFormat:@"no foreground delegate callbacks found for fitness sensor error %@, ignoring...", error]]; + } + } + }]; +} + +@end + +@implementation AppDelegate(CollectionPermission) +NSMutableArray* foregroundDelegateList; + +/* + * This is a bit tricky since this function is called whenever the authorization is changed + * Design decisions are at: + * https://github.com/e-mission/e-mission-docs/issues/680#issuecomment-1035972636 + * https://github.com/e-mission/e-mission-docs/issues/680#issuecomment-1035976420 + * https://github.com/e-mission/e-mission-docs/issues/680#issuecomment-1035984060 + */ + ++(void)registerForegroundDelegate:(SensorControlForegroundDelegate*) foregroundDelegate +{ + if (foregroundDelegateList == nil) { + foregroundDelegateList = [NSMutableArray new]; + } + [foregroundDelegateList addObject:foregroundDelegate]; +} + +-(void) application:(UIApplication*)application didRegisterUserNotificationSettings:(UIUserNotificationSettings *)notificationSettings +{ + [LocalNotificationManager addNotification:[NSString stringWithFormat: + @"Received callback from user notification settings "] + showUI:FALSE]; + if (foregroundDelegateList.count > 0) { + for (id currDelegate in foregroundDelegateList) { + [currDelegate didRegisterUserNotificationSettings:notificationSettings]; + } + [foregroundDelegateList removeAllObjects]; + } else { + [LocalNotificationManager addNotification:[NSString stringWithFormat:@"no foreground delegate callbacks found for notifications, ignoring success..."]]; + } +} +@end diff --git a/src/ios/Verification/TripDiarySensorControlChecks.h b/src/ios/Verification/TripDiarySensorControlChecks.h new file mode 100644 index 00000000..f31526ff --- /dev/null +++ b/src/ios/Verification/TripDiarySensorControlChecks.h @@ -0,0 +1,16 @@ +#import +#import +#import +#import +#import + +@interface TripDiarySensorControlChecks: NSObject + ++(BOOL)checkLocationSettings; ++(BOOL)checkLocationPermissions; ++(BOOL)checkMotionActivitySettings; ++(BOOL)checkMotionActivityPermissions; ++(BOOL)checkNotificationsEnabled; + ++(UIUserNotificationSettings*) REQUESTED_NOTIFICATION_TYPES; +@end diff --git a/src/ios/Verification/TripDiarySensorControlChecks.m b/src/ios/Verification/TripDiarySensorControlChecks.m new file mode 100644 index 00000000..402c1483 --- /dev/null +++ b/src/ios/Verification/TripDiarySensorControlChecks.m @@ -0,0 +1,59 @@ +#import "TripDiarySensorControlChecks.h" +#import "LocalNotificationManager.h" +#import "BEMAppDelegate.h" +#import "BEMActivitySync.h" + +#import + +@implementation TripDiarySensorControlChecks + ++(BOOL)checkLocationSettings { + return [CLLocationManager locationServicesEnabled]; +} + +// TODO: Decide whether we want to have a separate check for precise + ++(BOOL)checkLocationPermissions { + BOOL alwaysPerm = [CLLocationManager authorizationStatus] == kCLAuthorizationStatusAuthorizedAlways; + BOOL precisePerm = TRUE; + CLLocationManager* currLocMgr = [TripDiaryStateMachine instance].locMgr; + if (@available(iOS 14.0, *)) { + CLAccuracyAuthorization preciseOrNot = [currLocMgr accuracyAuthorization]; + precisePerm = preciseOrNot == CLAccuracyAuthorizationFullAccuracy; + } else { + [LocalNotificationManager addNotification:[NSString stringWithFormat:@"No precise location check needed for iOS < 14"]]; + } + [LocalNotificationManager addNotification:[NSString stringWithFormat:@"Returning combination of always = %@ and precise %@", @(alwaysPerm), @(precisePerm)]]; + return alwaysPerm && precisePerm; +} + ++(BOOL)checkMotionActivitySettings { +#if TARGET_OS_SIMULATOR + return TRUE; +#else + return [CMMotionActivityManager isActivityAvailable] == YES; +#endif +} + ++(BOOL)checkMotionActivityPermissions { +#if TARGET_OS_SIMULATOR + return TRUE; +#else + CMAuthorizationStatus currAuthStatus = [CMMotionActivityManager authorizationStatus]; + return currAuthStatus == CMAuthorizationStatusAuthorized; +#endif +} + ++(BOOL)checkNotificationsEnabled { + UIUserNotificationSettings* requestedSettings = [self REQUESTED_NOTIFICATION_TYPES]; + UIUserNotificationSettings* currSettings = [[UIApplication sharedApplication] currentUserNotificationSettings]; + return requestedSettings.types == currSettings.types; +} + ++(UIUserNotificationSettings*) REQUESTED_NOTIFICATION_TYPES { + return [UIUserNotificationSettings + settingsForTypes:UIUserNotificationTypeAlert|UIUserNotificationTypeBadge + categories:nil]; +} + +@end diff --git a/www/datacollection.js b/www/datacollection.js index 4d7d5bc1..912ae6b1 100644 --- a/www/datacollection.js +++ b/www/datacollection.js @@ -21,6 +21,66 @@ var DataCollection = { exec(resolve, reject, "DataCollection", "markConsented", [newConsent]); }); }, + fixLocationSettings: function () { + return new Promise(function(resolve, reject) { + exec(resolve, reject, "DataCollection", "fixLocationSettings", []); + }); + }, + isValidLocationSettings: function () { + return new Promise(function(resolve, reject) { + exec(resolve, reject, "DataCollection", "isValidLocationSettings", []); + }); + }, + fixLocationPermissions: function () { + return new Promise(function(resolve, reject) { + exec(resolve, reject, "DataCollection", "fixLocationPermissions", []); + }); + }, + isValidLocationPermissions: function () { + return new Promise(function(resolve, reject) { + exec(resolve, reject, "DataCollection", "isValidLocationPermissions", []); + }); + }, + fixFitnessPermissions: function () { + return new Promise(function(resolve, reject) { + exec(resolve, reject, "DataCollection", "fixFitnessPermissions", []); + }); + }, + isValidFitnessPermissions: function () { + return new Promise(function(resolve, reject) { + exec(resolve, reject, "DataCollection", "isValidFitnessPermissions", []); + }); + }, + fixShowNotifications: function () { + return new Promise(function(resolve, reject) { + exec(resolve, reject, "DataCollection", "fixShowNotifications", []); + }); + }, + isValidShowNotifications: function () { + return new Promise(function(resolve, reject) { + exec(resolve, reject, "DataCollection", "isValidShowNotifications", []); + }); + }, + isNotificationsUnpaused: function () { + return new Promise(function(resolve, reject) { + exec(resolve, reject, "DataCollection", "isNotificationsUnpaused", []); + }); + }, + fixUnusedAppRestrictions: function () { + return new Promise(function(resolve, reject) { + exec(resolve, reject, "DataCollection", "fixUnusedAppRestrictions", []); + }); + }, + isUnusedAppUnrestricted: function () { + return new Promise(function(resolve, reject) { + exec(resolve, reject, "DataCollection", "isUnusedAppUnrestricted", []); + }); + }, + fixOEMBackgroundRestrictions: function () { + return new Promise(function(resolve, reject) { + exec(resolve, reject, "DataCollection", "fixOEMBackgroundRestrictions", []); + }); + }, storeBatteryLevel: function () { return new Promise(function(resolve, reject) { exec(resolve, reject, "DataCollection", "storeBatteryLevel", []);