From 03602b99abab48f663890c50b95dc8376676ad37 Mon Sep 17 00:00:00 2001 From: Milton Barrera Date: Fri, 3 Apr 2026 14:40:05 -0600 Subject: [PATCH 1/3] mockito inline dependency and dedicated GpsTracesApi method --- app/build.gradle | 1 + .../java/net/osmtracker/osm/UploadToOpenStreetMapTask.java | 6 +++++- gradle.properties | 3 ++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index b59388fe0..05602fda1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -114,6 +114,7 @@ dependencies { testImplementation "androidx.test:core:1.6.1" // Mockito framework testImplementation "org.mockito:mockito-core:3.12.4" + testImplementation "org.mockito:mockito-inline:3.12.4" // Required for local unit tests. Prevent null in JSONObject, JSONArray, etc. testImplementation 'org.json:json:20240303' diff --git a/app/src/main/java/net/osmtracker/osm/UploadToOpenStreetMapTask.java b/app/src/main/java/net/osmtracker/osm/UploadToOpenStreetMapTask.java index e4bb3c669..4f50d5265 100644 --- a/app/src/main/java/net/osmtracker/osm/UploadToOpenStreetMapTask.java +++ b/app/src/main/java/net/osmtracker/osm/UploadToOpenStreetMapTask.java @@ -171,6 +171,10 @@ public void onClick(DialogInterface dialog, int which) { } } + protected GpsTracesApi createGpsTracesApi(OsmConnection connection) { + return new GpsTracesApi(connection); + } + @Override protected Void doInBackground(Void... params) { OsmConnection osm = new OsmConnection(OpenStreetMapConstants.Api.OSM_API_URL_PATH, @@ -179,7 +183,7 @@ protected Void doInBackground(Void... params) { List tags = new ArrayList<>(); tags.add(this.tags); try (InputStream is = new FileInputStream(gpxFile)) { - long gpxAPI = new GpsTracesApi(osm).create(filename, getVisibilityForOsmapi(visibility), + long gpxAPI = createGpsTracesApi(osm).create(filename, getVisibilityForOsmapi(visibility), description, tags, is); Log.v(TAG, "Gpx file uploaded. GPX id: " + gpxAPI); resultCode = okResultCode; diff --git a/gradle.properties b/gradle.properties index 918826a7c..239f452d5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,5 @@ android.enableJetifier=true android.nonFinalResIds=false android.nonTransitiveRClass=false -android.useAndroidX=true \ No newline at end of file +android.useAndroidX=true +org.gradle.java.home=/var/lib/flatpak/app/com.google.AndroidStudio/x86_64/stable/3faad27366894168095d798a08f2a63401e36c12459e1c9690528ec69fb49856/files/extra/jbr From 8ed88d8f2bd5e31454ec35b1be9eedd3332281de Mon Sep 17 00:00:00 2001 From: Milton Barrera Date: Fri, 3 Apr 2026 14:57:53 -0600 Subject: [PATCH 2/3] Add unit tests for UploadToOpenStreetMapTask functionality --- .../osm/UploadToOpenStreetMapTaskTest.java | 320 ++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 app/src/test/java/net/osmtracker/osm/UploadToOpenStreetMapTaskTest.java diff --git a/app/src/test/java/net/osmtracker/osm/UploadToOpenStreetMapTaskTest.java b/app/src/test/java/net/osmtracker/osm/UploadToOpenStreetMapTaskTest.java new file mode 100644 index 000000000..49cf5f678 --- /dev/null +++ b/app/src/test/java/net/osmtracker/osm/UploadToOpenStreetMapTaskTest.java @@ -0,0 +1,320 @@ +package net.osmtracker.osm; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.robolectric.Shadows.shadowOf; + +import android.content.ContentValues; +import android.content.DialogInterface; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.os.Looper; + +import androidx.preference.PreferenceManager; +import androidx.test.core.app.ApplicationProvider; + +import net.osmtracker.OSMTracker; +import net.osmtracker.activity.OpenStreetMapUpload; +import net.osmtracker.db.TrackContentProvider; +import net.osmtracker.db.model.Track; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.android.controller.ContentProviderController; +import org.robolectric.android.util.concurrent.InlineExecutorService; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowAlertDialog; +import org.robolectric.shadows.ShadowContentResolver; +import org.robolectric.shadows.ShadowDialog; +import org.robolectric.shadows.ShadowLog; +import org.robolectric.shadows.ShadowPausedAsyncTask; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.util.List; + +import de.westnordost.osmapi.OsmConnection; +import de.westnordost.osmapi.common.errors.OsmAuthorizationException; +import de.westnordost.osmapi.traces.GpsTraceDetails; +import de.westnordost.osmapi.traces.GpsTracesApi; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 25) +public class UploadToOpenStreetMapTaskTest { + + private static final long TEST_TRACK_ID = 42L; + + private OpenStreetMapUpload activity; + private File gpxFile; + private ContentProviderController providerController; + + // --------------------------------------------------------------------------- + // Stub GpsTracesApi subclasses — used instead of Mockito to avoid Byte Buddy + // conflicts with Robolectric's instrumented class loader. + // --------------------------------------------------------------------------- + + private static class SuccessGpsTracesApi extends GpsTracesApi { + SuccessGpsTracesApi() { super(new OsmConnection("", "", "")); } + + @Override + public long create(String name, GpsTraceDetails.Visibility visibility, + String description, List tags, InputStream gpx) { + return 99L; + } + } + + private static class AuthErrorGpsTracesApi extends GpsTracesApi { + AuthErrorGpsTracesApi() { super(new OsmConnection("", "", "")); } + + @Override + public long create(String name, GpsTraceDetails.Visibility visibility, + String description, List tags, InputStream gpx) { + throw new OsmAuthorizationException(401, "Unauthorized", ""); + } + } + + private static class GenericErrorGpsTracesApi extends GpsTracesApi { + GenericErrorGpsTracesApi() { super(new OsmConnection("", "", "")); } + + @Override + public long create(String name, GpsTraceDetails.Visibility visibility, + String description, List tags, InputStream gpx) { + throw new RuntimeException("unexpected server error"); + } + } + + /** + * Subclass of the task that replaces GpsTracesApi with a stub, avoiding + * any real network calls. + */ + private static class StubUploadTask extends UploadToOpenStreetMapTask { + private final GpsTracesApi stubApi; + + StubUploadTask(OpenStreetMapUpload activity, String token, long trackId, + File gpxFile, String filename, String description, + String tags, Track.OSMVisibility visibility, + GpsTracesApi stubApi) { + super(activity, token, trackId, gpxFile, filename, description, tags, visibility); + this.stubApi = stubApi; + } + + @Override + protected GpsTracesApi createGpsTracesApi(OsmConnection connection) { + return stubApi; + } + } + + @Before + public void setUp() throws IOException { + ShadowLog.stream = System.out; + OpenStreetMapConstants.setDevelopmentMode(true); + + // AsyncTask must run synchronously so tests can check side-effects inline + ShadowPausedAsyncTask.overrideExecutor(new InlineExecutorService()); + + // ContentProvider backing so DataHelper.setTrackUploadDate has a valid target + providerController = Robolectric.buildContentProvider(TrackContentProvider.class) + .create(TrackContentProvider.AUTHORITY); + + ContentValues values = new ContentValues(); + values.put(TrackContentProvider.Schema.COL_ID, TEST_TRACK_ID); + values.put(TrackContentProvider.Schema.COL_START_DATE, System.currentTimeMillis()); + values.put(TrackContentProvider.Schema.COL_NAME, "Test Track"); + values.put(TrackContentProvider.Schema.COL_DESCRIPTION, "Test description"); + values.put(TrackContentProvider.Schema.COL_TAGS, "test"); + values.put(TrackContentProvider.Schema.COL_OSM_VISIBILITY, + Track.OSMVisibility.Private.toString()); + ApplicationProvider.getApplicationContext().getContentResolver() + .insert(TrackContentProvider.CONTENT_URI_TRACK, values); + + // Fake OAuth token so the host activity doesn't try to open the browser + PreferenceManager.getDefaultSharedPreferences( + ApplicationProvider.getApplicationContext()) + .edit() + .putString(OSMTracker.Preferences.KEY_OSM_OAUTH2_ACCESSTOKEN, "fake_token") + .apply(); + + // Build the host activity needed to show dialogs + Intent intent = new Intent(ApplicationProvider.getApplicationContext(), + OpenStreetMapUpload.class); + intent.putExtra(TrackContentProvider.Schema.COL_TRACK_ID, TEST_TRACK_ID); + activity = Robolectric.buildActivity(OpenStreetMapUpload.class, intent) + .create().start().resume().get(); + + // Real temp file — the stub API ignores the stream content + gpxFile = File.createTempFile("test_upload", ".gpx"); + gpxFile.deleteOnExit(); + } + + @After + public void tearDown() { + ShadowPausedAsyncTask.reset(); + if (providerController != null) { + providerController.shutdown(); + } + ShadowContentResolver.reset(); + if (gpxFile != null) { + gpxFile.delete(); + } + } + + @Test + public void constructor_nullDefaults_appliedCorrectly() throws Exception { + UploadToOpenStreetMapTask task = new UploadToOpenStreetMapTask( + activity, "token", TEST_TRACK_ID, gpxFile, + "file.gpx", null, null, null); + + Field descField = UploadToOpenStreetMapTask.class.getDeclaredField("description"); + descField.setAccessible(true); + assertEquals("test", descField.get(task)); + + Field tagsField = UploadToOpenStreetMapTask.class.getDeclaredField("tags"); + tagsField.setAccessible(true); + assertEquals("test", tagsField.get(task)); + + Field visField = UploadToOpenStreetMapTask.class.getDeclaredField("visibility"); + visField.setAccessible(true); + assertEquals(Track.OSMVisibility.Private, visField.get(task)); + } + + @Test + public void doInBackground_success_updatesUploadDateInDbAndShowsSuccessDialog() + throws Exception { + StubUploadTask task = new StubUploadTask( + activity, "fake_token", TEST_TRACK_ID, gpxFile, + "test.gpx", "desc", "tags", Track.OSMVisibility.Public, + new SuccessGpsTracesApi()); + task.execute(); + shadowOf(Looper.getMainLooper()).idle(); + + // Success dialog was shown + assertNotNull("Success dialog should be displayed", ShadowAlertDialog.getLatestAlertDialog()); + + // DataHelper.setTrackUploadDate updated COL_OSM_UPLOAD_DATE via ContentProvider + Uri trackUri = android.content.ContentUris.withAppendedId( + TrackContentProvider.CONTENT_URI_TRACK, TEST_TRACK_ID); + try (Cursor cursor = ApplicationProvider.getApplicationContext() + .getContentResolver().query(trackUri, null, null, null, null)) { + assertTrue("Track row should exist", cursor != null && cursor.moveToFirst()); + long uploadDate = cursor.getLong( + cursor.getColumnIndex(TrackContentProvider.Schema.COL_OSM_UPLOAD_DATE)); + assertTrue("COL_OSM_UPLOAD_DATE should be set after successful upload", + uploadDate > 0); + } + } + + @Test + public void doInBackground_gpxFileMissing_showsErrorDialogAndDoesNotSetUploadDate() { + // Use a non-existent file so FileInputStream throws IOException + File nonExistentFile = new File("/nonexistent/path/test.gpx"); + + UploadToOpenStreetMapTask task = new UploadToOpenStreetMapTask( + activity, "fake_token", TEST_TRACK_ID, nonExistentFile, + "test.gpx", "desc", "tags", Track.OSMVisibility.Private); + task.execute(); + shadowOf(Looper.getMainLooper()).idle(); + + // Error dialog was shown (resultCode = -1) + assertNotNull("Error dialog should be displayed", ShadowAlertDialog.getLatestAlertDialog()); + + // COL_OSM_UPLOAD_DATE must remain 0 (not set) when the file is missing + Uri trackUri = android.content.ContentUris.withAppendedId( + TrackContentProvider.CONTENT_URI_TRACK, TEST_TRACK_ID); + try (Cursor cursor = ApplicationProvider.getApplicationContext() + .getContentResolver().query(trackUri, null, null, null, null)) { + assertTrue(cursor != null && cursor.moveToFirst()); + long uploadDate = cursor.getLong( + cursor.getColumnIndex(TrackContentProvider.Schema.COL_OSM_UPLOAD_DATE)); + assertEquals("COL_OSM_UPLOAD_DATE should remain 0 when upload fails", 0L, uploadDate); + } + } + + @Test + public void doInBackground_authorizationException_showsAuthDialogAndDoesNotSetUploadDate() + throws Exception { + StubUploadTask task = new StubUploadTask( + activity, "fake_token", TEST_TRACK_ID, gpxFile, + "test.gpx", "desc", "tags", Track.OSMVisibility.Private, + new AuthErrorGpsTracesApi()); + task.execute(); + shadowOf(Looper.getMainLooper()).idle(); + + // Due to switch fall-through bug: ProgressDialog (index 0) + auth AlertDialog (index 1) + List shown = ShadowDialog.getShownDialogs(); + assertTrue("At least ProgressDialog + auth dialog must be shown", shown.size() >= 2); + assertNotNull("Auth dialog must be shown", shown.get(1)); + + // COL_OSM_UPLOAD_DATE must remain 0 on auth failure + Uri trackUri = android.content.ContentUris.withAppendedId( + TrackContentProvider.CONTENT_URI_TRACK, TEST_TRACK_ID); + try (Cursor cursor = ApplicationProvider.getApplicationContext() + .getContentResolver().query(trackUri, null, null, null, null)) { + assertTrue(cursor != null && cursor.moveToFirst()); + long uploadDate = cursor.getLong( + cursor.getColumnIndex(TrackContentProvider.Schema.COL_OSM_UPLOAD_DATE)); + assertEquals("COL_OSM_UPLOAD_DATE should remain 0 on auth failure", 0L, uploadDate); + } + } + + @Test + public void doInBackground_genericException_showsErrorDialogAndDoesNotSetUploadDate() + throws Exception { + StubUploadTask task = new StubUploadTask( + activity, "fake_token", TEST_TRACK_ID, gpxFile, + "test.gpx", "desc", "tags", Track.OSMVisibility.Private, + new GenericErrorGpsTracesApi()); + task.execute(); + shadowOf(Looper.getMainLooper()).idle(); + + assertNotNull("Error dialog should be displayed", ShadowAlertDialog.getLatestAlertDialog()); + + Uri trackUri = android.content.ContentUris.withAppendedId( + TrackContentProvider.CONTENT_URI_TRACK, TEST_TRACK_ID); + try (Cursor cursor = ApplicationProvider.getApplicationContext() + .getContentResolver().query(trackUri, null, null, null, null)) { + assertTrue(cursor != null && cursor.moveToFirst()); + long uploadDate = cursor.getLong( + cursor.getColumnIndex(TrackContentProvider.Schema.COL_OSM_UPLOAD_DATE)); + assertEquals("COL_OSM_UPLOAD_DATE should remain 0 on generic failure", 0L, uploadDate); + } + } + + @Test + public void onPostExecute_authError_yesButton_clearsStoredToken() throws Exception { + PreferenceManager.getDefaultSharedPreferences( + ApplicationProvider.getApplicationContext()) + .edit() + .putString(OSMTracker.Preferences.KEY_OSM_OAUTH2_ACCESSTOKEN, "stored_token") + .apply(); + + StubUploadTask task = new StubUploadTask( + activity, "stored_token", TEST_TRACK_ID, gpxFile, + "test.gpx", "desc", "tags", Track.OSMVisibility.Private, + new AuthErrorGpsTracesApi()); + task.execute(); + shadowOf(Looper.getMainLooper()).idle(); + + // Auth dialog is at index 1 (after the ProgressDialog at index 0) + List shown = ShadowDialog.getShownDialogs(); + android.app.AlertDialog authDialog = (android.app.AlertDialog) shown.get(1); + assertNotNull(authDialog); + + // Click YES to clear stored credentials + authDialog.getButton(DialogInterface.BUTTON_POSITIVE).performClick(); + shadowOf(Looper.getMainLooper()).idle(); + + assertFalse("OAuth token must be removed after clicking YES", + PreferenceManager.getDefaultSharedPreferences( + ApplicationProvider.getApplicationContext()) + .contains(OSMTracker.Preferences.KEY_OSM_OAUTH2_ACCESSTOKEN)); + } +} From c2e1f4c7f3541816093ac15e550040f169e49cfb Mon Sep 17 00:00:00 2001 From: Milton Barrera Date: Fri, 3 Apr 2026 15:05:08 -0600 Subject: [PATCH 3/3] restoring gradle.properties --- gradle.properties | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/gradle.properties b/gradle.properties index 239f452d5..918826a7c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,4 @@ android.enableJetifier=true android.nonFinalResIds=false android.nonTransitiveRClass=false -android.useAndroidX=true -org.gradle.java.home=/var/lib/flatpak/app/com.google.AndroidStudio/x86_64/stable/3faad27366894168095d798a08f2a63401e36c12459e1c9690528ec69fb49856/files/extra/jbr +android.useAndroidX=true \ No newline at end of file