diff --git a/README.md b/README.md index 1ad7bf4..d05b967 100644 --- a/README.md +++ b/README.md @@ -1 +1,15 @@ -# android +Testing Hospitalization +
+
+1. You can add custom 'hospitals' (locations that will trigger the survey) in addHospitalForDebugging() of HospitalizationService.java. There should already be 1 custom hospital, so you can just follow that format to add as many as you'd like. +

+2. You'll probably want to change the trigger times so you're not sitting around for 5 hours. You'll want to change Constants.GEOFENCE_LOITER_TIME_MILLIS (amount of time to mark user as hospitalized) and Constants.SURVEY_TRIGGER_MILLIS (amount of time to wait after user exits hospital before displaying survey) in Constants.java. +

+3. Install the app on your device once you've set the above variables. +3a. If this is NOT a fresh install (i.e. you previously installed the app and decided to change some variables and re-install), you'll need to press the 'RESTART SERVICE' button after installation. +

+4. Assuming you've placed a hospital where your current location is, wait for GEOFENCE_LOITER_TIME_MILLIS (I would wait an extra minute or two to account for the geofence setup time). This will mark the user as hospitalized (you will NOT receive any indication of this on your phone, but it will display in the Android Studio logs). +

+5. To mimic a user exiting a location, I use a third-party application called "Mock Locations" (https://play.google.com/store/apps/details?id=ru.gavrikov.mocklocations&hl=en). You need to enable 'Allow mock locations' in your phone's developer options (Settings -> Developer Options). Follow the app's instructions to mimic leaving the 'hospital'. +

+6. Wait for SURVEY_TRIGGER_MILLIS and you should receive a notification that upon clicking will take you to the app and display a survey. diff --git a/app/app.iml b/app/app.iml index 2e68398..8c90823 100644 --- a/app/app.iml +++ b/app/app.iml @@ -69,8 +69,34 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -84,11 +110,41 @@ + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 7c5f0c1..6a0816a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,13 +1,13 @@ apply plugin: 'com.android.application' android { - compileSdkVersion 22 - buildToolsVersion "22.0.1" + compileSdkVersion 23 + buildToolsVersion "23" defaultConfig { applicationId "org.healtheheartstudy" - minSdkVersion 9 - targetSdkVersion 22 + minSdkVersion 10 + targetSdkVersion 23 versionCode 1 versionName "1.0" } @@ -21,5 +21,11 @@ android { dependencies { compile fileTree(dir: 'libs', include: ['*.jar']) - compile 'com.android.support:appcompat-v7:22.2.0' + compile 'com.android.support:appcompat-v7:23.+' + compile 'com.google.android.gms:play-services:7.5.0' + compile 'com.mcxiaoke.volley:library:1.0.+' + compile 'com.google.code.gson:gson:2.2.+' + compile 'com.jakewharton.timber:timber:3.1.0' + compile 'com.scottyab:secure-preferences-lib:0.1.3' + compile 'com.afollestad:material-dialogs:0.7.9.1' } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f408466..1779dcc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,7 +2,13 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/org/healtheheartstudy/AlarmHelper.java b/app/src/main/java/org/healtheheartstudy/AlarmHelper.java new file mode 100644 index 0000000..e66d452 --- /dev/null +++ b/app/src/main/java/org/healtheheartstudy/AlarmHelper.java @@ -0,0 +1,53 @@ +package org.healtheheartstudy; + +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.os.SystemClock; + +import java.text.SimpleDateFormat; +import java.util.Calendar; + +/** + * AlarmManager wrapper + */ +public class AlarmHelper { + + private Context context; + private Intent intent; + + public AlarmHelper(Context context, String intentAction) { + this.context = context; + intent = new Intent(context, CustomBroadcastReceiver.class); + intent.setAction(intentAction); + } + + public void putExtra(String key, String value) { + intent.putExtra(key, value); + } + + public void set(long triggerAtMillis) { + PendingIntent pi = PendingIntent.getBroadcast(context, 0, intent, 0); + AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + am.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, SystemClock.elapsedRealtime() + triggerAtMillis, pi); + } + + public void setRepeating(long triggerAtMillis, long intervalMillis) { + PendingIntent pi = PendingIntent.getBroadcast(context, 0, intent, 0); + AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + am.setRepeating( + AlarmManager.ELAPSED_REALTIME_WAKEUP, + SystemClock.elapsedRealtime() + triggerAtMillis, + intervalMillis, + pi); + } + + // Returns a date formatted in mm-dd-yyyy (e.g. 05-26-1992) + public static String getCurrentDate() { + Calendar c = Calendar.getInstance(); + SimpleDateFormat df = new SimpleDateFormat("MM-dd-yyyy"); + return df.format(c.getTime()); + } + +} diff --git a/app/src/main/java/org/healtheheartstudy/Constants.java b/app/src/main/java/org/healtheheartstudy/Constants.java new file mode 100644 index 0000000..2b5f609 --- /dev/null +++ b/app/src/main/java/org/healtheheartstudy/Constants.java @@ -0,0 +1,34 @@ +package org.healtheheartstudy; + +/** + * Created by dannypark on 7/1/15. + */ +public class Constants { + + public static final String KEY_FIRST_APP_OPEN = "first_app_open"; + public static final String KEY_SERVICE_ACTION = "service_action"; + public static final String KEY_HOSPITAL_NAME = "hospital_name"; + public static final String KEY_HOSPITAL_LAT = "hospital_lat"; + public static final String KEY_HOSPITAL_LNG = "hospital_lng"; + public static final String KEY_TRANSITION_TYPE = "transition_type"; + public static final String KEY_PREV_USER_LAT = "prev_user_lat"; + public static final String KEY_PREV_USER_LNG = "prev_user_lng"; + public static final String KEY_DATE = "date"; + public static final String KEY_PERSISTENT_SURVEY_HOSPITAL = "persistent_hospital_name"; + public static final String KEY_PERSISTENT_SURVEY_DATE = "persistent_date"; + + public static final String ACTION_CREATE_GEOFENCES = "service_create_geofences"; + public static final String ACTION_UPDATE_TRANSITION_TYPE = "update_transition_type"; + public static final String ACTION_SURVEY_ALARM = "survey_alarm"; + public static final String ACTION_CHECK_LOCATION = "check_location"; + + public static final long ONE_HOUR_MILLIS = 1000 * 60 * 60; + public static final long ONE_DAY_MILLIS = ONE_HOUR_MILLIS * 24; + public static final int TWO_MINUTES_MILLIS = 1000 * 60 * 2; + + public static final long SURVEY_TRIGGER_MILLIS = ONE_HOUR_MILLIS; + + public static final int GEOFENCE_RADIUS_METERS = 100; + public static final int GEOFENCE_LOITER_TIME_MILLIS = 1000 * 60 * 60 * 4; + +} diff --git a/app/src/main/java/org/healtheheartstudy/CustomBroadcastReceiver.java b/app/src/main/java/org/healtheheartstudy/CustomBroadcastReceiver.java new file mode 100644 index 0000000..0b72f7e --- /dev/null +++ b/app/src/main/java/org/healtheheartstudy/CustomBroadcastReceiver.java @@ -0,0 +1,85 @@ +package org.healtheheartstudy; + +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.support.v4.app.NotificationCompat; +import android.support.v4.app.NotificationManagerCompat; + +import com.securepreferences.SecurePreferences; + +import timber.log.Timber; + +/** + * CustomBroadcastReceiver handles three events. The first is BOOT_COMPLETED, which is when the + * device boots up. During this event, we need to recreate all geofences because they don't + * persist when the device shuts off. The second event is prompted 1-hour after a user + * leaves a hospital (after dwelling at a hospital for 4 hours). During this event, we need to + * display a notification that asks the user a few questions about their hospital visit. The + * third event is prompted by a daily alarm that launches our service to check if we need to + * find new hospitals because the user moved a significant distance. + */ +public class CustomBroadcastReceiver extends BroadcastReceiver { + + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (action != null) { + if (action.equals(Intent.ACTION_BOOT_COMPLETED)) { + Timber.d("BOOT COMPLETED"); + // Create geofences + Intent serviceIntent = new Intent(context, HospitalizationService.class); + serviceIntent.putExtra(Constants.KEY_SERVICE_ACTION, Constants.ACTION_CREATE_GEOFENCES); + context.startService(serviceIntent); + + // Start alarm to check user's location every day + AlarmHelper ah = new AlarmHelper(context, Constants.ACTION_CHECK_LOCATION); + ah.setRepeating(Constants.ONE_DAY_MILLIS, Constants.ONE_DAY_MILLIS); + } else if (action.equals(Constants.ACTION_SURVEY_ALARM)) { + Timber.d("ALARM TRIGGERED FOR SURVEY"); + String hospitalName = intent.getStringExtra(Constants.KEY_HOSPITAL_NAME); + buildNotification(context, hospitalName); + } else if (action.equals(Constants.ACTION_CHECK_LOCATION)) { + Timber.d("ALARM TRIGGERED FOR CHECK LOCATION"); + Intent serviceIntent = new Intent(context, HospitalizationService.class); + serviceIntent.putExtra(Constants.KEY_SERVICE_ACTION, action); + context.startService(serviceIntent); + } + } + } + + private void buildNotification(Context context, String hospitalName) { + // Need to store this in SharedPreferences in case user clears notification + String date = AlarmHelper.getCurrentDate(); + SharedPreferences prefs = new SecurePreferences(context.getApplicationContext()); + SharedPreferences.Editor editor = prefs.edit(); + editor.putString(Constants.KEY_PERSISTENT_SURVEY_HOSPITAL, hospitalName); + editor.putString(Constants.KEY_PERSISTENT_SURVEY_DATE, date); + editor.apply(); + + Intent displayIntent = new Intent(context, MainActivity.class); + displayIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); + displayIntent.putExtra(Constants.KEY_HOSPITAL_NAME, hospitalName); + displayIntent.putExtra(Constants.KEY_DATE, date); + PendingIntent displayPendingIntent = PendingIntent.getActivity( + context, + 0, + displayIntent, + PendingIntent.FLAG_UPDATE_CURRENT + ); + + NotificationCompat.Builder notifBuilder = new NotificationCompat.Builder(context) + .setSmallIcon(R.drawable.ic_media_pause) + .setContentTitle("Health eHeart Study") + .setContentText("We noticed that you're near a hospital and would like you to fill out a survey.") + .setContentIntent(displayPendingIntent) + .setAutoCancel(true) + .setVibrate(new long[]{500, 500, 500, 500}); + + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + notificationManager.notify(1, notifBuilder.build()); + } + +} diff --git a/app/src/main/java/org/healtheheartstudy/GeofenceIntentService.java b/app/src/main/java/org/healtheheartstudy/GeofenceIntentService.java new file mode 100644 index 0000000..36a904d --- /dev/null +++ b/app/src/main/java/org/healtheheartstudy/GeofenceIntentService.java @@ -0,0 +1,53 @@ +package org.healtheheartstudy; + +import android.app.IntentService; +import android.content.Intent; + +import com.google.android.gms.location.Geofence; +import com.google.android.gms.location.GeofencingEvent; + +import java.util.List; + +import timber.log.Timber; + +/** + * GeofenceIntentService is invoked when a geofence is triggered. + */ +public class GeofenceIntentService extends IntentService { + + public GeofenceIntentService() { + super("GeofenceIntentService"); + } + + @Override + protected void onHandleIntent(Intent intent) { + GeofencingEvent gEvent = GeofencingEvent.fromIntent(intent); + if (gEvent.hasError()) { + Timber.e("Geofence error code: " + gEvent.getErrorCode()); + } else { + // Retrieve geofence info + List places = gEvent.getTriggeringGeofences(); + String placeName = places.get(0).getRequestId(); + Double lat = gEvent.getTriggeringLocation().getLatitude(); + Double lng = gEvent.getTriggeringLocation().getLongitude(); + int transitionType = gEvent.getGeofenceTransition(); + + // Launch service to update geofence transition trigger + Intent serviceIntent = new Intent(this, HospitalizationService.class); + serviceIntent.putExtra(Constants.KEY_SERVICE_ACTION, Constants.ACTION_UPDATE_TRANSITION_TYPE); + serviceIntent.putExtra(Constants.KEY_TRANSITION_TYPE, transitionType); + serviceIntent.putExtra(Constants.KEY_HOSPITAL_NAME, placeName); + serviceIntent.putExtra(Constants.KEY_HOSPITAL_LAT, lat); + serviceIntent.putExtra(Constants.KEY_HOSPITAL_LNG, lng); + startService(serviceIntent); + + // If EXIT triggered, we need to set an alarm that will display the survey in 1 hour + if (transitionType == Geofence.GEOFENCE_TRANSITION_EXIT) { + Timber.d("EXIT was triggered. Starting timer now"); + AlarmHelper ah = new AlarmHelper(this, Constants.ACTION_SURVEY_ALARM); + ah.putExtra(Constants.KEY_HOSPITAL_NAME, placeName); + ah.set(Constants.SURVEY_TRIGGER_MILLIS); + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/healtheheartstudy/HospitalHelper.java b/app/src/main/java/org/healtheheartstudy/HospitalHelper.java new file mode 100644 index 0000000..a4b0a6c --- /dev/null +++ b/app/src/main/java/org/healtheheartstudy/HospitalHelper.java @@ -0,0 +1,111 @@ +package org.healtheheartstudy; + +import android.location.Location; +import android.os.Handler; + +import com.android.volley.Response; +import com.android.volley.VolleyError; + +import org.healtheheartstudy.network.GsonRequest; +import org.healtheheartstudy.network.RequestManager; + +import java.util.ArrayList; +import java.util.List; + +import timber.log.Timber; + +/** + * HospitalHelper retrieves all hospitals that are within a provided radius of a provided location + * by using Google Places Search API (web API). + */ +public class HospitalHelper implements Response.Listener, Response.ErrorListener { + + public static final int DEFAULT_SEARCH_RADIUS = 10000; + private static final String API_KEY = "AIzaSyALHFB5BA3emjybU9ZrPUCLDEcCyC37vjk"; + private static final String API_ROOT = "https://maps.googleapis.com/maps/api/place/nearbysearch/json?key=" + API_KEY; + + private List mHospitalsInArea; + private Listener listener; + + interface Listener { + void onHospitalsFound(List hospitals); + } + + /** + * Launches a request to retrieve hospitals that are within the provided radius + * away from the provided location. + * @param location The area to find hospitals in. + * @param radius The search radius in meters. + * @param listener The callback for when hospitals are found. + */ + public synchronized void findHospitalsInArea(Location location, int radius, Listener listener) { + this.mHospitalsInArea = new ArrayList<>(); + this.listener = listener; + findHospitalsInArea(location.getLatitude(), location.getLongitude(), radius, null); + } + + /** + * Makes a request to find all hospitals within the provided radius + * of location. + * @param lat + * @param lng + * @param radius + * @param nextPageToken @Nullable. If set, we need to retrieve the next page of results + */ + private void findHospitalsInArea(double lat, double lng, int radius, String nextPageToken) { + // Build URL + String requestURL = ""; + if (nextPageToken == null) { + requestURL = API_ROOT + "&location=" + lat + "," + lng + + "&radius=" + radius + + "&types=hospital"; + } else { + requestURL = API_ROOT + "&pagetoken=" + nextPageToken; + } + + // Build request + GsonRequest request = new GsonRequest<>( + requestURL, + PlaceSearchResult.class, + null, + this, + this + ); + + // Launch request + RequestManager.getRequestQueue().add(request); + } + + /** + * Handles success callback for retrieving a list of nearby hospitals. Will make another request + * if there are additional pages to retrieve. + * @param response + */ + @Override + public void onResponse(final PlaceSearchResult response) { + mHospitalsInArea.addAll(response.getPlaces()); + boolean moreResultsToFetch = !response.getNextPage().isEmpty(); + if (moreResultsToFetch) { + // Google docs state that the nextPage won't be accessible for 'a few moments', so + // take a 5 second nap and then request the next page + new Handler().postDelayed(new Runnable() { + @Override + public void run() { + findHospitalsInArea(0, 0, 0, response.getNextPage()); + } + }, 5000); + } else { + listener.onHospitalsFound(mHospitalsInArea); + } + } + + /** + * Handles error callback for retrieving a list of nearby hospitals. + * @param error + */ + @Override + public void onErrorResponse(VolleyError error) { + Timber.d("Network error: " + error.getMessage()); + // TODO: do something here + } +} diff --git a/app/src/main/java/org/healtheheartstudy/HospitalizationService.java b/app/src/main/java/org/healtheheartstudy/HospitalizationService.java new file mode 100644 index 0000000..b09605d --- /dev/null +++ b/app/src/main/java/org/healtheheartstudy/HospitalizationService.java @@ -0,0 +1,208 @@ +package org.healtheheartstudy; + +import android.app.Service; +import android.content.Intent; +import android.content.SharedPreferences; +import android.location.Location; +import android.os.IBinder; + +import com.google.android.gms.common.api.ResultCallback; +import com.google.android.gms.common.api.Status; +import com.google.android.gms.location.Geofence; +import com.google.android.gms.location.LocationRequest; +import com.securepreferences.SecurePreferences; + +import org.healtheheartstudy.client.GeofenceClient; + +import java.util.Arrays; +import java.util.List; + +import timber.log.Timber; + +/** + * HospitalizationService is started to perform one of three tasks: + * + * a. Build geofences around nearby hospitals + * 1) Retrieve the user's location + * 2) Retrieve hospitals near user's location + * 3) Remove any existing geofences + * 4) Create geofences around the hospitals + * b. Update geofence transition triggers + * c. Check if user has moved a significant distance, in which case we need to + * find new hospitals in the area (perform steps a2 - a4). This check is + * scheduled to occur daily. + * + * After the service performs a task, it is shut down so that no additional resources are consumed. + * Although the service shuts down, the geofences will continue to be tracked in the background. + */ +public class HospitalizationService extends Service implements + HospitalHelper.Listener, + GeofenceClient.Listener, + ResultCallback { + + private GeofenceClient mGeofenceClient; + private HospitalHelper mHospitalHelper; + private Intent mIntent; + private String action; + + @Override + public void onCreate() { + super.onCreate(); + Timber.d("onCreate"); + mHospitalHelper = new HospitalHelper(); + mGeofenceClient = new GeofenceClient(this, this); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + Timber.d("onStartCommand"); + mIntent = intent; + mGeofenceClient.connect(); + return START_NOT_STICKY; + } + + @Override + public void onDestroy() { + super.onDestroy(); + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + /** + * Notifies the service when the google api client has connected. + */ + @Override + public void onConnected() { + Timber.d("onConnected"); + action = mIntent.getStringExtra(Constants.KEY_SERVICE_ACTION); + if (action.equals(Constants.ACTION_CREATE_GEOFENCES) || action.equals(Constants.ACTION_CHECK_LOCATION)) { + Timber.d("CREATE_GEOFENCES or CHECK_LOCATION"); + // Instead of getting lastKnownLocation, request a single location update for better accuracy + LocationRequest mLocationRequest = new LocationRequest(); + mLocationRequest.setInterval(1000 * 60); + mLocationRequest.setFastestInterval(1000); + mLocationRequest.setPriority(LocationRequest.PRIORITY_HIGH_ACCURACY); + mLocationRequest.setNumUpdates(1); + mGeofenceClient.getUserLocation(mLocationRequest); + } else if (action.equals(Constants.ACTION_UPDATE_TRANSITION_TYPE)) { + // Retrieve extras + String hospitalName = mIntent.getStringExtra(Constants.KEY_HOSPITAL_NAME); + Double lat = mIntent.getDoubleExtra(Constants.KEY_HOSPITAL_LAT, 0); + Double lng = mIntent.getDoubleExtra(Constants.KEY_HOSPITAL_LNG, 0); + final int transitionTrigger = mIntent.getIntExtra(Constants.KEY_TRANSITION_TYPE, 0); + + // Create place with extras data + PlaceSearchResult psr = new PlaceSearchResult(); + final PlaceSearchResult.Place hospital = psr.new Place(); + hospital.name = hospitalName; + hospital.setLocation(lat, lng); + + // Update transition trigger + int newTransitionTrigger = 0; + if (transitionTrigger == Geofence.GEOFENCE_TRANSITION_DWELL) { + newTransitionTrigger = Geofence.GEOFENCE_TRANSITION_EXIT; + Timber.d("DWELL was triggered -- now updating transition to EXIT"); + } else if (transitionTrigger == Geofence.GEOFENCE_TRANSITION_EXIT){ + newTransitionTrigger = Geofence.GEOFENCE_TRANSITION_DWELL; + Timber.d("EXIT was triggered -- now updating transition to DWELL"); + } + mGeofenceClient.createGeofences(Arrays.asList(hospital), + newTransitionTrigger, + this); + } + } + + /** + * Serves as a callback that receives the user's current location. We search for hospitals + * with this location. The action will specify if we should immediately search for hospitals + * or if we need to check if the current location is far from the previous location, in which + * case we need to search for new hospitals in the new area. + * + * @param location + */ + @Override + public void onLocationChanged(Location location) { + Timber.d("Found user's location: " + location.toString()); + boolean stopService = false; + SharedPreferences prefs = new SecurePreferences(this); + + if (action.equals(Constants.ACTION_CREATE_GEOFENCES)) { + mHospitalHelper.findHospitalsInArea(location, HospitalHelper.DEFAULT_SEARCH_RADIUS, this); + } else if (action.equals(Constants.ACTION_CHECK_LOCATION)) { + // Get previous location + double prevLat = Double.longBitsToDouble(prefs.getLong(Constants.KEY_PREV_USER_LAT, 0)); + double prevLng = Double.longBitsToDouble(prefs.getLong(Constants.KEY_PREV_USER_LNG, 0)); + + // Get distance between locations + Location prevLocation = new Location(""); + prevLocation.setLatitude(prevLat); + prevLocation.setLongitude(prevLng); + float distance = location.distanceTo(prevLocation); + + if (distance > HospitalHelper.DEFAULT_SEARCH_RADIUS) { + // Find new hospitals to track because the user moved a significant distance + mHospitalHelper.findHospitalsInArea(location, HospitalHelper.DEFAULT_SEARCH_RADIUS, this); + } else { + stopService = true; + } + } + + // Save current location to SP + SharedPreferences.Editor editor = prefs.edit(); + editor.putLong(Constants.KEY_PREV_USER_LAT, Double.doubleToRawLongBits(location.getLatitude())); + editor.putLong(Constants.KEY_PREV_USER_LNG, Double.doubleToRawLongBits(location.getLongitude())); + editor.apply(); + + if (stopService) { + mGeofenceClient.disconnect(); + stopSelf(); + } + } + + /** + * Serves as a callback for when hospitals in the area are found. We want to create the + * geofences once we have the hospitals. + * @param hospitals + */ + @Override + public void onHospitalsFound(final List hospitals) { + Timber.d("Hospitals found: " + hospitals.size()); + // Remove any existing geofences before creating new ones. + mGeofenceClient.removeAllFences(new ResultCallback() { + @Override + public void onResult(Status status) { + Timber.d("Finished removing fences"); + addHospitalForDebugging(hospitals); + mGeofenceClient.createGeofences(hospitals, Geofence.GEOFENCE_TRANSITION_DWELL, HospitalizationService.this); + } + }); + } + + /** + * Notifies the service when a Geofence API call has finished. We only care about the callbacks + * for creating geofences and removing a geofence, because it lets us know the service can + * be stopped. + * @param status + */ + @Override + public void onResult(Status status) { + if (status.isSuccess()) { + mGeofenceClient.disconnect(); + stopSelf(); + } else { + // TODO: Handle failure case + } + } + + private void addHospitalForDebugging(List hospitals) { + PlaceSearchResult psr = new PlaceSearchResult(); + PlaceSearchResult.Place place = psr.new Place(); + place.name = "Home"; + place.setLocation(42.022732, -87.66665); + hospitals.add(place); + } + +} diff --git a/app/src/main/java/org/healtheheartstudy/MainActivity.java b/app/src/main/java/org/healtheheartstudy/MainActivity.java index 96793de..4b516ce 100644 --- a/app/src/main/java/org/healtheheartstudy/MainActivity.java +++ b/app/src/main/java/org/healtheheartstudy/MainActivity.java @@ -1,9 +1,15 @@ package org.healtheheartstudy; -import android.support.v7.app.ActionBarActivity; +import android.content.Intent; +import android.content.SharedPreferences; import android.os.Bundle; -import android.view.Menu; -import android.view.MenuItem; +import android.support.v7.app.ActionBarActivity; +import android.view.View; + +import com.afollestad.materialdialogs.MaterialDialog; +import com.securepreferences.SecurePreferences; + +import timber.log.Timber; public class MainActivity extends ActionBarActivity { @@ -12,27 +18,112 @@ public class MainActivity extends ActionBarActivity { protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); + + // Create geofences if this is the first time opening the app + SharedPreferences prefs = new SecurePreferences(getApplicationContext()); + boolean firstOpen = prefs.getBoolean(Constants.KEY_FIRST_APP_OPEN, true); + if (firstOpen) { + // Launch hospitalization + Intent serviceIntent = new Intent(this, HospitalizationService.class); + serviceIntent.putExtra(Constants.KEY_SERVICE_ACTION, Constants.ACTION_CREATE_GEOFENCES); + startService(serviceIntent); + + // Start alarm to check user's location every day + AlarmHelper ah = new AlarmHelper(this, Constants.ACTION_CHECK_LOCATION); + ah.setRepeating(Constants.ONE_DAY_MILLIS, Constants.ONE_DAY_MILLIS); + + prefs.edit().putBoolean(Constants.KEY_FIRST_APP_OPEN, false).apply(); + } } - @Override - public boolean onCreateOptionsMenu(Menu menu) { - // Inflate the menu; this adds items to the action bar if it is present. - getMenuInflater().inflate(R.menu.menu_main, menu); - return true; + public void displayDummySurvey(View v) { + displaySurvey("SOME_HOSPITAL", AlarmHelper.getCurrentDate()); } - @Override - public boolean onOptionsItemSelected(MenuItem item) { - // Handle action bar item clicks here. The action bar will - // automatically handle clicks on the Home/Up button, so long - // as you specify a parent activity in AndroidManifest.xml. - int id = item.getItemId(); - - //noinspection SimplifiableIfStatement - if (id == R.id.action_settings) { - return true; + private void displaySurvey(String hospitalName, String date) { + String content = "Were you at " + hospitalName + " on " + date + " for your medical care?"; + MaterialDialog.Builder builder = new MaterialDialog.Builder(this) + .content(content) + .positiveText("Yes") + .negativeText("No") + .neutralText("I was there for another reason"); + builder.callback( + new MaterialDialog.ButtonCallback() { + @Override + public void onPositive(MaterialDialog dialog) { + super.onPositive(dialog); + dialog.dismiss(); + removeSurvey(); + } + + @Override + public void onNegative(MaterialDialog dialog) { + super.onNegative(dialog); + dialog.dismiss(); + removeSurvey(); + } + + @Override + public void onNeutral(MaterialDialog dialog) { + super.onNeutral(dialog); + dialog.dismiss(); + removeSurvey(); + } + } + ); + builder.cancelable(false); + builder.show(); + } + + /** + * Checks if the app was opened from a notification, in which case we need to present + * a survey to the user. We also check SharedPreferences in case the user cleared + * the notification. + * @param intent + */ + private void checkForSurvey(Intent intent) { + String hospitalName = intent.getStringExtra(Constants.KEY_HOSPITAL_NAME); + String date = intent.getStringExtra(Constants.KEY_DATE); + if (hospitalName != null && date != null) { + Timber.d("Intent was not null"); + displaySurvey(hospitalName, date); + } else { + SharedPreferences prefs = new SecurePreferences(getApplicationContext()); + hospitalName = prefs.getString(Constants.KEY_PERSISTENT_SURVEY_HOSPITAL, null); + date = prefs.getString(Constants.KEY_PERSISTENT_SURVEY_DATE, null); + if (hospitalName != null) { + Timber.d("SharedPrefs --> KEY_SURVEY was not null"); + displaySurvey(hospitalName, date); + } } + } - return super.onOptionsItemSelected(item); + /** + * Removes survey from SharedPreferences. + */ + private void removeSurvey() { + SharedPreferences prefs = new SecurePreferences(getApplicationContext()); + SharedPreferences.Editor editor = prefs.edit(); + editor.putString(Constants.KEY_PERSISTENT_SURVEY_HOSPITAL, null); + editor.putString(Constants.KEY_PERSISTENT_SURVEY_DATE, null); + editor.apply(); + } + + public void restartService(View v) { + Intent serviceIntent = new Intent(this, HospitalizationService.class); + serviceIntent.putExtra(Constants.KEY_SERVICE_ACTION, Constants.ACTION_CREATE_GEOFENCES); + startService(serviceIntent); + } + + @Override + protected void onNewIntent(Intent intent) { + Timber.d("onNewIntent()"); + checkForSurvey(intent); + } + + @Override + public void onResume() { + super.onResume(); + checkForSurvey(getIntent()); } } diff --git a/app/src/main/java/org/healtheheartstudy/MainApp.java b/app/src/main/java/org/healtheheartstudy/MainApp.java new file mode 100644 index 0000000..2e2e4d9 --- /dev/null +++ b/app/src/main/java/org/healtheheartstudy/MainApp.java @@ -0,0 +1,18 @@ +package org.healtheheartstudy; + +import android.app.Application; + +import org.healtheheartstudy.network.RequestManager; + +import timber.log.Timber; + +public class MainApp extends Application { + + @Override + public void onCreate() { + super.onCreate(); + RequestManager.init(this); + if (BuildConfig.DEBUG) Timber.plant(new Timber.DebugTree()); + } + +} diff --git a/app/src/main/java/org/healtheheartstudy/PlaceSearchResult.java b/app/src/main/java/org/healtheheartstudy/PlaceSearchResult.java new file mode 100644 index 0000000..6c6f64c --- /dev/null +++ b/app/src/main/java/org/healtheheartstudy/PlaceSearchResult.java @@ -0,0 +1,100 @@ +package org.healtheheartstudy; + +import java.util.ArrayList; +import java.util.List; + +/** + * PlaceSearchResult is a subset of Places from Google Places API. It is molded to our needs. + */ +public class PlaceSearchResult { + + private String status; + private List results; + private List html_attributions; + private String next_page_token; + + PlaceSearchResult() { + status = ""; + results = new ArrayList<>(); + html_attributions = new ArrayList<>(); + next_page_token = ""; + } + + public String getStatus() { + return status; + } + + public List getPlaces() { + return results; + } + + public String getNextPage() { + return next_page_token; + } + + + public class Place { + + public Geometry geometry; + public String icon; + public String name; + public String place_id; + public String scope; + public List types; + public String vicinity; + + Place() { + geometry = new Geometry(); + icon = ""; + name = ""; + place_id = ""; + scope = ""; + types = new ArrayList<>(); + vicinity = ""; + } + + public android.location.Location getLocation() { + android.location.Location location = new android.location.Location(""); + location.setLatitude(geometry.location.lat); + location.setLongitude(geometry.location.lng); + return location; + } + + public void setLocation(Double lat, Double lng) { + geometry = new Geometry(lat, lng); + } + + class Geometry { + + Location location; + + Geometry() { + location = new Location(); + } + + Geometry(Double lat, Double lng) { + this.location = new Location(lat, lng); + } + + } + + class Location { + + double lat; + double lng; + + Location() { + lat = 0.0; + lng = 0.0; + } + + Location(Double lat, Double lng) { + this.lat = lat; + this.lng = lng; + } + + } + + } + +} diff --git a/app/src/main/java/org/healtheheartstudy/client/Client.java b/app/src/main/java/org/healtheheartstudy/client/Client.java new file mode 100644 index 0000000..3e22e44 --- /dev/null +++ b/app/src/main/java/org/healtheheartstudy/client/Client.java @@ -0,0 +1,43 @@ +package org.healtheheartstudy.client; + +import android.content.Context; +import android.os.Bundle; + +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.api.Api; +import com.google.android.gms.common.api.GoogleApiClient; + +/** + * Client is a simple GoogleApiClient wrapper. + */ +public class Client implements GoogleApiClient.ConnectionCallbacks, GoogleApiClient.OnConnectionFailedListener { + + protected GoogleApiClient mGoogleApiClient; + + protected synchronized void connect(Context context, Api api) { + if (mGoogleApiClient == null || !mGoogleApiClient.isConnected()) { + mGoogleApiClient = new GoogleApiClient.Builder(context) + .addConnectionCallbacks(this) + .addOnConnectionFailedListener(this) + .addApi(api) + .build(); + mGoogleApiClient.connect(); + } + } + + public void disconnect() { + mGoogleApiClient.disconnect(); + } + + @Override + public void onConnected(Bundle bundle) { + } + + @Override + public void onConnectionSuspended(int i) { + } + + @Override + public void onConnectionFailed(ConnectionResult connectionResult) { + } +} diff --git a/app/src/main/java/org/healtheheartstudy/client/GeofenceClient.java b/app/src/main/java/org/healtheheartstudy/client/GeofenceClient.java new file mode 100644 index 0000000..df11a97 --- /dev/null +++ b/app/src/main/java/org/healtheheartstudy/client/GeofenceClient.java @@ -0,0 +1,229 @@ +package org.healtheheartstudy.client; + +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.location.Location; +import android.os.Bundle; + +import com.google.android.gms.common.api.ResultCallback; +import com.google.android.gms.common.api.Status; +import com.google.android.gms.location.Geofence; +import com.google.android.gms.location.GeofencingRequest; +import com.google.android.gms.location.LocationListener; +import com.google.android.gms.location.LocationRequest; +import com.google.android.gms.location.LocationServices; + +import org.healtheheartstudy.Constants; +import org.healtheheartstudy.GeofenceIntentService; +import org.healtheheartstudy.PlaceSearchResult; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import timber.log.Timber; + +/** + * GeofenceClient provides an interface for creating geofences and retrieving a user's + * location. + */ +public class GeofenceClient extends Client implements LocationListener { + + private List mGeofences; + private PendingIntent mGeofencePendingIntent; + private Context context; + private Location mCurrentLocation; + private Listener mListener; + + public interface Listener { + void onConnected(); + void onLocationChanged(Location location); + } + + public GeofenceClient(Context context, Listener listener) { + this.context = context; + this.mListener = listener; + mGeofences = new ArrayList<>(); + } + + /** + * Connects to Google API. The specfic API it connects to is passed into the constructor + * for this class. + */ + public void connect() { + connect(context, LocationServices.API); + } + + /** + * Retrieves the user's current location. + * @param request + */ + public void getUserLocation(LocationRequest request) { + LocationServices.FusedLocationApi.requestLocationUpdates(mGoogleApiClient, + request, + this); + } + + /** + * Handles the creation and setting of geofences. + * @param places + */ + public void createGeofences(List places, int transitionType, + ResultCallback callback) { + Timber.d("Creating geofences"); + populateGeofenceList(transitionType, places); + LocationServices.GeofencingApi.addGeofences( + mGoogleApiClient, + getGeofencingRequest(), + getGeofencePendingIntent() + ).setResultCallback(callback); + } + + /** + * Removes a geofence based on the request ID. + * @param geofenceId A hospital name. + */ + public void removeFence(String geofenceId, ResultCallback callback) { + Timber.d("Removing fence: " + geofenceId); + LocationServices.GeofencingApi.removeGeofences( + mGoogleApiClient, + Arrays.asList(geofenceId) + ).setResultCallback(callback); + } + + /** + * Removes all geofences. + */ + public void removeAllFences() { + Timber.d("Removing all fences"); + mGeofences.clear(); + LocationServices.GeofencingApi.removeGeofences( + mGoogleApiClient, + getGeofencePendingIntent() + ); + } + + /** + * Removes all geofences with a success/error callback. + * @param callback + */ + public void removeAllFences(ResultCallback callback) { + Timber.d("Removing all fences"); + mGeofences.clear(); + LocationServices.GeofencingApi.removeGeofences( + mGoogleApiClient, + getGeofencePendingIntent() + ).setResultCallback(callback); + } + + private GeofencingRequest getGeofencingRequest() { + GeofencingRequest.Builder builder = new GeofencingRequest.Builder(); + builder.setInitialTrigger(GeofencingRequest.INITIAL_TRIGGER_DWELL); + builder.addGeofences(mGeofences); + return builder.build(); + } + + private PendingIntent getGeofencePendingIntent() { + if (mGeofencePendingIntent != null) { + return mGeofencePendingIntent; + } + Intent intent = new Intent(context, GeofenceIntentService.class); + mGeofencePendingIntent = + PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); + return mGeofencePendingIntent; + } + + /** + * Creates an array of geofences + * @param transitionType The transition type for the trigger. + */ + private void populateGeofenceList(int transitionType, List places) { + for (PlaceSearchResult.Place place : places) { + Geofence.Builder builder = new Geofence.Builder() + .setRequestId(place.name) + .setCircularRegion( + place.getLocation().getLatitude(), + place.getLocation().getLongitude(), + Constants.GEOFENCE_RADIUS_METERS + ) + .setExpirationDuration(Geofence.NEVER_EXPIRE) + .setTransitionTypes(transitionType); + + // Set loiter time if it is a DWELL trigger + if (transitionType == Geofence.GEOFENCE_TRANSITION_DWELL) { + builder.setLoiteringDelay(Constants.GEOFENCE_LOITER_TIME_MILLIS); + } + + mGeofences.add(builder.build()); + } + Timber.d("Finished populating geofences. Number of fences add: " + mGeofences.size()); + } + + /** + * Determines if the new location is better than the previous location. This code is taken + * from Google docs. + * @param newLocation + * @return + */ + private boolean isBetterLocation(Location newLocation) { + if (mCurrentLocation == null) { + return true; + } + + long timeDelta = newLocation.getTime() - mCurrentLocation.getTime(); + boolean isMuchNewer = timeDelta > Constants.TWO_MINUTES_MILLIS; + boolean isMuchOlder = timeDelta < -Constants.TWO_MINUTES_MILLIS; + boolean isNewer = timeDelta > 0; + + if (isMuchNewer) { + return true; + } else if (isMuchOlder) { + return false; + } + + int accuracyDelta = (int) (newLocation.getAccuracy() - mCurrentLocation.getAccuracy()); + boolean isLessAccurate = accuracyDelta > 0; + boolean isMoreAccurate = accuracyDelta < 0; + boolean isMuchLessAccurate = accuracyDelta > 200; + + boolean isSameProvider; + if (newLocation.getProvider() == null) { + isSameProvider = mCurrentLocation.getProvider() == null; + } else { + isSameProvider = newLocation.getProvider().equals(mCurrentLocation.getProvider()); + } + + if (isMoreAccurate) { + return true; + } else if (isNewer && !isLessAccurate) { + return true; + } else if (isNewer && !isMuchLessAccurate && isSameProvider) { + return true; + } + + return false; + } + + @Override + public void onLocationChanged(Location location) { + Timber.d("onLocationChanged()"); + if (isBetterLocation(location)) { + mCurrentLocation = location; + mListener.onLocationChanged(mCurrentLocation); + } + } + + @Override + public void onConnected(Bundle bundle) { + mListener.onConnected(); + } + + @Override + public void disconnect() { + if (mGoogleApiClient.isConnected()) { + super.disconnect(); + } + } + +} diff --git a/app/src/main/java/org/healtheheartstudy/network/GsonRequest.java b/app/src/main/java/org/healtheheartstudy/network/GsonRequest.java new file mode 100644 index 0000000..b5d244f --- /dev/null +++ b/app/src/main/java/org/healtheheartstudy/network/GsonRequest.java @@ -0,0 +1,64 @@ +package org.healtheheartstudy.network; + +import com.android.volley.AuthFailureError; +import com.android.volley.NetworkResponse; +import com.android.volley.ParseError; +import com.android.volley.Request; +import com.android.volley.Response; +import com.android.volley.toolbox.HttpHeaderParser; +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; + +import java.io.UnsupportedEncodingException; +import java.util.Map; + +/** + * GsonRequest is used in conjunction with Volley to transform JSON responses into objects. + */ +public class GsonRequest extends Request { + private final Gson gson = new Gson(); + private final Class clazz; + private final Map headers; + private final Response.Listener listener; + + /** + * Make a GET request and return a parsed object from JSON. + * + * @param url URL of the request to make + * @param clazz Relevant class object, for Gson's reflection + * @param headers Map of request headers + */ + public GsonRequest(String url, Class clazz, Map headers, + Response.Listener listener, Response.ErrorListener errorListener) { + super(Method.GET, url, errorListener); + this.clazz = clazz; + this.headers = headers; + this.listener = listener; + } + + @Override + public Map getHeaders() throws AuthFailureError { + return headers != null ? headers : super.getHeaders(); + } + + @Override + protected void deliverResponse(T response) { + listener.onResponse(response); + } + + @Override + protected Response parseNetworkResponse(NetworkResponse response) { + try { + String json = new String( + response.data, + HttpHeaderParser.parseCharset(response.headers)); + return Response.success( + gson.fromJson(json, clazz), + HttpHeaderParser.parseCacheHeaders(response)); + } catch (UnsupportedEncodingException e) { + return Response.error(new ParseError(e)); + } catch (JsonSyntaxException e) { + return Response.error(new ParseError(e)); + } + } +} diff --git a/app/src/main/java/org/healtheheartstudy/network/RequestManager.java b/app/src/main/java/org/healtheheartstudy/network/RequestManager.java new file mode 100644 index 0000000..78ebdb4 --- /dev/null +++ b/app/src/main/java/org/healtheheartstudy/network/RequestManager.java @@ -0,0 +1,24 @@ +package org.healtheheartstudy.network; + +import android.content.Context; + +import com.android.volley.RequestQueue; +import com.android.volley.toolbox.Volley; + +/** + * RequestManager is a wrapper to simplify Volley's request queue. + */ +public class RequestManager { + + private static RequestQueue requestQueue; + + public static void init(Context context) { + requestQueue = Volley.newRequestQueue(context); + } + + public static RequestQueue getRequestQueue() { + if (requestQueue != null) return requestQueue; + else throw new IllegalStateException("Not initialized"); + } + +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index f7158b8..25c68a8 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -5,7 +5,23 @@ android:paddingTop="@dimen/activity_vertical_margin" android:paddingBottom="@dimen/activity_vertical_margin" tools:context=".MainActivity"> - +