diff --git a/app/build.gradle b/app/build.gradle index 9980cf3..6e874c8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -74,8 +74,14 @@ dependencies { implementation 'stax:stax-api:1.0.1' implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.9.8' + implementation ('de.westnordost:osmapi-traces:1.0') + configurations { + compile.exclude group: 'net.sf.kxml', module: 'kxml2' // already included in Android + } + implementation 'oauth.signpost:signpost-commonshttp4:1.2.1.2' + // Android Room - def room_version = "2.2.0-beta01" + def room_version = "2.2.0" annotationProcessor "androidx.room:room-compiler:$room_version" implementation "androidx.room:room-runtime:$room_version" diff --git a/app/src/main/java/de/tadris/fitness/activity/ShowWorkoutActivity.java b/app/src/main/java/de/tadris/fitness/activity/ShowWorkoutActivity.java index 0036a13..b9a6c02 100644 --- a/app/src/main/java/de/tadris/fitness/activity/ShowWorkoutActivity.java +++ b/app/src/main/java/de/tadris/fitness/activity/ShowWorkoutActivity.java @@ -20,6 +20,8 @@ package de.tadris.fitness.activity; import android.app.AlertDialog; +import android.app.Dialog; +import android.content.DialogInterface; import android.content.Intent; import android.graphics.Typeface; import android.net.Uri; @@ -28,21 +30,31 @@ import android.util.TypedValue; import android.view.Menu; import android.view.MenuItem; import android.view.View; +import android.widget.CheckBox; import android.widget.EditText; +import android.widget.Spinner; import android.widget.TextView; +import android.widget.Toast; import androidx.core.content.FileProvider; import java.io.File; import java.text.SimpleDateFormat; +import java.util.ArrayList; import java.util.Date; +import java.util.List; import de.tadris.fitness.Instance; import de.tadris.fitness.R; +import de.tadris.fitness.data.WorkoutSample; +import de.tadris.fitness.osm.OAuthAuthentication; +import de.tadris.fitness.osm.OsmTraceUploader; import de.tadris.fitness.util.DialogUtils; import de.tadris.fitness.util.gpx.GpxExporter; import de.tadris.fitness.util.unit.UnitUtils; import de.tadris.fitness.view.ProgressDialogController; +import de.westnordost.osmapi.traces.GpsTraceDetails; +import oauth.signpost.OAuthConsumer; public class ShowWorkoutActivity extends WorkoutActivity implements DialogUtils.WorkoutDeleter { @@ -211,7 +223,55 @@ public class ShowWorkoutActivity extends WorkoutActivity implements DialogUtils. }).start(); } + private OAuthConsumer oAuthConsumer= null; + private void prepareUpload(){ + OAuthAuthentication authentication= new OAuthAuthentication(mHandler, this, new OAuthAuthentication.OAuthAuthenticationListener() { + @Override + public void authenticationFailed() { + new AlertDialog.Builder(ShowWorkoutActivity.this) + .setTitle(R.string.error) + .setMessage(R.string.authenticationFailed) + .setPositiveButton(R.string.okay, null) + .create().show(); + } + @Override + public void authenticationComplete(OAuthConsumer consumer) { + oAuthConsumer= consumer; + showUploadOptions(); + } + }); + + authentication.authenticateIfNecessary(); + } + + AlertDialog dialog= null; + private void showUploadOptions(){ + dialog= new AlertDialog.Builder(this) + .setTitle(R.string.actionUploadToOSM) + .setView(R.layout.dialog_upload_osm) + .setPositiveButton(R.string.upload, (dialogInterface, i) -> { + CheckBox checkBox= dialog.findViewById(R.id.uploadCutting); + Spinner spinner= dialog.findViewById(R.id.uploadVisibility); + EditText descriptionEdit= dialog.findViewById(R.id.uploadDescription); + String description= descriptionEdit.getText().toString().trim(); + GpsTraceDetails.Visibility visibility; + switch (spinner.getSelectedItemPosition()){ + case 0: visibility= GpsTraceDetails.Visibility.IDENTIFIABLE; break; + default: + case 1: visibility= GpsTraceDetails.Visibility.TRACKABLE; break; + case 2: visibility= GpsTraceDetails.Visibility.PRIVATE; break; + } + uploadToOsm(checkBox.isChecked(), visibility, description); + }) + .setNegativeButton(R.string.cancel, null) + .show(); + } + + private void uploadToOsm(boolean cut, GpsTraceDetails.Visibility visibility, String description){ + List samples = new ArrayList<>(this.samples); + new OsmTraceUploader(this, mHandler, workout, samples, visibility, oAuthConsumer, cut, description).upload(); + } @Override public boolean onOptionsItemSelected(MenuItem item) { @@ -223,6 +283,9 @@ public class ShowWorkoutActivity extends WorkoutActivity implements DialogUtils. case R.id.actionExportGpx: exportToGpx(); return true; + case R.id.actionUploadOSM: + prepareUpload(); + return true; } return super.onOptionsItemSelected(item); } diff --git a/app/src/main/java/de/tadris/fitness/data/Workout.java b/app/src/main/java/de/tadris/fitness/data/Workout.java index 159ac91..0e9f1d2 100644 --- a/app/src/main/java/de/tadris/fitness/data/Workout.java +++ b/app/src/main/java/de/tadris/fitness/data/Workout.java @@ -80,9 +80,13 @@ public class Workout{ if(comment.length() > 2){ return comment; }else{ - return SimpleDateFormat.getDateTimeInstance().format(new Date(start)); + return getDateString(); } } + public String getDateString(){ + return SimpleDateFormat.getDateTimeInstance().format(new Date(start)); + } + } \ No newline at end of file diff --git a/app/src/main/java/de/tadris/fitness/osm/GpsTraceLatLong.java b/app/src/main/java/de/tadris/fitness/osm/GpsTraceLatLong.java new file mode 100644 index 0000000..2a292ea --- /dev/null +++ b/app/src/main/java/de/tadris/fitness/osm/GpsTraceLatLong.java @@ -0,0 +1,29 @@ +package de.tadris.fitness.osm; + +import de.tadris.fitness.data.WorkoutSample; +import de.westnordost.osmapi.map.data.LatLon; + +public class GpsTraceLatLong implements LatLon { + + private final double latitude; + private final double longitude; + + public GpsTraceLatLong(double latitude, double longitude) { + this.latitude = latitude; + this.longitude = longitude; + } + + public GpsTraceLatLong(WorkoutSample sample) { + this(sample.lat, sample.lon); + } + + @Override + public double getLatitude() { + return latitude; + } + + @Override + public double getLongitude() { + return longitude; + } +} diff --git a/app/src/main/java/de/tadris/fitness/osm/OAuthAuthentication.java b/app/src/main/java/de/tadris/fitness/osm/OAuthAuthentication.java new file mode 100644 index 0000000..bde83ff --- /dev/null +++ b/app/src/main/java/de/tadris/fitness/osm/OAuthAuthentication.java @@ -0,0 +1,132 @@ +package de.tadris.fitness.osm; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Handler; +import android.provider.Browser; +import android.text.InputType; +import android.widget.EditText; + +import de.tadris.fitness.R; +import de.tadris.fitness.view.ProgressDialogController; +import oauth.signpost.OAuth; +import oauth.signpost.OAuthConsumer; +import oauth.signpost.OAuthProvider; +import oauth.signpost.exception.OAuthException; + +public class OAuthAuthentication { + + private OAuthConsumer oAuthConsumer= OAuthUrlProvider.getDefaultConsumer(); + private OAuthProvider oAuthProvider= OAuthUrlProvider.getDefaultProvider(); + + private Handler handler; + private Activity activity; + private ProgressDialogController dialogController; + private SharedPreferences preferences; + private OAuthAuthenticationListener listener; + + public OAuthAuthentication(Handler handler, Activity activity, OAuthAuthenticationListener listener) { + this.handler = handler; + this.activity = activity; + dialogController= new ProgressDialogController(activity, activity.getString(R.string.uploading)); + this.preferences= activity.getSharedPreferences("osm_oauth", Context.MODE_PRIVATE); + this.listener= listener; + } + + public void authenticateIfNecessary(){ + if(isAuthenticated()){ + loadAccessToken(); + listener.authenticationComplete(oAuthConsumer); + }else{ + retrieveRequestToken(); + } + } + + private boolean isAuthenticated(){ + return preferences.getBoolean("authenticated", false); + } + + private void retrieveRequestToken(){ + dialogController.show(); + dialogController.setIndeterminate(true); + new Thread(() -> { + try { + String authUrl = oAuthProvider.retrieveRequestToken(oAuthConsumer, OAuth.OUT_OF_BAND); + handler.post(() -> { + Intent intent= new Intent(Intent.ACTION_VIEW, Uri.parse(authUrl)); + intent.putExtra(Browser.EXTRA_APPLICATION_ID, activity.getPackageName()); + intent.setFlags(Intent.FLAG_ACTIVITY_NO_HISTORY | Intent.FLAG_ACTIVITY_SINGLE_TOP); + activity.startActivity(intent); + showEnterVerificationCodeDialog(); + dialogController.cancel(); + }); + } catch (OAuthException e) { + e.printStackTrace(); + handler.post(() -> { + dialogController.cancel(); + listener.authenticationFailed(); + }); + } + }).start(); + } + + private void showEnterVerificationCodeDialog(){ + EditText editText= new EditText(activity); + editText.setInputType(InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); + editText.setSingleLine(true); + + AlertDialog dialog= new AlertDialog.Builder(activity) + .setTitle(R.string.enterVerificationCode).setView(editText).setPositiveButton(R.string.okay, (dialogInterface, i) -> { + new Thread(() -> retrieveAccessToken(editText.getText().toString().trim())).start(); + dialogInterface.cancel(); + }).setNegativeButton(R.string.cancel, null).setCancelable(false).create(); + dialog.show(); + } + + private void loadAccessToken(){ + oAuthConsumer.setTokenWithSecret(preferences.getString("accessToken", ""), + preferences.getString("tokenSecret", "")); + } + + private void saveAccessToken(){ + preferences.edit() + .putString("accessToken", oAuthConsumer.getToken()) + .putString("tokenSecret", oAuthConsumer.getTokenSecret()) + .putBoolean("authenticated", true).apply(); + } + + public void clearAccessToken(){ + preferences.edit().putBoolean("authenticated", false).apply(); + } + + private void retrieveAccessToken(String verificationCode){ + handler.post(() -> dialogController.show()); + try{ + oAuthProvider.retrieveAccessToken(oAuthConsumer, verificationCode); + handler.post(() -> { + dialogController.cancel(); + saveAccessToken(); + listener.authenticationComplete(oAuthConsumer); + }); + }catch (OAuthException e){ + handler.post(() -> { + dialogController.cancel(); + listener.authenticationFailed(); + }); + e.printStackTrace(); + } + } + + public interface OAuthAuthenticationListener{ + + void authenticationFailed(); + + void authenticationComplete(OAuthConsumer consumer); + + } + +} diff --git a/app/src/main/java/de/tadris/fitness/osm/OAuthUrlProvider.java b/app/src/main/java/de/tadris/fitness/osm/OAuthUrlProvider.java new file mode 100644 index 0000000..c76d1d2 --- /dev/null +++ b/app/src/main/java/de/tadris/fitness/osm/OAuthUrlProvider.java @@ -0,0 +1,26 @@ +package de.tadris.fitness.osm; + +import oauth.signpost.OAuthConsumer; +import oauth.signpost.OAuthProvider; +import oauth.signpost.commonshttp.CommonsHttpOAuthConsumer; +import oauth.signpost.commonshttp.CommonsHttpOAuthProvider; + +public class OAuthUrlProvider { + + static private final String CONSUMER_KEY= "jFL9grFmAo5ZS720YDDRXdSOb7F0IZQf9lnY1PHq"; + static private final String CONSUMER_SECRET= "oH969vYW60fZLco6E09UQl3uFXqjl4siQbOL0q9q"; + + static OAuthConsumer getDefaultConsumer(){ + return new CommonsHttpOAuthConsumer(CONSUMER_KEY, CONSUMER_SECRET); + } + + static OAuthProvider getDefaultProvider(){ + return new CommonsHttpOAuthProvider(URL_TOKEN_REQUEST, URL_TOKEN_ACCESS, URL_AUTHORIZE); + } + + static private final String URL_TOKEN_REQUEST= "https://www.openstreetmap.org/oauth/request_token"; + static private final String URL_TOKEN_ACCESS= "https://www.openstreetmap.org/oauth/access_token"; + static private final String URL_AUTHORIZE= "https://www.openstreetmap.org/oauth/authorize"; + + +} diff --git a/app/src/main/java/de/tadris/fitness/osm/OsmTraceUploader.java b/app/src/main/java/de/tadris/fitness/osm/OsmTraceUploader.java new file mode 100644 index 0000000..fc02141 --- /dev/null +++ b/app/src/main/java/de/tadris/fitness/osm/OsmTraceUploader.java @@ -0,0 +1,116 @@ +package de.tadris.fitness.osm; + +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.os.Handler; +import android.util.Log; +import android.widget.Toast; + +import androidx.annotation.StringRes; + +import java.lang.reflect.Array; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.List; + +import de.tadris.fitness.R; +import de.tadris.fitness.data.Workout; +import de.tadris.fitness.data.WorkoutSample; +import de.tadris.fitness.view.ProgressDialogController; +import de.westnordost.osmapi.OsmConnection; +import de.westnordost.osmapi.traces.GpsTraceDetails; +import de.westnordost.osmapi.traces.GpsTracesDao; +import de.westnordost.osmapi.traces.GpsTrackpoint; +import oauth.signpost.OAuthConsumer; + +public class OsmTraceUploader { + + private static final int CUT_DISTANCE= 300; + + private Activity activity; + private Handler handler; + private Workout workout; + private List samples; + private GpsTraceDetails.Visibility visibility; + private OAuthConsumer consumer; + private boolean cut; + private ProgressDialogController dialogController; + private String description; + + public OsmTraceUploader(Activity activity, Handler handler, Workout workout, List samples, GpsTraceDetails.Visibility visibility, OAuthConsumer consumer, boolean cut, String description) { + this.activity = activity; + this.handler = handler; + this.workout = workout; + this.samples = samples; + this.visibility = visibility; + this.consumer = consumer; + this.cut = cut; + this.description= description; + this.dialogController= new ProgressDialogController(activity, activity.getString(R.string.uploading)); + } + + private void cut(){ + cut(false); + cut(true); + } + + private void cut(boolean last){ + double distance= 0; + int count= 0; + WorkoutSample lastSample= samples.remove(last ? samples.size()-1 : 0); + while(distance < CUT_DISTANCE){ + WorkoutSample currentSample= samples.remove(last ? samples.size()-1 : 0); + distance+= lastSample.toLatLong().sphericalDistance(currentSample.toLatLong()); + count++; + lastSample= currentSample; + } + Log.d("Uploader", "Cutted " + (last ? "last" : "first") + " " + count + " Samples (" + distance + " meters)"); + } + + public void upload(){ + new Thread(() -> { + try{ + executeTask(); + }catch (Exception e){ + e.printStackTrace(); + handler.post(() -> { + Toast.makeText(activity, R.string.uploadFailed, Toast.LENGTH_LONG).show(); + dialogController.cancel(); + }); + } + }).start(); + } + + private void executeTask(){ + handler.post(() -> dialogController.show()); + setProgress(0); + if(cut){ cut(); } + setProgress(20); + OsmConnection osm = new OsmConnection( + "https://api.openstreetmap.org/api/0.6/", "FitoTrack", consumer); + + List trackpoints= new ArrayList<>(); + + for(WorkoutSample sample : samples){ + GpsTrackpoint trackpoint= new GpsTrackpoint(new GpsTraceLatLong(sample)); + trackpoint.time= new Date(sample.absoluteTime); + trackpoint.elevation= (float)sample.elevation; + trackpoints.add(trackpoint); + } + setProgress(25); + new GpsTracesDao(osm).create(workout.getDateString(), visibility, description, Collections.singletonList("FitoTrack"), trackpoints); + setProgress(100); + handler.post(() -> { + Toast.makeText(activity, R.string.uploadSuccessful, Toast.LENGTH_LONG).show(); + dialogController.cancel(); + }); + } + + private void setProgress(int progress){ + handler.post(() -> dialogController.setProgress(progress)); + } + +} diff --git a/app/src/main/res/layout/dialog_upload_osm.xml b/app/src/main/res/layout/dialog_upload_osm.xml new file mode 100644 index 0000000..0a9d176 --- /dev/null +++ b/app/src/main/res/layout/dialog_upload_osm.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/show_workout_menu.xml b/app/src/main/res/menu/show_workout_menu.xml index 9124cf6..fe5e260 100644 --- a/app/src/main/res/menu/show_workout_menu.xml +++ b/app/src/main/res/menu/show_workout_menu.xml @@ -23,6 +23,9 @@ + diff --git a/app/src/main/res/values/osm_track_visibility.xml b/app/src/main/res/values/osm_track_visibility.xml new file mode 100644 index 0000000..a3b286d --- /dev/null +++ b/app/src/main/res/values/osm_track_visibility.xml @@ -0,0 +1,8 @@ + + + + Identifiable + Trackable + Private + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 924d17e..bd74019 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -62,6 +62,12 @@ Burned Energy Total Energy Energy Consumption + Uploading + Enter Verification Code + Authentication failed. + Upload + Upload Successful + Upload Failed Ascent Descent @@ -115,4 +121,8 @@ Data Map Style Waiting for GPS + Upload to OSM + Cut the first/last 300 Meters + Track Visibility + Description