diff --git a/build.gradle b/build.gradle index 909175b67..7b8c56a04 100644 --- a/build.gradle +++ b/build.gradle @@ -100,6 +100,8 @@ dependencies { exclude group: 'com.android.support', module: 'support-v4' } + googleplayCompile group: 'com.google.android.gms', name: 'play-services-location', version: '7.0.0' + compile(group: 'com.google.apis', name: 'google-api-services-tasks', version: 'v1-rev33-1.18.0-rc') { exclude group: 'org.apache.httpcomponents', module: 'httpclient' } diff --git a/src/debug/AndroidManifest.xml b/src/debug/AndroidManifest.xml new file mode 100644 index 000000000..09dcf6b14 --- /dev/null +++ b/src/debug/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/src/generic/java/org/tasks/dialogs/LocationPickerDialog.java b/src/generic/java/org/tasks/dialogs/LocationPickerDialog.java new file mode 100644 index 000000000..fa3d83197 --- /dev/null +++ b/src/generic/java/org/tasks/dialogs/LocationPickerDialog.java @@ -0,0 +1,12 @@ +package org.tasks.dialogs; + +import android.support.v4.app.Fragment; + +import org.tasks.location.LocationApi; +import org.tasks.location.OnLocationPickedHandler; + +public class LocationPickerDialog { + public static void pickLocation(LocationApi locationApi, Fragment fragment, OnLocationPickedHandler onLocationPickedHandler) { + + } +} diff --git a/src/generic/java/org/tasks/location/GeofenceApi.java b/src/generic/java/org/tasks/location/GeofenceApi.java new file mode 100644 index 000000000..7806f3101 --- /dev/null +++ b/src/generic/java/org/tasks/location/GeofenceApi.java @@ -0,0 +1,21 @@ +package org.tasks.location; + +import java.util.List; + +import javax.inject.Inject; + +public class GeofenceApi { + + @Inject + public GeofenceApi() { + + } + + public void register(List activeGeofences) { + + } + + public void cancel(Geofence geofence) { + + } +} diff --git a/src/generic/java/org/tasks/location/GeofenceTransitionsIntentService.java b/src/generic/java/org/tasks/location/GeofenceTransitionsIntentService.java new file mode 100644 index 000000000..508a57af7 --- /dev/null +++ b/src/generic/java/org/tasks/location/GeofenceTransitionsIntentService.java @@ -0,0 +1,5 @@ +package org.tasks.location; + +public class GeofenceTransitionsIntentService { + +} diff --git a/src/generic/java/org/tasks/location/LocationApi.java b/src/generic/java/org/tasks/location/LocationApi.java new file mode 100644 index 000000000..50bd7783c --- /dev/null +++ b/src/generic/java/org/tasks/location/LocationApi.java @@ -0,0 +1,11 @@ +package org.tasks.location; + +import javax.inject.Inject; + +public class LocationApi { + + @Inject + public LocationApi() { + + } +} diff --git a/src/googleplay/AndroidManifest.xml b/src/googleplay/AndroidManifest.xml index bd4bbe0cb..7669ebd1c 100644 --- a/src/googleplay/AndroidManifest.xml +++ b/src/googleplay/AndroidManifest.xml @@ -1,5 +1,6 @@ @@ -16,12 +17,30 @@ + + + + + + + + + + + + + + + diff --git a/src/googleplay/java/org/tasks/dialogs/LocationPickerDialog.java b/src/googleplay/java/org/tasks/dialogs/LocationPickerDialog.java new file mode 100644 index 000000000..653455563 --- /dev/null +++ b/src/googleplay/java/org/tasks/dialogs/LocationPickerDialog.java @@ -0,0 +1,112 @@ +package org.tasks.dialogs; + +import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.v4.app.DialogFragment; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentActivity; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.EditorInfo; +import android.widget.AdapterView; +import android.widget.EditText; +import android.widget.ListView; +import android.widget.TextView; +import android.widget.Toast; + +import com.google.android.gms.common.api.ResultCallback; +import com.google.android.gms.location.places.Place; +import com.google.android.gms.location.places.PlaceBuffer; +import com.google.android.gms.maps.model.LatLng; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.tasks.R; +import org.tasks.location.Geofence; +import org.tasks.location.LocationApi; +import org.tasks.location.OnLocationPickedHandler; +import org.tasks.location.PlaceAutocompleteAdapter; + +public class LocationPickerDialog extends DialogFragment { + + private static final Logger log = LoggerFactory.getLogger(LocationPickerDialog.class); + + private static final String FRAG_TAG_LOCATION_PICKER = "frag_tag_location_picker"; + private LocationApi locationApi; + private FragmentActivity fragmentActivity; + private OnLocationPickedHandler onLocationPickedHandler; + private PlaceAutocompleteAdapter mAdapter; + + public static void pickLocation(LocationApi locationApi, Fragment fragment, OnLocationPickedHandler onLocationPickedHandler) { + LocationPickerDialog locationPickerDialog = new LocationPickerDialog(); + locationPickerDialog.initialize(locationApi, fragment.getActivity(), onLocationPickedHandler); + locationPickerDialog.show(fragment.getChildFragmentManager(), FRAG_TAG_LOCATION_PICKER); + } + + @Override + public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View layout = inflater.inflate(R.layout.location_picker_dialog, null); + EditText addressEntry = (EditText) layout.findViewById(R.id.address_entry); + + addressEntry.setOnEditorActionListener(new TextView.OnEditorActionListener() { + @Override + public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { + if (actionId == EditorInfo.IME_ACTION_SEARCH) { + CharSequence search = v.getText(); + mAdapter.getAutocomplete(search); + return true; + } + return false; + } + }); + + mAdapter = new PlaceAutocompleteAdapter(locationApi, fragmentActivity, android.R.layout.simple_list_item_1); + ListView list = (ListView) layout.findViewById(R.id.list); + list.setAdapter(mAdapter); + list.setOnItemClickListener(mAutocompleteClickListener); + + return layout; + } + + public void initialize(LocationApi locationApi, FragmentActivity fragmentActivity, OnLocationPickedHandler onLocationPickedHandler) { + this.locationApi = locationApi; + this.fragmentActivity = fragmentActivity; + this.onLocationPickedHandler = onLocationPickedHandler; + } + + private void error(String text) { + log.error(text); + Toast.makeText(fragmentActivity, text, Toast.LENGTH_LONG).show(); + } + + private AdapterView.OnItemClickListener mAutocompleteClickListener + = new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + final PlaceAutocompleteAdapter.PlaceAutocomplete item = mAdapter.getItem(position); + final String placeId = String.valueOf(item.placeId); + log.info("Autocomplete item selected: " + item.description); + locationApi.getPlaceDetails(placeId, mUpdatePlaceDetailsCallback); + } + }; + + private ResultCallback mUpdatePlaceDetailsCallback + = new ResultCallback() { + @Override + public void onResult(PlaceBuffer places) { + if (places.getStatus().isSuccess()) { + final Place place = places.get(0); + LatLng latLng = place.getLatLng(); + Geofence geofence = new Geofence(place.getName().toString(), latLng.latitude, latLng.longitude, 50); + log.info("Picked {}", geofence); + onLocationPickedHandler.onLocationPicked(geofence); + dismiss(); + } else { + error("Error looking up location details - " + places.getStatus().toString()); + } + places.release(); + } + }; +} diff --git a/src/googleplay/java/org/tasks/location/GeofenceApi.java b/src/googleplay/java/org/tasks/location/GeofenceApi.java new file mode 100644 index 000000000..c196a5c95 --- /dev/null +++ b/src/googleplay/java/org/tasks/location/GeofenceApi.java @@ -0,0 +1,118 @@ +package org.tasks.location; + +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; + +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.api.GoogleApiClient; +import com.google.android.gms.common.api.PendingResult; +import com.google.android.gms.common.api.ResultCallback; +import com.google.android.gms.common.api.Status; +import com.google.android.gms.location.GeofencingRequest; +import com.google.android.gms.location.LocationServices; +import com.google.common.base.Function; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.tasks.injection.ForApplication; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +import javax.inject.Inject; + +import static com.google.android.gms.location.Geofence.NEVER_EXPIRE; +import static com.google.common.collect.Iterables.transform; +import static com.google.common.collect.Lists.newArrayList; +import static java.util.Arrays.asList; + +public class GeofenceApi { + + private static final Logger log = LoggerFactory.getLogger(GeofenceApi.class); + + private Context context; + private GoogleApiClientProvider googleApiClientProvider; + + @Inject + public GeofenceApi(@ForApplication Context context, GoogleApiClientProvider googleApiClientProvider) { + this.context = context; + this.googleApiClientProvider = googleApiClientProvider; + } + + public void register(final List geofences) { + if (geofences.isEmpty()) { + return; + } + + googleApiClientProvider.getApi(new GoogleApiClientProvider.withApi() { + @Override + public void doWork(GoogleApiClient googleApiClient) { + PendingResult result = LocationServices.GeofencingApi.addGeofences( + googleApiClient, + getRequests(geofences), + PendingIntent.getService(context, 0, new Intent(context, GeofenceTransitionsIntentService.class), PendingIntent.FLAG_UPDATE_CURRENT)); + result.setResultCallback(new ResultCallback() { + @Override + public void onResult(Status status) { + if (status.isSuccess()) { + log.info("Registered {}", geofences); + } else { + log.error("Failed to register {}", geofences); + } + } + }); + } + + @Override + public void onConnectionFailed(ConnectionResult connectionResult) { + log.info("failed to register geofences"); + } + }); + } + + public void cancel(final Geofence geofence) { + googleApiClientProvider.getApi(new GoogleApiClientProvider.withApi() { + @Override + public void doWork(GoogleApiClient googleApiClient) { + LocationServices.GeofencingApi.removeGeofences( + googleApiClient, + asList(Long.toString(geofence.getMetadataId()))) + .setResultCallback(new ResultCallback() { + @Override + public void onResult(Status status) { + if (status.isSuccess()) { + log.info("Removed {}", geofence); + } else { + log.error("Failed to remove {}", geofence); + } + } + }); + } + + @Override + public void onConnectionFailed(ConnectionResult connectionResult) { + log.info("failed to cancel geofence"); + } + }); + } + + private List getRequests(List geofences) { + return newArrayList(transform(geofences, new Function() { + @Override + public com.google.android.gms.location.Geofence apply(Geofence geofence) { + return toGoogleGeofence(geofence); + } + })); + } + + private com.google.android.gms.location.Geofence toGoogleGeofence(Geofence geofence) { + return new com.google.android.gms.location.Geofence.Builder() + .setCircularRegion(geofence.getLatitude(), geofence.getLongitude(), geofence.getRadius()) + .setNotificationResponsiveness((int) TimeUnit.SECONDS.toMillis(30)) + .setRequestId(Long.toString(geofence.getMetadataId())) + .setTransitionTypes(GeofencingRequest.INITIAL_TRIGGER_ENTER) + .setExpirationDuration(NEVER_EXPIRE) + .build(); + } +} diff --git a/src/googleplay/java/org/tasks/location/GeofenceTransitionsIntentService.java b/src/googleplay/java/org/tasks/location/GeofenceTransitionsIntentService.java new file mode 100644 index 000000000..2afbab791 --- /dev/null +++ b/src/googleplay/java/org/tasks/location/GeofenceTransitionsIntentService.java @@ -0,0 +1,49 @@ +package org.tasks.location; + +import android.content.Intent; + +import com.google.android.gms.location.GeofencingEvent; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.tasks.Broadcaster; +import org.tasks.injection.InjectingIntentService; + +import java.util.List; + +import javax.inject.Inject; + +public class GeofenceTransitionsIntentService extends InjectingIntentService { + + private static final Logger log = LoggerFactory.getLogger(GeofenceTransitionsIntentService.class); + + @Inject GeofenceService geofenceService; + @Inject Broadcaster broadcaster; + + public GeofenceTransitionsIntentService() { + super(GeofenceTransitionsIntentService.class.getSimpleName()); + } + + protected void onHandleIntent(Intent intent) { + super.onHandleIntent(intent); + + GeofencingEvent geofencingEvent = GeofencingEvent.fromIntent(intent); + if (geofencingEvent.hasError()) { + log.error("geofence error code {}", geofencingEvent.getErrorCode()); + return; + } + + int transitionType = geofencingEvent.getGeofenceTransition(); + + if (transitionType == com.google.android.gms.location.Geofence.GEOFENCE_TRANSITION_ENTER) { + List triggeringGeofences = geofencingEvent.getTriggeringGeofences(); + log.info("Received geofence transition: {}, {}", transitionType, triggeringGeofences); + for (com.google.android.gms.location.Geofence triggerGeofence : triggeringGeofences) { + Geofence geofence = geofenceService.getGeofenceById(Long.parseLong(triggerGeofence.getRequestId())); + broadcaster.requestNotification(geofence.getMetadataId(), geofence.getTaskId()); + } + } else { + log.warn("invalid geofence transition type: {}", transitionType); + } + } +} \ No newline at end of file diff --git a/src/googleplay/java/org/tasks/location/GoogleApiClientProvider.java b/src/googleplay/java/org/tasks/location/GoogleApiClientProvider.java new file mode 100644 index 000000000..1fd628956 --- /dev/null +++ b/src/googleplay/java/org/tasks/location/GoogleApiClientProvider.java @@ -0,0 +1,51 @@ +package org.tasks.location; + +import android.content.Context; +import android.os.Bundle; + +import com.google.android.gms.common.api.GoogleApiClient; +import com.google.android.gms.location.LocationServices; +import com.google.android.gms.location.places.Places; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.tasks.injection.ForApplication; + +import javax.inject.Inject; + +public class GoogleApiClientProvider { + + private static final Logger log = LoggerFactory.getLogger(GoogleApiClientProvider.class); + + private Context context; + + public interface withApi extends GoogleApiClient.OnConnectionFailedListener { + void doWork(GoogleApiClient googleApiClient); + } + + @Inject + public GoogleApiClientProvider(@ForApplication Context context) { + this.context = context; + } + + public void getApi(final withApi callback) { + final GoogleApiClient googleApiClient = new GoogleApiClient.Builder(context) + .addOnConnectionFailedListener(callback) + .addApi(Places.GEO_DATA_API) + .addApi(LocationServices.API) + .build(); + googleApiClient.registerConnectionCallbacks(new GoogleApiClient.ConnectionCallbacks() { + @Override + public void onConnected(Bundle bundle) { + log.info("onConnected({})", bundle); + callback.doWork(googleApiClient); + } + + @Override + public void onConnectionSuspended(int i) { + log.info("onConnectionSuspended({})", i); + } + }); + googleApiClient.connect(); + } +} diff --git a/src/googleplay/java/org/tasks/location/LocationApi.java b/src/googleplay/java/org/tasks/location/LocationApi.java new file mode 100644 index 000000000..ba808ea7f --- /dev/null +++ b/src/googleplay/java/org/tasks/location/LocationApi.java @@ -0,0 +1,84 @@ +package org.tasks.location; + +import android.location.Location; + +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.api.GoogleApiClient; +import com.google.android.gms.common.api.ResultCallback; +import com.google.android.gms.location.LocationServices; +import com.google.android.gms.location.places.AutocompletePredictionBuffer; +import com.google.android.gms.location.places.PlaceBuffer; +import com.google.android.gms.location.places.Places; +import com.google.android.gms.maps.model.LatLng; +import com.google.android.gms.maps.model.LatLngBounds; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.TimeUnit; + +import javax.inject.Inject; + +public class LocationApi { + + private static final Logger log = LoggerFactory.getLogger(LocationApi.class); + + private GoogleApiClientProvider googleApiClientProvider; + + @Inject + public LocationApi(GoogleApiClientProvider googleApiClientProvider) { + this.googleApiClientProvider = googleApiClientProvider; + } + + public void getPlaceDetails(final String placeId, final ResultCallback callback) { + googleApiClientProvider.getApi(new GoogleApiClientProvider.withApi() { + @Override + public void doWork(final GoogleApiClient googleApiClient) { + Places.GeoDataApi.getPlaceById(googleApiClient, placeId) + .setResultCallback(new ResultCallback() { + @Override + public void onResult(PlaceBuffer places) { + callback.onResult(places); + googleApiClient.disconnect(); + } + }, 15, TimeUnit.SECONDS); + } + + @Override + public void onConnectionFailed(ConnectionResult connectionResult) { + log.error("onConnectionFailed({})", connectionResult); + } + }); + } + + public void getAutocompletePredictions(final String constraint, final ResultCallback callback) { + googleApiClientProvider.getApi(new GoogleApiClientProvider.withApi() { + @Override + public void doWork(final GoogleApiClient googleApiClient) { + final LatLngBounds bounds = LatLngBounds.builder().include(getLastKnownLocation(googleApiClient)).build(); + Places.GeoDataApi.getAutocompletePredictions(googleApiClient, constraint, bounds, null) + .setResultCallback(new ResultCallback() { + @Override + public void onResult(AutocompletePredictionBuffer autocompletePredictions) { + callback.onResult(autocompletePredictions); + googleApiClient.disconnect(); + } + }, 15, TimeUnit.SECONDS); + } + + @Override + public void onConnectionFailed(ConnectionResult connectionResult) { + log.error("onConnectionFailed({})", connectionResult); + } + }); + } + + private LatLng getLastKnownLocation(GoogleApiClient googleApiClient) { + try { + Location lastLocation = LocationServices.FusedLocationApi.getLastLocation(googleApiClient); + return new LatLng(lastLocation.getLatitude(), lastLocation.getLongitude()); + } catch (Exception e) { + return new LatLng(0, 0); + } + } +} diff --git a/src/googleplay/java/org/tasks/location/PlaceAutocompleteAdapter.java b/src/googleplay/java/org/tasks/location/PlaceAutocompleteAdapter.java new file mode 100644 index 000000000..5e72d3ea8 --- /dev/null +++ b/src/googleplay/java/org/tasks/location/PlaceAutocompleteAdapter.java @@ -0,0 +1,98 @@ +package org.tasks.location; + +import android.content.Context; +import android.widget.ArrayAdapter; +import android.widget.Toast; + +import com.google.android.gms.common.api.ResultCallback; +import com.google.android.gms.common.api.Status; +import com.google.android.gms.location.places.AutocompletePrediction; +import com.google.android.gms.location.places.AutocompletePredictionBuffer; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +public class PlaceAutocompleteAdapter + extends ArrayAdapter { + + private static final Logger log = LoggerFactory.getLogger(PlaceAutocompleteAdapter.class); + + private List mResultList = new ArrayList<>(); + private LocationApi locationApi; + + public PlaceAutocompleteAdapter(LocationApi locationApi, Context context, int resource) { + super(context, resource); + this.locationApi = locationApi; + } + + @Override + public int getCount() { + return mResultList == null ? 0 : mResultList.size(); + } + + @Override + public PlaceAutocomplete getItem(int position) { + return mResultList.get(position); + } + + public void getAutocomplete(CharSequence constraint) { + locationApi.getAutocompletePredictions(constraint.toString(), onResults); + } + + private ResultCallback onResults = new ResultCallback() { + @Override + public void onResult(AutocompletePredictionBuffer autocompletePredictions) { + final Status status = autocompletePredictions.getStatus(); + if (!status.isSuccess()) { + Toast.makeText(getContext(), "Error contacting API: " + status.toString(), + Toast.LENGTH_SHORT).show(); + log.error("Error getting autocomplete prediction API call: " + status.toString()); + autocompletePredictions.release(); + return; + } + + log.info("Query completed. Received " + autocompletePredictions.getCount() + + " predictions."); + + Iterator iterator = autocompletePredictions.iterator(); + List resultList = new ArrayList<>(autocompletePredictions.getCount()); + while (iterator.hasNext()) { + AutocompletePrediction prediction = iterator.next(); + resultList.add(new PlaceAutocomplete(prediction.getPlaceId(), + prediction.getDescription())); + } + + setResults(resultList); + autocompletePredictions.release(); + } + }; + + private void setResults(List results) { + mResultList = results; + if (mResultList != null && mResultList.size() > 0) { + notifyDataSetChanged(); + } else { + notifyDataSetInvalidated(); + } + } + + public class PlaceAutocomplete { + + public CharSequence placeId; + public CharSequence description; + + PlaceAutocomplete(CharSequence placeId, CharSequence description) { + this.placeId = placeId; + this.description = description; + } + + @Override + public String toString() { + return description.toString(); + } + } +} diff --git a/src/googleplay/res/layout/location_picker_dialog.xml b/src/googleplay/res/layout/location_picker_dialog.xml new file mode 100644 index 000000000..70741601a --- /dev/null +++ b/src/googleplay/res/layout/location_picker_dialog.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/googleplay/res/values-sw600dp/template-dimens.xml b/src/googleplay/res/values-sw600dp/template-dimens.xml new file mode 100644 index 000000000..774ba3ee1 --- /dev/null +++ b/src/googleplay/res/values-sw600dp/template-dimens.xml @@ -0,0 +1,6 @@ + + + @dimen/margin_huge + @dimen/margin_medium + + diff --git a/src/googleplay/res/values/bools.xml b/src/googleplay/res/values/bools.xml index 9f359460c..b97aa432c 100644 --- a/src/googleplay/res/values/bools.xml +++ b/src/googleplay/res/values/bools.xml @@ -1,4 +1,5 @@ true + true \ No newline at end of file diff --git a/src/googleplay/res/values/template-dimens.xml b/src/googleplay/res/values/template-dimens.xml new file mode 100644 index 000000000..e8b853af7 --- /dev/null +++ b/src/googleplay/res/values/template-dimens.xml @@ -0,0 +1,9 @@ + + + 16dp + 64dp + + @dimen/margin_medium + @dimen/margin_medium + + diff --git a/src/main/java/com/todoroo/astrid/activity/TaskEditFragment.java b/src/main/java/com/todoroo/astrid/activity/TaskEditFragment.java index e6195739a..6f838d42e 100755 --- a/src/main/java/com/todoroo/astrid/activity/TaskEditFragment.java +++ b/src/main/java/com/todoroo/astrid/activity/TaskEditFragment.java @@ -82,6 +82,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.tasks.R; import org.tasks.injection.InjectingFragment; +import org.tasks.location.GeofenceService; +import org.tasks.location.LocationApi; import org.tasks.notifications.NotificationManager; import org.tasks.preferences.ActivityPreferences; @@ -173,6 +175,8 @@ ViewPager.OnPageChangeListener, EditNoteActivity.UpdatesChangedListener { @Inject DateChangedAlerts dateChangedAlerts; @Inject TagDataDao tagDataDao; @Inject ActFmCameraModule actFmCameraModule; + @Inject GeofenceService geofenceService; + @Inject LocationApi locationApi; // --- UI components @@ -389,10 +393,9 @@ ViewPager.OnPageChangeListener, EditNoteActivity.UpdatesChangedListener { controlSetMap.put(getString(R.string.TEA_ctrl_notes_pref), notesControlSet); - ReminderControlSet reminderControl = new ReminderControlSet(alarmService, this); - controls.add(reminderControl); - controlSetMap.put(getString(R.string.TEA_ctrl_reminders_pref), - reminderControl); + ReminderControlSet reminderControlSet = new ReminderControlSet(locationApi, alarmService, geofenceService, this); + controls.add(reminderControlSet); + controlSetMap.put(getString(R.string.TEA_ctrl_reminders_pref), reminderControlSet); HideUntilControlSet hideUntilControls = new HideUntilControlSet(this); controls.add(hideUntilControls); diff --git a/src/main/java/com/todoroo/astrid/ui/ReminderControlSet.java b/src/main/java/com/todoroo/astrid/ui/ReminderControlSet.java index b6838197b..377fc947f 100644 --- a/src/main/java/com/todoroo/astrid/ui/ReminderControlSet.java +++ b/src/main/java/com/todoroo/astrid/ui/ReminderControlSet.java @@ -29,11 +29,17 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.tasks.R; import org.tasks.dialogs.DateAndTimePickerDialog; +import org.tasks.dialogs.LocationPickerDialog; +import org.tasks.location.Geofence; +import org.tasks.location.GeofenceService; +import org.tasks.location.LocationApi; +import org.tasks.location.OnLocationPickedHandler; import java.util.ArrayList; import java.util.Date; import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; import static org.tasks.date.DateTimeUtils.newDateTime; @@ -55,15 +61,19 @@ public class ReminderControlSet extends TaskEditControlSetBase implements Adapte private LinearLayout alertContainer; private boolean whenDue; private boolean whenOverdue; + private LocationApi locationApi; private AlarmService alarmService; + private GeofenceService geofenceService; private TaskEditFragment taskEditFragment; private List spinnerOptions = new ArrayList<>(); private ArrayAdapter remindAdapter; - public ReminderControlSet(AlarmService alarmService, TaskEditFragment taskEditFragment) { + public ReminderControlSet(LocationApi locationApi, AlarmService alarmService, GeofenceService geofenceService, TaskEditFragment taskEditFragment) { super(taskEditFragment.getActivity(), R.layout.control_set_reminders); + this.locationApi = locationApi; this.alarmService = alarmService; + this.geofenceService = geofenceService; this.taskEditFragment = taskEditFragment; } @@ -117,6 +127,15 @@ public class ReminderControlSet extends TaskEditControlSetBase implements Adapte }); } + public void addGeolocationReminder(final Geofence geofence) { + View alertItem = addAlarmRow(geofence.getName(), null, new OnClickListener() { + @Override + public void onClick(View v) { + } + }); + alertItem.setTag(geofence); + } + private View addAlarmRow(String text, Long timestamp, final OnClickListener onRemove) { final View alertItem = LayoutInflater.from(activity).inflate(R.layout.alarm_edit_row, null); alertContainer.addView(alertItem); @@ -155,6 +174,9 @@ public class ReminderControlSet extends TaskEditControlSetBase implements Adapte if (randomControlSet == null) { spinnerOptions.add(taskEditFragment.getString(R.string.randomly)); } + if (taskEditFragment.getResources().getBoolean(R.bool.location_enabled)) { + spinnerOptions.add(taskEditFragment.getString(R.string.pick_a_location)); + } spinnerOptions.add(taskEditFragment.getString(R.string.pick_a_date_and_time)); remindAdapter.notifyDataSetChanged(); } @@ -257,6 +279,9 @@ public class ReminderControlSet extends TaskEditControlSetBase implements Adapte addAlarmRow(entry.getValue(AlarmFields.TIME)); } }); + for (Geofence geofence : geofenceService.getGeofences(model.getId())) { + addGeolocationReminder(geofence); + } updateSpinner(); } @@ -310,18 +335,28 @@ public class ReminderControlSet extends TaskEditControlSetBase implements Adapte task.setReminderPeriod(randomControlSet == null ? 0L : randomControlSet.getReminderPeriod()); - LinkedHashSet alarms = new LinkedHashSet<>(); + Set alarms = new LinkedHashSet<>(); + Set geofences = new LinkedHashSet<>(); + for(int i = 0; i < alertContainer.getChildCount(); i++) { - Long dateValue = (Long) alertContainer.getChildAt(i).getTag(); - if(dateValue == null) { - continue; + Object tag = alertContainer.getChildAt(i).getTag(); + //noinspection StatementWithEmptyBody + if (tag == null) { + } else if (tag instanceof Long) { + alarms.add((Long) tag); + } else if (tag instanceof Geofence) { + geofences.add((Geofence) tag); + } else { + log.error("Unexpected tag: {}", tag); } - alarms.add(dateValue); } if(alarmService.synchronizeAlarms(task.getId(), alarms)) { task.setModificationDate(DateUtilities.now()); } + if (geofenceService.synchronizeGeofences(task.getId(), geofences)) { + task.setModificationDate(DateUtilities.now()); + } } private String getDisplayString(long forDate) { @@ -345,6 +380,13 @@ public class ReminderControlSet extends TaskEditControlSetBase implements Adapte addRandomReminder(); } else if (selected.equals(taskEditFragment.getString(R.string.pick_a_date_and_time))) { addNewAlarm(); + } else if (selected.equals(taskEditFragment.getString(R.string.pick_a_location))) { + LocationPickerDialog.pickLocation(locationApi, taskEditFragment, new OnLocationPickedHandler() { + @Override + public void onLocationPicked(Geofence geofence) { + addGeolocationReminder(geofence); + } + }); } if (position != 0) { updateSpinner(); diff --git a/src/main/java/org/tasks/Broadcaster.java b/src/main/java/org/tasks/Broadcaster.java index a3b4fc528..39deecdf0 100644 --- a/src/main/java/org/tasks/Broadcaster.java +++ b/src/main/java/org/tasks/Broadcaster.java @@ -6,6 +6,7 @@ import android.content.Intent; import com.todoroo.astrid.api.AstridApiConstants; import com.todoroo.astrid.reminders.NotificationFragment; import com.todoroo.astrid.reminders.Notifications; +import com.todoroo.astrid.reminders.ReminderService; import com.todoroo.astrid.utility.Constants; import org.tasks.injection.ForApplication; @@ -55,6 +56,14 @@ public class Broadcaster { }}, AstridApiConstants.PERMISSION_READ); } + public void requestNotification(final long alarmId, final long taskId) { + sendOrderedBroadcast(new Intent(context, Notifications.class) {{ + setAction("ALARM" + alarmId); //$NON-NLS-1$ + putExtra(Notifications.ID_KEY, taskId); + putExtra(Notifications.EXTRAS_TYPE, ReminderService.TYPE_ALARM); + }}); + } + public void taskCompleted(final long id) { sendOrderedBroadcast(new Intent(AstridApiConstants.BROADCAST_EVENT_TASK_COMPLETED) {{ putExtra(AstridApiConstants.EXTRAS_TASK_ID, id); diff --git a/src/main/java/org/tasks/injection/FragmentModule.java b/src/main/java/org/tasks/injection/FragmentModule.java index 972a01e5a..c470973bd 100644 --- a/src/main/java/org/tasks/injection/FragmentModule.java +++ b/src/main/java/org/tasks/injection/FragmentModule.java @@ -3,6 +3,7 @@ package org.tasks.injection; import android.app.Activity; import android.content.Context; import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentActivity; import com.todoroo.astrid.actfm.TagViewFragment; import com.todoroo.astrid.activity.TaskEditFragment; diff --git a/src/main/java/org/tasks/injection/IntentServiceModule.java b/src/main/java/org/tasks/injection/IntentServiceModule.java index baa4f91be..8c4ca06ec 100644 --- a/src/main/java/org/tasks/injection/IntentServiceModule.java +++ b/src/main/java/org/tasks/injection/IntentServiceModule.java @@ -1,5 +1,6 @@ package org.tasks.injection; +import org.tasks.location.GeofenceTransitionsIntentService; import org.tasks.scheduling.*; import dagger.Module; @@ -11,7 +12,8 @@ import dagger.Module; GtasksBackgroundService.class, MidnightRefreshService.class, RefreshSchedulerIntentService.class, - ReminderSchedulerIntentService.class + ReminderSchedulerIntentService.class, + GeofenceTransitionsIntentService.class }) public class IntentServiceModule { } diff --git a/src/main/java/org/tasks/location/Geofence.java b/src/main/java/org/tasks/location/Geofence.java new file mode 100644 index 000000000..889a05db8 --- /dev/null +++ b/src/main/java/org/tasks/location/Geofence.java @@ -0,0 +1,64 @@ +package org.tasks.location; + +import com.todoroo.astrid.data.Metadata; + +public class Geofence { + private final String name; + private final double latitude; + private final double longitude; + private final int radius; + private long taskId; + private long metadataId; + + public Geofence(Metadata metadata) { + this(metadata.getValue(GeofenceFields.PLACE), + metadata.getValue(GeofenceFields.LATITUDE), + metadata.getValue(GeofenceFields.LONGITUDE), + metadata.getValue(GeofenceFields.RADIUS)); + metadataId = metadata.getId(); + taskId = metadata.getTask(); + } + + public Geofence(String name, double latitude, double longitude, int radius) { + this.name = name; + this.latitude = latitude; + this.longitude = longitude; + this.radius = radius; + } + + public String getName() { + return name; + } + + public double getLatitude() { + return latitude; + } + + public double getLongitude() { + return longitude; + } + + public int getRadius() { + return radius; + } + + public long getMetadataId() { + return metadataId; + } + + public long getTaskId() { + return taskId; + } + + @Override + public String toString() { + return "Geofence{" + + "name='" + name + '\'' + + ", latitude=" + latitude + + ", longitude=" + longitude + + ", radius=" + radius + + ", taskId=" + taskId + + ", metadataId=" + metadataId + + '}'; + } +} \ No newline at end of file diff --git a/src/main/java/org/tasks/location/GeofenceFields.java b/src/main/java/org/tasks/location/GeofenceFields.java new file mode 100644 index 000000000..19e147f54 --- /dev/null +++ b/src/main/java/org/tasks/location/GeofenceFields.java @@ -0,0 +1,24 @@ +package org.tasks.location; + +import com.todoroo.andlib.data.Property.IntegerProperty; +import com.todoroo.astrid.data.Metadata; + +import static com.todoroo.andlib.data.Property.DoubleProperty; +import static com.todoroo.andlib.data.Property.StringProperty; + +public class GeofenceFields { + + public static final String METADATA_KEY = "geofence"; + + public static final StringProperty PLACE = new StringProperty(Metadata.TABLE, + Metadata.VALUE1.name); + + public static final DoubleProperty LATITUDE = new DoubleProperty(Metadata.TABLE, + Metadata.VALUE2.name); + + public static final DoubleProperty LONGITUDE = new DoubleProperty(Metadata.TABLE, + Metadata.VALUE3.name); + + public static final IntegerProperty RADIUS = new IntegerProperty(Metadata.TABLE, + Metadata.VALUE4.name); +} diff --git a/src/main/java/org/tasks/location/GeofenceService.java b/src/main/java/org/tasks/location/GeofenceService.java new file mode 100644 index 000000000..5326a472f --- /dev/null +++ b/src/main/java/org/tasks/location/GeofenceService.java @@ -0,0 +1,162 @@ +package org.tasks.location; + +import android.content.ContentValues; + +import com.google.common.base.Function; +import com.todoroo.andlib.data.Callback; +import com.todoroo.andlib.sql.Criterion; +import com.todoroo.andlib.sql.Join; +import com.todoroo.andlib.sql.Order; +import com.todoroo.andlib.sql.Query; +import com.todoroo.andlib.utility.DateUtilities; +import com.todoroo.astrid.dao.MetadataDao; +import com.todoroo.astrid.dao.MetadataDao.MetadataCriteria; +import com.todoroo.astrid.dao.TaskDao.TaskCriteria; +import com.todoroo.astrid.data.Metadata; +import com.todoroo.astrid.data.Task; +import com.todoroo.astrid.service.SynchronizeMetadataCallback; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import static com.google.common.collect.Iterables.transform; +import static com.google.common.collect.Lists.newArrayList; + +@Singleton +public class GeofenceService { + + private final MetadataDao metadataDao; + private final GeofenceApi geofenceApi; + + @Inject + public GeofenceService(MetadataDao metadataDao, GeofenceApi geofenceApi) { + this.metadataDao = metadataDao; + this.geofenceApi = geofenceApi; + } + + public Geofence getGeofenceById(long metadataId) { + return new Geofence(metadataDao.fetch(metadataId, Metadata.TASK, GeofenceFields.PLACE, GeofenceFields.LATITUDE, GeofenceFields.LONGITUDE, GeofenceFields.RADIUS)); + } + + public List getGeofences(long taskId) { + return toGeofences(metadataDao.toList(Query.select( + Metadata.PROPERTIES).where(MetadataCriteria.byTaskAndwithKey( + taskId, GeofenceFields.METADATA_KEY)).orderBy(Order.asc(GeofenceFields.PLACE)))); + } + + public void setupGeofences() { + geofenceApi.register(getActiveGeofences()); + } + + public boolean synchronizeGeofences(final long taskId, Set geofences) { + List metadata = newArrayList(transform(geofences, new Function() { + @Override + public Metadata apply(final Geofence geofence) { + return new Metadata() {{ + setKey(GeofenceFields.METADATA_KEY); + setValue(GeofenceFields.PLACE, geofence.getName()); + setValue(GeofenceFields.LATITUDE, geofence.getLatitude()); + setValue(GeofenceFields.LONGITUDE, geofence.getLongitude()); + setValue(GeofenceFields.RADIUS, geofence.getRadius()); + }}; + } + })); + + boolean changed = synchronizeMetadata(taskId, metadata, new SynchronizeMetadataCallback() { + @Override + public void beforeDeleteMetadata(Metadata m) { + geofenceApi.cancel(new Geofence(m)); + } + }); + + if(changed) { + setupGeofences(taskId); + } + return changed; + } + + private List toGeofences(List geofences) { + return newArrayList(transform(geofences, new Function() { + @Override + public Geofence apply(Metadata metadata) { + return new Geofence(metadata); + } + })); + } + + private void setupGeofences(long taskId) { + geofenceApi.register(getGeofencesForTask(taskId)); + } + + private List getActiveGeofences() { + return toGeofences(metadataDao.toList(Query.select(Metadata.ID, Metadata.TASK, GeofenceFields.PLACE, GeofenceFields.LATITUDE, GeofenceFields.LONGITUDE, GeofenceFields.RADIUS). + join(Join.inner(Task.TABLE, Metadata.TASK.eq(Task.ID))). + where(Criterion.and(TaskCriteria.isActive(), MetadataCriteria.withKey(GeofenceFields.METADATA_KEY))))); + } + + private List getGeofencesForTask(long taskId) { + return toGeofences(metadataDao.toList(Query.select(Metadata.ID, Metadata.TASK, GeofenceFields.PLACE, GeofenceFields.LATITUDE, GeofenceFields.LONGITUDE, GeofenceFields.RADIUS). + join(Join.inner(Task.TABLE, Metadata.TASK.eq(Task.ID))). + where(Criterion.and(TaskCriteria.isActive(), + MetadataCriteria.byTaskAndwithKey(taskId, GeofenceFields.METADATA_KEY))))); + } + + private boolean synchronizeMetadata(long taskId, List metadata, final SynchronizeMetadataCallback callback) { + final boolean[] dirty = new boolean[1]; + final Set newMetadataValues = new HashSet<>(); + for(Metadata metadatum : metadata) { + metadatum.setTask(taskId); + metadatum.clearValue(Metadata.CREATION_DATE); + metadatum.clearValue(Metadata.ID); + + ContentValues values = metadatum.getMergedValues(); + for(Map.Entry entry : values.valueSet()) { + if(entry.getKey().startsWith("value")) //$NON-NLS-1$ + { + values.put(entry.getKey(), entry.getValue().toString()); + } + } + newMetadataValues.add(values); + } + + metadataDao.byTaskAndKey(taskId, GeofenceFields.METADATA_KEY, new Callback() { + @Override + public void apply(Metadata item) { + long id = item.getId(); + + // clear item id when matching with incoming values + item.clearValue(Metadata.ID); + item.clearValue(Metadata.CREATION_DATE); + ContentValues itemMergedValues = item.getMergedValues(); + + if(newMetadataValues.contains(itemMergedValues)) { + newMetadataValues.remove(itemMergedValues); + } else { + // not matched. cut it + item.setId(id); + if (callback != null) { + callback.beforeDeleteMetadata(item); + } + metadataDao.delete(id); + dirty[0] = true; + } + } + }); + + // everything that remains shall be written + for(ContentValues values : newMetadataValues) { + Metadata item = new Metadata(); + item.setCreationDate(DateUtilities.now()); + item.mergeWith(values); + metadataDao.persist(item); + dirty[0] = true; + } + + return dirty[0]; + } +} diff --git a/src/main/java/org/tasks/location/OnLocationPickedHandler.java b/src/main/java/org/tasks/location/OnLocationPickedHandler.java new file mode 100644 index 000000000..2a98eba76 --- /dev/null +++ b/src/main/java/org/tasks/location/OnLocationPickedHandler.java @@ -0,0 +1,5 @@ +package org.tasks.location; + +public interface OnLocationPickedHandler { + void onLocationPicked(Geofence geofence); +} diff --git a/src/main/java/org/tasks/scheduling/AlarmSchedulingIntentService.java b/src/main/java/org/tasks/scheduling/AlarmSchedulingIntentService.java index 73ced8ee6..f278faa51 100644 --- a/src/main/java/org/tasks/scheduling/AlarmSchedulingIntentService.java +++ b/src/main/java/org/tasks/scheduling/AlarmSchedulingIntentService.java @@ -7,6 +7,7 @@ import com.todoroo.astrid.alarms.AlarmService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.tasks.injection.InjectingIntentService; +import org.tasks.location.GeofenceService; import javax.inject.Inject; @@ -15,6 +16,7 @@ public class AlarmSchedulingIntentService extends InjectingIntentService { private static final Logger log = LoggerFactory.getLogger(AlarmSchedulingIntentService.class); @Inject AlarmService alarmService; + @Inject GeofenceService geofenceService; public AlarmSchedulingIntentService() { super(AlarmSchedulingIntentService.class.getSimpleName()); @@ -27,5 +29,6 @@ public class AlarmSchedulingIntentService extends InjectingIntentService { log.debug("onHandleIntent({})", intent); alarmService.scheduleAllAlarms(); + geofenceService.setupGeofences(); } } diff --git a/src/main/res/values-bg-rBG/strings.xml b/src/main/res/values-bg-rBG/strings.xml index bad795eb8..2241eabb4 100644 --- a/src/main/res/values-bg-rBG/strings.xml +++ b/src/main/res/values-bg-rBG/strings.xml @@ -494,6 +494,8 @@ Произволно веднъж Произволно Избери дата и време + Избери местоположение + Въведи местоположение След крайния срок При краен срок diff --git a/src/main/res/values-ja/strings.xml b/src/main/res/values-ja/strings.xml index 2e9288d36..6ee4e1888 100644 --- a/src/main/res/values-ja/strings.xml +++ b/src/main/res/values-ja/strings.xml @@ -492,6 +492,8 @@ 1回ランダムに ランダムに 日付と時間を選択 + 場所を選択 + 場所を入力 期限を過ぎたとき 期限に diff --git a/src/main/res/values/bools.xml b/src/main/res/values/bools.xml index 5b33fae3e..3af822b39 100644 --- a/src/main/res/values/bools.xml +++ b/src/main/res/values/bools.xml @@ -4,4 +4,5 @@ false false false + false \ No newline at end of file diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index e24f0655c..29110fd11 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -98,6 +98,8 @@ Randomly once Randomly Pick a date and time + Pick a location + Type a location When overdue When due