diff --git a/app/build.gradle b/app/build.gradle index 3caf91def..ac854fe64 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -85,6 +85,12 @@ dependencies { exclude group: 'net.sf.kxml', module: 'kxml2' exclude group: 'xmlpull', module: 'xmlpull' } + // For upload notes to osm server + implementation ('de.westnordost:osmapi-notes:3.1'){ + // Already included in Android + exclude group: 'net.sf.kxml', module: 'kxml2' + exclude group: 'xmlpull', module: 'xmlpull' + } // App intro implementation 'com.github.AppIntro:AppIntro:6.3.1' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4dc8627d4..451be00f4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -108,6 +108,7 @@ + Uploads a note on OSM using the API and + * OAuth authentication.

+ * + *

This activity may be called twice during a single + * upload cycle: First to start the upload, then a second + * time when the user has authenticated using the browser.

+ * + * @author Most of the code was made by Nicolas Guillaumin, adapted by Jose Andrés Vargas Serrano + */ +public class OpenStreetMapNotesUpload extends Activity { + + private static final String TAG = OpenStreetMapNotesUpload.class.getSimpleName(); + + private double latitude; + private double longitude; + + private TextView noteContentView; + private TextView noteFooterView; + + /** URL that the browser will call once the user is authenticated */ + public final static String OAUTH2_CALLBACK_URL = "osmtracker://osm-upload/oath2-completed/"; + public final static int RC_AUTH = 7; + + private AuthorizationService authService; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + View uploadNoteView = getLayoutInflater().inflate(R.layout.osm_note_upload, null); + setContentView(uploadNoteView); + setTitle(R.string.osm_note_upload); + + noteContentView = uploadNoteView.findViewById(R.id.wplist_item_name); + noteFooterView = uploadNoteView.findViewById(R.id.osm_note_footer); + + // Read and cache extras + Bundle extras = getIntent().getExtras(); + if (extras == null) { + Log.e(TAG, "Missing extras for note upload."); + finish(); + return; + } + + String initialNoteText = extras.getString("noteContent", ""); + String appName = extras.getString("appName", getString(R.string.app_name)); + String version = extras.getString("version", ""); + + if (extras.containsKey("latitude")) latitude = extras.getDouble("latitude"); + if (extras.containsKey("longitude")) longitude = extras.getDouble("longitude"); + + // fill UI with note content and note footer + noteContentView.setText(initialNoteText); + noteFooterView.setText(getString(R.string.osm_note_footer, appName, version)); + + final Button btnOk = (Button) findViewById(R.id.osm_note_upload_button_ok); + btnOk.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + startUpload(); + } + }); + final Button btnCancel = (Button) findViewById(R.id.osm_note_upload_button_cancel); + btnCancel.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + finish(); + } + }); + + } + + + /** + * Either starts uploading directly if we are authenticated against OpenStreetMap, + * or ask the user to authenticate via the browser. + */ + private void startUpload() { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); + if ( prefs.contains(OSMTracker.Preferences.KEY_OSM_OAUTH2_ACCESSTOKEN) ) { + // Re-use saved token + uploadToOsm(prefs.getString(OSMTracker.Preferences.KEY_OSM_OAUTH2_ACCESSTOKEN, "")); + } else { + // Open browser and request token + requestOsmAuth(); + } + } + /* + * Init Authorization request workflow. + */ + public void requestOsmAuth() { + // Authorization service configuration + AuthorizationServiceConfiguration serviceConfig = + new AuthorizationServiceConfiguration( + Uri.parse(OpenStreetMapConstants.OAuth2.Urls.AUTHORIZATION_ENDPOINT), + Uri.parse(OpenStreetMapConstants.OAuth2.Urls.TOKEN_ENDPOINT)); + + // Obtaining an authorization code + Uri redirectURI = Uri.parse(OAUTH2_CALLBACK_URL); + AuthorizationRequest.Builder authRequestBuilder = + new AuthorizationRequest.Builder( + serviceConfig, OpenStreetMapConstants.OAuth2.CLIENT_ID, + ResponseTypeValues.CODE, redirectURI); + AuthorizationRequest authRequest = authRequestBuilder + .setScope(OpenStreetMapConstants.OAuth2.SCOPE) + .build(); + + // Start activity. + authService = new AuthorizationService(this); + Intent authIntent = authService.getAuthorizationRequestIntent(authRequest); + startActivityForResult(authIntent, RC_AUTH); //when done onActivityResult will be called. + } + + + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + // User is returning from authentication + if (requestCode == RC_AUTH) { + // Handling the authorization response + AuthorizationResponse resp = AuthorizationResponse.fromIntent(data); + AuthorizationException ex = AuthorizationException.fromIntent(data); + // ... process the response or exception ... + if (ex != null) { + Log.e(TAG, "Authorization Error. Exception received from server."); + Log.e(TAG, ex.getMessage()); + } else if (resp == null) { + Log.e(TAG, "Authorization Error. Null response from server."); + } else { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); + + //Exchanging the authorization code + authService.performTokenRequest( + resp.createTokenExchangeRequest(), + new AuthorizationService.TokenResponseCallback() { + @Override public void onTokenRequestCompleted( + TokenResponse resp, AuthorizationException ex) { + if (resp != null) { + // exchange succeeded + SharedPreferences.Editor editor = prefs.edit(); + editor.putString(OSMTracker.Preferences.KEY_OSM_OAUTH2_ACCESSTOKEN, resp.accessToken); + editor.apply(); + //continue with the note Upload. + uploadToOsm(resp.accessToken); + } else { + // authorization failed, check ex for more details + Log.e(TAG, "OAuth failed."); + } + } + }); + } + } else { + Log.e(TAG, "Unexpected requestCode:" + requestCode + "."); + } + } + + /** + * Uploads notes to OSM. + */ + public void uploadToOsm(String accessToken) { + String noteText = noteContentView.getText().toString(); + String footer = noteFooterView.getText().toString(); + if (!footer.isEmpty()) { + noteText = noteText + "\n\n" + footer; + } + new UploadToOpenStreetMapNotesTask( + OpenStreetMapNotesUpload.this, + accessToken, + noteText, + latitude, + longitude + ).execute(); + } + + +} diff --git a/app/src/main/java/net/osmtracker/activity/WaypointList.java b/app/src/main/java/net/osmtracker/activity/WaypointList.java index 73dce3f77..42764e99f 100644 --- a/app/src/main/java/net/osmtracker/activity/WaypointList.java +++ b/app/src/main/java/net/osmtracker/activity/WaypointList.java @@ -5,18 +5,19 @@ import android.content.ActivityNotFoundException; import android.content.DialogInterface; import android.content.Intent; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; import android.database.Cursor; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.util.Log; +import android.view.ContextMenu; import android.view.LayoutInflater; +import android.view.MenuItem; import android.view.View; -import android.widget.Button; -import android.widget.CursorAdapter; -import android.widget.EditText; -import android.widget.ListView; -import android.widget.Toast; +import android.widget.*; +import android.widget.AdapterView.AdapterContextMenuInfo; import androidx.core.content.FileProvider; import net.osmtracker.R; import net.osmtracker.db.DataHelper; @@ -43,6 +44,8 @@ protected void onCreate(Bundle savedInstanceState) { listView.setFitsSystemWindows(true); listView.setClipToPadding(false); listView.setPadding(0, 48, 0, 0); + + registerForContextMenu(listView); } @Override @@ -224,4 +227,44 @@ private boolean isAudioFile(String path) { return path.endsWith(DataHelper.EXTENSION_3GPP); } + // Where the menu items get defined + @Override + public void onCreateContextMenu(ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo) { + super.onCreateContextMenu(menu, v, menuInfo); + getMenuInflater().inflate(R.menu.waypoint_contextmenu, menu); + } + + // What happens when a menu item is selected + @Override + public boolean onContextItemSelected(MenuItem item) { + AdapterContextMenuInfo info = (AdapterContextMenuInfo) item.getMenuInfo(); + final Cursor cursor = ((CursorAdapter) getListAdapter()).getCursor(); + if (!cursor.moveToPosition(info.position)) return super.onContextItemSelected(item); + + // Menu options when you long press on a waypoint + switch (item.getItemId()) { + case R.id.wplist_contextmenu_osm_note_upload: + String noteText = cursor.getString(cursor.getColumnIndex(TrackContentProvider.Schema.COL_NAME)); + String appName = getString(R.string.app_name); + double lat = cursor.getDouble(cursor.getColumnIndex(TrackContentProvider.Schema.COL_LATITUDE)); + double lon = cursor.getDouble(cursor.getColumnIndex(TrackContentProvider.Schema.COL_LONGITUDE)); + + Intent intent = new Intent(this, OpenStreetMapNotesUpload.class); + intent.putExtra("noteContent", noteText); + intent.putExtra("appName", appName); + // Retrieve app. version number + try { + PackageInfo pi = getPackageManager().getPackageInfo(getPackageName(), 0); + String version = pi.versionName; + intent.putExtra("version", version); + } catch (PackageManager.NameNotFoundException nnfe) { + // Should not occur + } + intent.putExtra("latitude", lat); + intent.putExtra("longitude", lon); + startActivity(intent); + return true; + } + return super.onContextItemSelected(item); + } } diff --git a/app/src/main/java/net/osmtracker/osm/OpenStreetMapConstants.java b/app/src/main/java/net/osmtracker/osm/OpenStreetMapConstants.java index f51469320..2db037506 100644 --- a/app/src/main/java/net/osmtracker/osm/OpenStreetMapConstants.java +++ b/app/src/main/java/net/osmtracker/osm/OpenStreetMapConstants.java @@ -14,12 +14,13 @@ public static class Api { } public static class OAuth2 { - public static final String CLIENT_ID_PROD = "6s8TuIQoPeq89ZWUFOXU7EZ-ZaCUVtUoNZFIKCMdU-E"; + // Client ID prod was changed to test notes uploading with new scope, must be changed back + public static final String CLIENT_ID_PROD = "2gBvqUryethglDBRXIZvXA-ijLMp--r6NUHV19NyRz4"; public static final String CLIENT_ID_DEV = "94Ht-oVBJ2spydzfk18s1RV2z7NS98SBwMfzSCqLQLE"; // DEV public static final String CLIENT_ID = (DEV_MODE) ? CLIENT_ID_DEV : CLIENT_ID_PROD; - public static final String SCOPE = "write_gpx"; + public static final String SCOPE = "write_gpx write_notes"; public static final String USER_AGENT = "OSMTracker for Android™"; public static class Urls { diff --git a/app/src/main/java/net/osmtracker/osm/UploadToOpenStreetMapNotesTask.java b/app/src/main/java/net/osmtracker/osm/UploadToOpenStreetMapNotesTask.java new file mode 100644 index 000000000..21e676312 --- /dev/null +++ b/app/src/main/java/net/osmtracker/osm/UploadToOpenStreetMapNotesTask.java @@ -0,0 +1,178 @@ +package net.osmtracker.osm; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.ProgressDialog; +import android.content.DialogInterface; +import android.content.SharedPreferences.Editor; +import android.os.AsyncTask; +import android.preference.PreferenceManager; +import android.util.Log; + +import net.osmtracker.OSMTracker; +import net.osmtracker.R; +import net.osmtracker.util.DialogUtils; + +import de.westnordost.osmapi.OsmConnection; +import de.westnordost.osmapi.common.errors.OsmAuthorizationException; +import de.westnordost.osmapi.common.errors.OsmBadUserInputException; +import de.westnordost.osmapi.map.data.OsmLatLon; +import de.westnordost.osmapi.notes.Note; // Note object +import de.westnordost.osmapi.notes.NotesApi; // Api for uploading notes to OSM +import de.westnordost.osmapi.map.data.LatLon; // Data type for location points, maybe I'll put it in the dialog file + +/** + * Uploads a note to OpenStreetMap + * + * @author Most of the code was made by Nicolas Guillaumin, adapted by Jose Andrés Vargas Serrano + */ +public class UploadToOpenStreetMapNotesTask extends AsyncTask { + + private static final String TAG = UploadToOpenStreetMapNotesTask.class.getSimpleName(); + + /** Upload progress dialog */ + private ProgressDialog dialog; + + private final Activity activity; + private final String accessToken; + + /** Note text */ + private final String noteText; + + /** Note longitude */ + private final double longitude; + + /** Note latitude */ + private final double latitude; + + /** + * Error message, or text of the response returned by OSM + * if the request completed + */ + private String errorMsg; + + /** + * Either the HTTP result code, or -1 for an internal error + */ + private int resultCode = -1; + private final int authorizationErrorResultCode = -2; + private final int anotherErrorResultCode = -3; + private final int okResultCode = 1; + + // Not using an activity yet + public UploadToOpenStreetMapNotesTask(Activity activity, String accessToken, String noteText, + double latitude, double longitude) { + this.activity = activity; + this.accessToken = accessToken; + this.noteText = noteText; + this.longitude = longitude; + this.latitude = latitude; + } + + @Override + protected void onPreExecute() { + try { + // Display progress dialog + dialog = new ProgressDialog(activity); + dialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL); + dialog.setIndeterminate(true); + dialog.setTitle(R.string.osm_note_upload); + + dialog.setCancelable(false); + dialog.show(); + + } catch (Exception e) { + Log.e(TAG, "onPreExecute() failed", e); + errorMsg = e.getLocalizedMessage(); + cancel(true); + } + } + + @Override + protected void onPostExecute(Void result) { + switch (resultCode) { + case -1: + dialog.dismiss(); + // Internal error, the request didn't start at all + DialogUtils.showErrorDialog(activity, + activity.getResources().getString(R.string.osm_note_upload_error) + + ": " + errorMsg); + break; + case okResultCode: + dialog.dismiss(); + + new AlertDialog.Builder(activity) + .setTitle(android.R.string.dialog_alert_title) + .setIcon(android.R.drawable.ic_dialog_info) + .setMessage(R.string.osm_upload_sucess) + .setCancelable(true) + .setNeutralButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + activity.finish(); + } + }).create().show(); + + break; + case authorizationErrorResultCode: + dialog.dismiss(); + Log.e(TAG, "onPostExecute() authorization failed: " + errorMsg + " (" + resultCode + ")"); + // Authorization issue. Provide a way to clear credentials + new AlertDialog.Builder(activity) + .setTitle(android.R.string.dialog_alert_title) + .setIcon(android.R.drawable.ic_dialog_alert) + .setMessage(R.string.osm_note_upload_unauthorized) + .setCancelable(true) + .setNegativeButton(android.R.string.no, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + } + }) + .setPositiveButton(android.R.string.yes, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + Editor editor = PreferenceManager.getDefaultSharedPreferences(activity).edit(); + editor.remove(OSMTracker.Preferences.KEY_OSM_OAUTH2_ACCESSTOKEN); + editor.commit(); + + dialog.dismiss(); + } + }).create().show(); + break; + + default: + // Another error. Display OSM response + dialog.dismiss(); + // Internal error, the request didn't start at all + Log.e(TAG, "onPostExecute() default failed: " + errorMsg + " (" + resultCode + ")"); + DialogUtils.showErrorDialog(activity, + activity.getResources().getString(R.string.osm_note_upload_error) + + ": " + errorMsg); + } + } + + @Override + protected Void doInBackground(Void... params) { + OsmConnection osm = new OsmConnection(OpenStreetMapConstants.Api.OSM_API_URL_PATH, + OpenStreetMapConstants.OAuth2.USER_AGENT, accessToken); + + try { + LatLon point = new OsmLatLon(latitude, longitude); + Note note = new NotesApi(osm).create(point, noteText); + resultCode = okResultCode; + } catch (/*IOException |*/ IllegalArgumentException | OsmBadUserInputException e) { + Log.d(TAG, e.getMessage()); + resultCode = -1; //internal error. + } catch (OsmAuthorizationException oae) { + Log.d(TAG, "OsmAuthorizationException"); + resultCode = authorizationErrorResultCode; + } catch (Exception e) { + Log.e(TAG, e.getMessage()); + resultCode = anotherErrorResultCode; + } + return null; + } + +} diff --git a/app/src/main/res/layout/osm_note_upload.xml b/app/src/main/res/layout/osm_note_upload.xml new file mode 100644 index 000000000..268319633 --- /dev/null +++ b/app/src/main/res/layout/osm_note_upload.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + +