diff --git a/app/src/main/java/com/todoroo/astrid/service/Upgrader.java b/app/src/main/java/com/todoroo/astrid/service/Upgrader.java index 86268a376..edceb0081 100644 --- a/app/src/main/java/com/todoroo/astrid/service/Upgrader.java +++ b/app/src/main/java/com/todoroo/astrid/service/Upgrader.java @@ -13,12 +13,14 @@ import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableListMultimap; import com.google.common.collect.ListMultimap; +import com.google.common.collect.Lists; import com.google.common.collect.Multimaps; import com.todoroo.astrid.api.GtasksFilter; import com.todoroo.astrid.dao.TaskDao; import java.io.File; import java.util.List; import javax.inject.Inject; +import net.fortuna.ical4j.model.property.Geo; import org.tasks.R; import org.tasks.analytics.Tracker; import org.tasks.caldav.iCalendar; @@ -31,6 +33,8 @@ import org.tasks.data.FilterDao; import org.tasks.data.GoogleTaskAccount; import org.tasks.data.GoogleTaskList; import org.tasks.data.GoogleTaskListDao; +import org.tasks.data.Location; +import org.tasks.data.LocationDao; import org.tasks.data.Tag; import org.tasks.data.TagDao; import org.tasks.data.TagData; @@ -56,6 +60,7 @@ public class Upgrader { private static final int V6_9 = 608; private static final int V7_0 = 617; public static final int V8_2 = 675; + private static final int V8_5 = 700; private final Context context; private final Preferences preferences; private final Tracker tracker; @@ -68,6 +73,7 @@ public class Upgrader { private final TaskAttachmentDao taskAttachmentDao; private final CaldavDao caldavDao; private final TaskDao taskDao; + private final LocationDao locationDao; private final iCalendar iCal; @Inject @@ -84,6 +90,7 @@ public class Upgrader { TaskAttachmentDao taskAttachmentDao, CaldavDao caldavDao, TaskDao taskDao, + LocationDao locationDao, iCalendar iCal) { this.context = context; this.preferences = preferences; @@ -97,6 +104,7 @@ public class Upgrader { this.taskAttachmentDao = taskAttachmentDao; this.caldavDao = caldavDao; this.taskDao = taskDao; + this.locationDao = locationDao; this.iCal = iCal; } @@ -113,6 +121,7 @@ public class Upgrader { run(from, V6_9, this::applyCaldavCategories); run(from, V7_0, this::applyCaldavSubtasks); run(from, V8_2, this::migrateColors); + run(from, V8_5, this::applyCaldavGeo); } preferences.setCurrentVersion(to); } @@ -181,6 +190,35 @@ public class Upgrader { } } + private void applyCaldavGeo() { + List updated = newArrayList(); + + List tasksWithLocations = + Lists.transform(locationDao.getActiveGeofences(), Location::getTask); + + for (CaldavTask task : transform(caldavDao.getTasks(), CaldavTaskContainer::getCaldavTask)) { + long taskId = task.getTask(); + if (tasksWithLocations.contains(taskId)) { + continue; + } + + at.bitfire.ical4android.Task remoteTask = iCalendar.Companion.fromVtodo(task.getVtodo()); + if (remoteTask == null) { + continue; + } + + Geo geo = remoteTask.getGeoPosition(); + if (geo == null) { + continue; + } + + iCal.setPlace(taskId, geo); + } + + batch(tasksWithLocations, taskDao::touch); + caldavDao.update(updated); + } + private void applyCaldavSubtasks() { List updated = newArrayList(); diff --git a/app/src/main/java/org/tasks/caldav/iCalendar.kt b/app/src/main/java/org/tasks/caldav/iCalendar.kt index 2dd28f317..9a0c10b6e 100644 --- a/app/src/main/java/org/tasks/caldav/iCalendar.kt +++ b/app/src/main/java/org/tasks/caldav/iCalendar.kt @@ -15,8 +15,15 @@ import com.todoroo.astrid.helper.UUIDHelper import com.todoroo.astrid.service.TaskCreator import net.fortuna.ical4j.model.Parameter import net.fortuna.ical4j.model.parameter.RelType +import net.fortuna.ical4j.model.property.Geo import net.fortuna.ical4j.model.property.RelatedTo +import org.tasks.caldav.GeoUtils.equalish +import org.tasks.caldav.GeoUtils.toGeo +import org.tasks.caldav.GeoUtils.toLikeString import org.tasks.data.* +import org.tasks.jobs.WorkManager +import org.tasks.location.GeofenceApi +import org.tasks.preferences.Preferences import timber.log.Timber import java.io.ByteArrayOutputStream import java.io.StringReader @@ -24,6 +31,10 @@ import javax.inject.Inject class iCalendar @Inject constructor( private val tagDataDao: TagDataDao, + private val preferences: Preferences, + private val locationDao: LocationDao, + private val workManager: WorkManager, + private val geofenceApi: GeofenceApi, private val taskCreator: TaskCreator, private val tagDao: TagDao, private val taskDao: TaskDao, @@ -60,6 +71,30 @@ class iCalendar @Inject constructor( } } + fun setPlace(taskId: Long, geo: Geo) { + var place: Place? = locationDao.findPlace( + geo.latitude.toLikeString(), + geo.longitude.toLikeString()) + if (place == null) { + place = Place.newPlace(geo) + place.id = locationDao.insert(place) + workManager.reverseGeocode(place) + } + val existing: Location? = locationDao.getGeofences(taskId) + if (existing == null) { + val geofence = Geofence(place!!.uid, preferences) + geofence.task = taskId + geofence.id = locationDao.insert(geofence) + geofenceApi.register(Location(geofence, place)) + } else if (place != existing.place) { + geofenceApi.cancel(existing) + val geofence = existing.geofence + geofence.place = place!!.uid + locationDao.update(geofence) + geofenceApi.register(existing) + } + } + fun getTags(categories: List): List { if (categories.isEmpty()) { return emptyList() @@ -87,6 +122,11 @@ class iCalendar @Inject constructor( } else { remoteModel.uid = caldavTask.remoteId } + val location = locationDao.getGeofences(task.getId()) + val localGeo = toGeo(location) + if (localGeo == null || !localGeo.equalish(remoteModel.geoPosition)) { + remoteModel.geoPosition = localGeo + } val os = ByteArrayOutputStream() remoteModel.write(os) @@ -111,6 +151,15 @@ class iCalendar @Inject constructor( caldavTask = existing } CaldavConverter.apply(task, remote) + val geo = remote.geoPosition + if (geo == null) { + locationDao.getActiveGeofences(task.getId()).forEach { + geofenceApi.cancel(it) + locationDao.delete(it.geofence) + } + } else { + setPlace(task.getId(), geo) + } tagDao.applyTags(task, tagDataDao, getTags(remote.categories)) task.putTransitory(SyncFlags.GTASKS_SUPPRESS_SYNC, true) task.putTransitory(TaskDao.TRANS_SUPPRESS_REFRESH, true) diff --git a/app/src/main/java/org/tasks/data/Location.java b/app/src/main/java/org/tasks/data/Location.java index 09577b3c0..e037c3688 100644 --- a/app/src/main/java/org/tasks/data/Location.java +++ b/app/src/main/java/org/tasks/data/Location.java @@ -63,7 +63,7 @@ public class Location implements Serializable, Parcelable { geofence.setTask(task); } - public String getName() { + @Nullable public String getName() { return place.getName(); } diff --git a/app/src/main/java/org/tasks/data/LocationDao.java b/app/src/main/java/org/tasks/data/LocationDao.java index 1a09e59a2..f300becc8 100644 --- a/app/src/main/java/org/tasks/data/LocationDao.java +++ b/app/src/main/java/org/tasks/data/LocationDao.java @@ -47,6 +47,9 @@ public interface LocationDao { @Update void update(Place place); + @Update + void update(Geofence geofence); + @Query("SELECT * FROM places WHERE uid = :uid LIMIT 1") Place getByUid(String uid); @@ -56,6 +59,9 @@ public interface LocationDao { @Query("SELECT * FROM places") List getPlaces(); + @Query("SELECT * FROM places WHERE place_id = :id") + Place getPlace(long id); + @Query( "SELECT places.*, IFNULL(COUNT(geofence_id),0) AS count FROM places LEFT OUTER JOIN geofences ON geofences.place = places.uid GROUP BY uid ORDER BY COUNT(geofence_id) DESC") LiveData> getPlaceUsage(); diff --git a/app/src/main/java/org/tasks/data/Place.java b/app/src/main/java/org/tasks/data/Place.java index 969f24cec..5fd76e8cc 100644 --- a/app/src/main/java/org/tasks/data/Place.java +++ b/app/src/main/java/org/tasks/data/Place.java @@ -7,6 +7,7 @@ import android.location.Location; import android.net.Uri; import android.os.Parcel; import android.os.Parcelable; +import androidx.annotation.Nullable; import androidx.room.ColumnInfo; import androidx.room.Entity; import androidx.room.Ignore; @@ -21,6 +22,7 @@ import java.io.Serializable; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; +import net.fortuna.ical4j.model.property.Geo; import org.tasks.location.MapPosition; @Entity(tableName = TABLE_NAME, indices = @Index(name = "place_uid", value = "uid", unique = true)) @@ -44,6 +46,8 @@ public class Place implements Serializable, Parcelable { } }; private static final Pattern pattern = Pattern.compile("(\\d+):(\\d+):(\\d+\\.\\d+)"); + private static final Pattern COORDS = + Pattern.compile("^\\d+°\\d+'\\d+\\.\\d+\"[NS] \\d+°\\d+'\\d+\\.\\d+\"[EW]$"); @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "place_id") @@ -96,12 +100,6 @@ public class Place implements Serializable, Parcelable { longitude = parcel.readDouble(); } - private static String formatCoordinates(org.tasks.data.Place place) { - return String.format( - "%s %s", - formatCoordinate(place.getLatitude(), true), formatCoordinate(place.getLongitude(), false)); - } - private static String formatCoordinate(double coordinates, boolean latitude) { String output = android.location.Location.convert(Math.abs(coordinates), Location.FORMAT_SECONDS); @@ -118,11 +116,21 @@ public class Place implements Serializable, Parcelable { } } - public static Place newPlace(MapPosition mapPosition) { + public static Place newPlace(Geo geo) { + Place place = newPlace(); + place.setLatitude(geo.getLatitude().doubleValue()); + place.setLongitude(geo.getLongitude().doubleValue()); + return place; + } + + @Nullable public static Place newPlace(@Nullable MapPosition mapPosition) { + if (mapPosition == null) { + return null; + } + Place place = newPlace(); place.setLatitude(mapPosition.getLatitude()); place.setLongitude(mapPosition.getLongitude()); - place.setName(formatCoordinates(place)); return place; } @@ -162,7 +170,7 @@ public class Place implements Serializable, Parcelable { this.uid = uid; } - public String getName() { + @Nullable public String getName() { return name; } @@ -211,13 +219,14 @@ public class Place implements Serializable, Parcelable { } public String getDisplayName() { + if (!Strings.isNullOrEmpty(name) && !COORDS.matcher(name).matches()) { + return name; + } if (!Strings.isNullOrEmpty(address)) { return address; } - if (!Strings.isNullOrEmpty(name)) { - return name; - } - return formatCoordinates(this); + return String.format( + "%s %s", formatCoordinate(getLatitude(), true), formatCoordinate(getLongitude(), false)); } public String getDisplayAddress() { diff --git a/app/src/main/java/org/tasks/injection/ActivityModule.java b/app/src/main/java/org/tasks/injection/ActivityModule.java index 51611cc16..3f707a811 100644 --- a/app/src/main/java/org/tasks/injection/ActivityModule.java +++ b/app/src/main/java/org/tasks/injection/ActivityModule.java @@ -6,8 +6,6 @@ import dagger.Module; import dagger.Provides; import org.tasks.R; import org.tasks.billing.Inventory; -import org.tasks.location.Geocoder; -import org.tasks.location.MapboxGeocoder; import org.tasks.preferences.Preferences; import org.tasks.themes.ColorProvider; import org.tasks.themes.ThemeAccent; @@ -51,10 +49,4 @@ public class ActivityModule { public ThemeAccent getThemeAccent(ColorProvider colorProvider, Preferences preferences) { return colorProvider.getThemeAccent(preferences.getInt(R.string.p_theme_accent, 1)); } - - @Provides - @ActivityScope - public Geocoder getGeocoder(@ForApplication Context context) { - return new MapboxGeocoder(context); - } } diff --git a/app/src/main/java/org/tasks/injection/ApplicationModule.java b/app/src/main/java/org/tasks/injection/ApplicationModule.java index 5ebceac76..ab83eea2c 100644 --- a/app/src/main/java/org/tasks/injection/ApplicationModule.java +++ b/app/src/main/java/org/tasks/injection/ApplicationModule.java @@ -25,6 +25,8 @@ import org.tasks.data.TaskListMetadataDao; import org.tasks.data.UserActivityDao; import org.tasks.jobs.WorkManager; import org.tasks.locale.Locale; +import org.tasks.location.Geocoder; +import org.tasks.location.MapboxGeocoder; import org.tasks.notifications.NotificationDao; import org.tasks.security.Encryption; import org.tasks.security.KeyStoreEncryption; @@ -146,4 +148,9 @@ public class ApplicationModule { public BillingClient getBillingClient(Inventory inventory, Tracker tracker) { return new BillingClientImpl(context, inventory, tracker); } + + @Provides + public Geocoder getGeocoder(@ForApplication Context context) { + return new MapboxGeocoder(context); + } } diff --git a/app/src/main/java/org/tasks/injection/JobComponent.java b/app/src/main/java/org/tasks/injection/JobComponent.java index 8560521a4..a54ba228f 100644 --- a/app/src/main/java/org/tasks/injection/JobComponent.java +++ b/app/src/main/java/org/tasks/injection/JobComponent.java @@ -7,6 +7,7 @@ import org.tasks.jobs.CleanupWork; import org.tasks.jobs.DriveUploader; import org.tasks.jobs.MidnightRefreshWork; import org.tasks.jobs.RefreshWork; +import org.tasks.jobs.ReverseGeocodeWork; import org.tasks.jobs.SyncWork; @Subcomponent(modules = WorkModule.class) @@ -25,4 +26,6 @@ public interface JobComponent { void inject(AfterSaveWork afterSaveWork); void inject(DriveUploader driveUploader); + + void inject(ReverseGeocodeWork reverseGeocodeWork); } diff --git a/app/src/main/java/org/tasks/jobs/ReverseGeocodeWork.kt b/app/src/main/java/org/tasks/jobs/ReverseGeocodeWork.kt new file mode 100644 index 000000000..9d54128a3 --- /dev/null +++ b/app/src/main/java/org/tasks/jobs/ReverseGeocodeWork.kt @@ -0,0 +1,52 @@ +package org.tasks.jobs + +import android.content.Context +import androidx.work.WorkerParameters +import org.tasks.LocalBroadcastManager +import org.tasks.analytics.Tracker +import org.tasks.data.LocationDao +import org.tasks.injection.InjectingWorker +import org.tasks.injection.JobComponent +import org.tasks.location.Geocoder +import timber.log.Timber +import java.io.IOException +import javax.inject.Inject + +class ReverseGeocodeWork(context: Context, workerParams: WorkerParameters) : InjectingWorker(context, workerParams) { + + companion object { + const val PLACE_ID = "place_id" + } + + @Inject lateinit var localBroadcastManager: LocalBroadcastManager + @Inject lateinit var geocoder: Geocoder + @Inject lateinit var locationDao: LocationDao + @Inject lateinit var tracker: Tracker + + public override fun run(): Result { + val id = inputData.getLong(PLACE_ID, 0) + if (id == 0L) { + Timber.e("Missing id") + return Result.failure() + } + val place = locationDao.getPlace(id) + if (place == null) { + Timber.e("Can't find place $id") + return Result.failure() + } + return try { + val result = geocoder.reverseGeocode(place.mapPosition) + result.id = place.id + result.uid = place.uid + locationDao.update(result) + localBroadcastManager.broadcastRefresh() + Timber.d("found $result") + Result.success() + } catch (e: IOException) { + tracker.reportException(e); + Result.failure() + } + } + + override fun inject(component: JobComponent) = component.inject(this) +} \ No newline at end of file diff --git a/app/src/main/java/org/tasks/jobs/WorkManager.java b/app/src/main/java/org/tasks/jobs/WorkManager.java index 473496c47..daeab0561 100644 --- a/app/src/main/java/org/tasks/jobs/WorkManager.java +++ b/app/src/main/java/org/tasks/jobs/WorkManager.java @@ -9,6 +9,7 @@ import static io.reactivex.Single.zip; import static org.tasks.date.DateTimeUtils.midnight; import static org.tasks.date.DateTimeUtils.newDateTime; import static org.tasks.db.DbUtils.batch; +import static org.tasks.jobs.ReverseGeocodeWork.PLACE_ID; import static org.tasks.time.DateTimeUtils.currentTimeMillis; import static org.tasks.time.DateTimeUtils.printDuration; import static org.tasks.time.DateTimeUtils.printTimestamp; @@ -37,9 +38,11 @@ import io.reactivex.schedulers.Schedulers; import java.util.Random; import java.util.concurrent.TimeUnit; import javax.inject.Inject; +import org.tasks.BuildConfig; import org.tasks.R; import org.tasks.data.CaldavDao; import org.tasks.data.GoogleTaskListDao; +import org.tasks.data.Place; import org.tasks.injection.ApplicationScope; import org.tasks.injection.ForApplication; import org.tasks.preferences.Preferences; @@ -54,6 +57,7 @@ public class WorkManager { private static final String TAG_MIDNIGHT_REFRESH = "tag_midnight_refresh"; private static final String TAG_SYNC = "tag_sync"; private static final String TAG_BACKGROUND_SYNC = "tag_background_sync"; + private static final String TAG_REVERSE_GEOCODE = "tag_reverse_geocode"; private final Context context; private final Preferences preferences; @@ -120,6 +124,19 @@ public class WorkManager { workManager.beginUniqueWork(TAG_SYNC, ExistingWorkPolicy.REPLACE, request).enqueue(); } + public void reverseGeocode(Place place) { + if (BuildConfig.DEBUG && place.getId() == 0) { + throw new RuntimeException("Missing id"); + } + workManager.enqueue( + new Builder(ReverseGeocodeWork.class) + .setInputData(new Data.Builder().putLong(PLACE_ID, place.getId()).build()) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES) + .setConstraints( + new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .build()); + } + public void updateBackgroundSync() { updateBackgroundSync(null, null, null); } diff --git a/app/src/main/java/org/tasks/location/LocationPickerActivity.java b/app/src/main/java/org/tasks/location/LocationPickerActivity.java index 94ff3ec7a..406bdd487 100644 --- a/app/src/main/java/org/tasks/location/LocationPickerActivity.java +++ b/app/src/main/java/org/tasks/location/LocationPickerActivity.java @@ -6,7 +6,6 @@ import static com.todoroo.andlib.utility.AndroidUtilities.hideKeyboard; import static org.tasks.PermissionUtil.verifyPermissions; import android.annotation.SuppressLint; -import android.content.Context; import android.content.Intent; import android.content.res.Configuration; import android.location.Location; @@ -49,14 +48,12 @@ import java.util.concurrent.TimeUnit; import javax.inject.Inject; import org.tasks.Event; import org.tasks.R; -import org.tasks.billing.Inventory; import org.tasks.caldav.GeoUtils; import org.tasks.data.LocationDao; import org.tasks.data.Place; import org.tasks.data.PlaceUsage; import org.tasks.dialogs.DialogBuilder; import org.tasks.injection.ActivityComponent; -import org.tasks.injection.ForApplication; import org.tasks.injection.InjectingAppCompatActivity; import org.tasks.location.LocationPickerAdapter.OnLocationPicked; import org.tasks.location.LocationSearchAdapter.OnPredictionPicked; @@ -106,10 +103,8 @@ public class LocationPickerActivity extends InjectingAppCompatActivity @BindView(R.id.recent_locations) RecyclerView recyclerView; - @Inject @ForApplication Context context; @Inject Theme theme; @Inject Toaster toaster; - @Inject Inventory inventory; @Inject LocationDao locationDao; @Inject PlaceSearchProvider searchProvider; @Inject PermissionChecker permissionChecker; @@ -325,7 +320,7 @@ public class LocationPickerActivity extends InjectingAppCompatActivity }); } - private void returnPlace(org.tasks.data.Place place) { + private void returnPlace(@Nullable org.tasks.data.Place place) { if (place == null) { Timber.e("Place is null"); return; diff --git a/app/src/main/java/org/tasks/location/MapboxGeocoder.java b/app/src/main/java/org/tasks/location/MapboxGeocoder.java index c6ea92ec2..a46f35b3d 100644 --- a/app/src/main/java/org/tasks/location/MapboxGeocoder.java +++ b/app/src/main/java/org/tasks/location/MapboxGeocoder.java @@ -4,6 +4,8 @@ import static com.todoroo.andlib.utility.AndroidUtilities.assertNotMainThread; import static org.tasks.data.Place.newPlace; import android.content.Context; +import android.os.Handler; +import android.os.Looper; import com.google.gson.GsonBuilder; import com.google.gson.JsonParser; import com.mapbox.api.geocoding.v5.MapboxGeocoding; @@ -25,6 +27,15 @@ public class MapboxGeocoder implements Geocoder { public MapboxGeocoder(Context context) { token = context.getString(R.string.mapbox_key); + Looper mainLooper = Looper.getMainLooper(); + if (mainLooper.getThread() == Thread.currentThread()) { + init(context); + } else { + new Handler(mainLooper).post(() -> init(context)); + } + } + + private void init(Context context) { Mapbox.getInstance(context, token); }