diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..5be040e03 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms +# github: [labexp] +liberapay: OSMTracker diff --git a/app/src/main/java/net/osmtracker/OSMTracker.java b/app/src/main/java/net/osmtracker/OSMTracker.java index 3accb77c9..650bb0b62 100644 --- a/app/src/main/java/net/osmtracker/OSMTracker.java +++ b/app/src/main/java/net/osmtracker/OSMTracker.java @@ -100,7 +100,7 @@ public static final class Preferences { public final static String VAL_UI_ORIENTATION_LANDSCAPE = "landscape"; public final static String VAL_UI_ORIENTATION = VAL_UI_ORIENTATION_NONE; - public final static String VAL_UI_MAP_TILE_MAPNIK = "MAPNIK"; + public final static String VAL_UI_MAP_TILE_MAPNIK = "Mapnik"; //default values for repository settings public final static String VAL_GITHUB_USERNAME = "labexp"; @@ -187,4 +187,4 @@ public static final class Preferences { public static final class Devices { public static final String NEXUS_S = "Nexus S"; } -} \ No newline at end of file +} diff --git a/app/src/main/java/net/osmtracker/activity/DisplayTrackMap.java b/app/src/main/java/net/osmtracker/activity/DisplayTrackMap.java index 2d178e419..b86bb06c0 100644 --- a/app/src/main/java/net/osmtracker/activity/DisplayTrackMap.java +++ b/app/src/main/java/net/osmtracker/activity/DisplayTrackMap.java @@ -1,6 +1,7 @@ package net.osmtracker.activity; import android.app.Activity; +import android.content.ContentValues; import android.content.ContentUris; import android.content.Intent; import android.content.SharedPreferences; @@ -12,18 +13,24 @@ import android.os.Handler; import android.util.DisplayMetrics; import android.util.Log; +import android.view.View; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.MotionEvent; +import android.widget.ImageView; import androidx.preference.PreferenceManager; import net.osmtracker.OSMTracker; import net.osmtracker.R; +import net.osmtracker.db.DataHelper; import net.osmtracker.db.TrackContentProvider; import net.osmtracker.overlay.WayPointsOverlay; import net.osmtracker.overlay.Polylines; +import net.osmtracker.service.gps.GPSLoggerConnectionListener; +import net.osmtracker.service.gps.GPSLoggerServiceConnection; +import net.osmtracker.service.gps.GPSLogger; import org.osmdroid.api.IMapController; import org.osmdroid.config.Configuration; @@ -50,7 +57,9 @@ * @author Viesturs Zarins * */ -public class DisplayTrackMap extends Activity { +public class DisplayTrackMap extends Activity + implements GPSLoggerConnectionListener +{ private static final String TAG = DisplayTrackMap.class.getSimpleName(); @@ -146,6 +155,11 @@ public class DisplayTrackMap extends Activity { */ private boolean zoomedToTrackAlready = false; + /** + * Are we currently tracking? + */ + private boolean isTracking; + /** * the last position we know */ @@ -173,6 +187,88 @@ public class DisplayTrackMap extends Activity { */ private SharedPreferences prefs = null; + private ImageView startStopButton = null; + + private GPSLogger gpsLogger; + + private GPSLoggerServiceConnection gpsLoggerConnection; + + private synchronized GPSLoggerServiceConnection getGpsLoggerConnection() { + if(gpsLoggerConnection == null) + gpsLoggerConnection = new GPSLoggerServiceConnection(this); + return gpsLoggerConnection; + } + + private synchronized void unbindGpsLoggerConnection() { + if(gpsLoggerConnection != null) { + unbindService(gpsLoggerConnection); + gpsLoggerConnection=null; + } + } + + @Override + public long getCurrentTrackId() { + return currentTrackId; + } + + @Override + public void setGpsLogger(GPSLogger l) { + gpsLogger = l; + if(l == null) + isTracking = false; + updateTrackingIcon(); + } + + @Override + public GPSLogger getGpsLogger() { + return gpsLogger; + } + + private boolean isTracking() { + if(gpsLogger != null && + gpsLogger.isTracking()) + isTracking = true; + return isTracking; + } + + private void updateTrackingIcon() { + if(startStopButton != null) + startStopButton.setImageResource(isTracking() ? + R.drawable.started : + R.drawable.stopped); + } + + private void toggleStartStop() { + DataHelper dataHelper = new DataHelper(this); + boolean forceTracking = false; + if(isTracking()) { + // currently tracking => stop it + Intent intent = new Intent(OSMTracker.INTENT_STOP_TRACKING); + intent.setPackage(getPackageName()); + sendBroadcast(intent); + + isTracking = false; + dataHelper.stopTracking(currentTrackId); + } else { + // currently not tracking => start it + Intent intent = new Intent(this, GPSLogger.class); + intent.putExtra(TrackContentProvider.Schema.COL_TRACK_ID, currentTrackId); + startService(intent); + bindService(intent, getGpsLoggerConnection(), 0); + + // update DB, copied from TrackManager + ContentValues values = new ContentValues(); + values.put(TrackContentProvider.Schema.COL_ACTIVE, + TrackContentProvider.Schema.VAL_TRACK_ACTIVE); + getContentResolver().update(TrackContentProvider.CONTENT_URI_TRACK, values, + TrackContentProvider.Schema.COL_ID + " = ?", + new String[] {Long.toString(currentTrackId)}); + + isTracking = true; + } + updateTrackingIcon(); + } + @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -183,6 +279,9 @@ public void onCreate(Bundle savedInstanceState) { setContentView(R.layout.displaytrackmap); currentTrackId = getIntent().getExtras().getLong(TrackContentProvider.Schema.COL_TRACK_ID); + long activeTrackId = + DataHelper.getActiveTrackId(getContentResolver()); + isTracking = currentTrackId == activeTrackId; setTitle(getTitle() + ": #" + currentTrackId); // Initialize OSM view @@ -232,8 +331,23 @@ public void onChange(boolean selfChange) { osmViewController.animateTo(currentPosition,CENTER_DEFAULT_ZOOM_LEVEL, ANIMATION_DURATION_MS); } }); + startStopButton = + (ImageView)findViewById(R.id.displaytrackmap_imgStartStop); + if(activeTrackId == currentTrackId || + activeTrackId == TrackManager.TRACK_ID_NO_TRACK) { + updateTrackingIcon(); + startStopButton.setOnClickListener(view -> toggleStartStop()); + } else { + startStopButton.setVisibility(View.INVISIBLE); + } } + @Override + public void onDestroy() { + super.onDestroy(); + unbindGpsLoggerConnection(); + } + /** * Sets the map tile provider according to the user's demands in the settings. */ diff --git a/app/src/main/java/net/osmtracker/activity/TrackLogger.java b/app/src/main/java/net/osmtracker/activity/TrackLogger.java index df8bbfdf6..97ada85d3 100644 --- a/app/src/main/java/net/osmtracker/activity/TrackLogger.java +++ b/app/src/main/java/net/osmtracker/activity/TrackLogger.java @@ -46,6 +46,7 @@ import net.osmtracker.listener.PressureListener; import net.osmtracker.listener.SensorListener; import net.osmtracker.receiver.MediaButtonReceiver; +import net.osmtracker.service.gps.GPSLoggerConnectionListener; import net.osmtracker.service.gps.GPSLogger; import net.osmtracker.service.gps.GPSLoggerServiceConnection; import net.osmtracker.util.CustomLayoutsUtils; @@ -67,7 +68,8 @@ * @author Nicolas Guillaumin * */ -public class TrackLogger extends Activity { +public class TrackLogger extends Activity + implements GPSLoggerConnectionListener { private static final String TAG = TrackLogger.class.getSimpleName(); @@ -332,35 +334,38 @@ protected void onResume() { OSMTracker.Preferences.VAL_GPS_CHECKSTARTUP)) { checkGPSProvider(); } - - // Register GPS status update for upper controls - ((GpsStatusRecord) findViewById(R.id.gpsStatus)).requestLocationUpdates(true); - - // Start GPS Logger service - startService(gpsLoggerServiceIntent); - - // Bind to GPS service. - // We can't use BIND_AUTO_CREATE here, because when we'll ubound - // later, we want to keep the service alive in background - bindService(gpsLoggerServiceIntent, gpsLoggerConnection, 0); + if(currentTrackId == + DataHelper.getActiveTrackId(getContentResolver())) { + // Register GPS status update for upper controls + ((GpsStatusRecord) findViewById(R.id.gpsStatus)).requestLocationUpdates(true); + + // Start GPS Logger service + startService(gpsLoggerServiceIntent); + + // Bind to GPS service. + // We can't use BIND_AUTO_CREATE here, because when we'll ubound + // later, we want to keep the service alive in background + bindService(gpsLoggerServiceIntent, gpsLoggerConnection, 0); - // connect the sensor listener - sensorListener.register(this); + // connect the sensor listener + sensorListener.register(this); - // connect the pressure listener - pressureListener.register(this, prefs.getBoolean(OSMTracker.Preferences.KEY_USE_BAROMETER,OSMTracker.Preferences.VAL_USE_BAROMETER)); + // connect the pressure listener + pressureListener.register(this, prefs.getBoolean(OSMTracker.Preferences.KEY_USE_BAROMETER,OSMTracker.Preferences.VAL_USE_BAROMETER)); - setEnabledActionButtons(buttonsEnabled); - if(!buttonsEnabled){ - Toast.makeText(this, R.string.tracklogger_waiting_gps, Toast.LENGTH_LONG).show(); - } + setEnabledActionButtons(buttonsEnabled); + if(!buttonsEnabled){ + Toast.makeText(this, R.string.tracklogger_waiting_gps, Toast.LENGTH_LONG).show(); + } - mAudioManager.registerMediaButtonEventReceiver(mediaButtonReceiver); - - //save the layout file name if it change, in tags array - String layoutName = CustomLayoutsUtils.getCurrentLayoutName(getApplicationContext()); - layoutNameTags.add(layoutName); + mAudioManager.registerMediaButtonEventReceiver(mediaButtonReceiver); + //save the layout file name if it change, in tags array + String layoutName = CustomLayoutsUtils.getCurrentLayoutName(getApplicationContext()); + layoutNameTags.add(layoutName); + } else { + ((GpsStatusRecord) findViewById(R.id.gpsStatus)).showStopped(); + } super.onResume(); } @@ -396,8 +401,14 @@ protected void onPause() { if (gpsLogger != null) { if (!gpsLogger.isTracking()) { Log.v(TAG, "Service is not tracking, trying to stopService()"); - unbindService(gpsLoggerConnection); - stopService(gpsLoggerServiceIntent); + try { + unbindService(gpsLoggerConnection); + } catch(Exception e) { + } + try { + stopService(gpsLoggerServiceIntent); + } catch(Exception e) { + } } else { unbindService(gpsLoggerConnection); } diff --git a/app/src/main/java/net/osmtracker/activity/TrackManager.java b/app/src/main/java/net/osmtracker/activity/TrackManager.java index 4f9f23189..7ecb67de2 100644 --- a/app/src/main/java/net/osmtracker/activity/TrackManager.java +++ b/app/src/main/java/net/osmtracker/activity/TrackManager.java @@ -76,7 +76,7 @@ public class TrackManager extends AppCompatActivity private static final String PREV_VISIBLE = "prev_visible"; /** Constant used if no track is active (-1)*/ - private static final long TRACK_ID_NO_TRACK = -1; + public static final long TRACK_ID_NO_TRACK = -1; // The active track being recorded, if any, or {TRACK_ID_NO_TRACK}; // value is updated in {@link #onResume()} diff --git a/app/src/main/java/net/osmtracker/layout/GpsStatusRecord.java b/app/src/main/java/net/osmtracker/layout/GpsStatusRecord.java index 3afd60bc5..ba854b145 100644 --- a/app/src/main/java/net/osmtracker/layout/GpsStatusRecord.java +++ b/app/src/main/java/net/osmtracker/layout/GpsStatusRecord.java @@ -128,6 +128,11 @@ public void requestLocationUpdates(boolean request) { } } + public void showStopped() { + TextView tvAccuracy = findViewById(R.id.gpsstatus_record_tvAccuracy); + tvAccuracy.setText(R.string.various_stopped); + } + private GnssStatus.Callback mStatusCallback = new GnssStatus.Callback() { @Override public void onSatelliteStatusChanged(GnssStatus status) { diff --git a/app/src/main/java/net/osmtracker/service/gps/GPSLoggerConnectionListener.java b/app/src/main/java/net/osmtracker/service/gps/GPSLoggerConnectionListener.java new file mode 100644 index 000000000..1ced67e10 --- /dev/null +++ b/app/src/main/java/net/osmtracker/service/gps/GPSLoggerConnectionListener.java @@ -0,0 +1,22 @@ +package net.osmtracker.service.gps; + +import android.view.View; +import android.content.Intent; +import android.app.Activity; + +public interface GPSLoggerConnectionListener { + void setGpsLogger(GPSLogger l); + + long getCurrentTrackId(); + + default void setEnabledActionButtons(boolean enabled) { } + + GPSLogger getGpsLogger(); + + /* Stuff supplied by Activity / AppCompatActivity */ + String getPackageName(); + + void sendBroadcast(Intent intent); + + T findViewById (int id); +} diff --git a/app/src/main/java/net/osmtracker/service/gps/GPSLoggerServiceConnection.java b/app/src/main/java/net/osmtracker/service/gps/GPSLoggerServiceConnection.java index e7d22f67b..0fb275f07 100644 --- a/app/src/main/java/net/osmtracker/service/gps/GPSLoggerServiceConnection.java +++ b/app/src/main/java/net/osmtracker/service/gps/GPSLoggerServiceConnection.java @@ -2,7 +2,6 @@ import net.osmtracker.OSMTracker; import net.osmtracker.R; -import net.osmtracker.activity.TrackLogger; import net.osmtracker.layout.GpsStatusRecord; import net.osmtracker.db.TrackContentProvider; @@ -20,11 +19,11 @@ public class GPSLoggerServiceConnection implements ServiceConnection { /** - * Reference to TrackLogger activity + * Reference to client activity */ - private TrackLogger activity; + private GPSLoggerConnectionListener activity; - public GPSLoggerServiceConnection(TrackLogger tl) { + public GPSLoggerServiceConnection(GPSLoggerConnectionListener tl) { activity = tl; } diff --git a/app/src/main/res/drawable-mdpi/started.png b/app/src/main/res/drawable-mdpi/started.png new file mode 100644 index 000000000..6181d034d Binary files /dev/null and b/app/src/main/res/drawable-mdpi/started.png differ diff --git a/app/src/main/res/drawable-mdpi/stopped.png b/app/src/main/res/drawable-mdpi/stopped.png new file mode 100644 index 000000000..327f476e0 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/stopped.png differ diff --git a/app/src/main/res/layout/displaytrackmap.xml b/app/src/main/res/layout/displaytrackmap.xml index ca862d5d7..2c135c8f6 100644 --- a/app/src/main/res/layout/displaytrackmap.xml +++ b/app/src/main/res/layout/displaytrackmap.xml @@ -21,18 +21,31 @@ app:layout_constraintVertical_bias="1.0"/> + + - \ No newline at end of file + diff --git a/app/src/main/res/values/accessibility.xml b/app/src/main/res/values/accessibility.xml index a5f171e2b..ffffc7d33 100644 --- a/app/src/main/res/values/accessibility.xml +++ b/app/src/main/res/values/accessibility.xml @@ -6,6 +6,7 @@ Zoom in Zoom out Zoom center + Start / Stop Satellite signal strength indicator Record in progress indicator diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9f18028c5..881c14616 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -183,6 +183,7 @@ Export process finished successfully Position not available {0} {1} / {2} {3} + Stopped OpenStreetMap track display diff --git a/app/src/test/java/net/osmtracker/activity/StartStopOnMapTest.java b/app/src/test/java/net/osmtracker/activity/StartStopOnMapTest.java new file mode 100644 index 000000000..7eceb74b4 --- /dev/null +++ b/app/src/test/java/net/osmtracker/activity/StartStopOnMapTest.java @@ -0,0 +1,316 @@ +package net.osmtracker.activity; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.InvocationTargetException; +import android.database.sqlite.SQLiteDatabase; +import android.content.ContentValues; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.widget.ImageView; +import android.app.Application; +import android.content.ComponentName; + +import org.junit.Before; +import org.junit.After; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.annotation.Config; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.shadow.api.Shadow; +import org.robolectric.shadows.ShadowLog; +import org.robolectric.shadows.ShadowApplication; +import org.robolectric.shadows.ShadowService; +import org.robolectric.shadows.ShadowLooper; + +import androidx.preference.PreferenceManager; + +import net.osmtracker.OSMTracker; +import net.osmtracker.R; +import net.osmtracker.db.TrackContentProvider; +import net.osmtracker.service.gps.GPSLogger; +import net.osmtracker.service.gps.GPSLoggerServiceConnection; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.any; +import org.mockito.InOrder; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 25) +public class StartStopOnMapTest { + + private DisplayTrackMap displayTrackMap; + private Context context; + private ShadowApplication shadowApplication; + private ContentResolver resolver; + + private static final long TEST_TRACK_ID = 42L; + + private static final String TAG = "StartStopOnMapTest"; + + private ImageView mockStartStopButton; + private InOrder startStopButtonInOrder; + + private ShadowService shadowService; + private ComponentName componentName; + + @Before + public void setUp() + throws NoSuchFieldException, IllegalAccessException, + NoSuchMethodException, InvocationTargetException + { + // Comment out to redirect log output to stdout: + // ShadowLog.stream = System.out; + + // application for creating activities, etc. + Application app = RuntimeEnvironment.getApplication(); + context = app; + + // shadow application, with robolectric-specific + // methods for finding out whether a service has been + // started + shadowApplication = Shadow.extract(app); + + resolver = context.getContentResolver(); + + // Set up a default track for this test + ContentValues values = new ContentValues(); + values.put(TrackContentProvider.Schema.COL_START_DATE, + System.currentTimeMillis()); + values.put(TrackContentProvider.Schema.COL_ID, + TEST_TRACK_ID); + var uri = resolver.insert(TrackContentProvider.CONTENT_URI_TRACK, values); + assertNotNull("Track should have been created", uri); + + // Set up default preferences + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .putString(OSMTracker.Preferences.KEY_GPS_LOGGING_INTERVAL, "0") + .putString(OSMTracker.Preferences.KEY_GPS_LOGGING_MIN_DISTANCE, "0") + .commit(); + + // Create activity with test track ID + Intent intent = new Intent(); + intent.putExtra(TrackContentProvider.Schema.COL_TRACK_ID, + TEST_TRACK_ID); + displayTrackMap = Robolectric + .buildActivity(DisplayTrackMap.class, intent) + .create().get(); + + componentName = new ComponentName(context, GPSLogger.class); + + // spy the startService call + displayTrackMap = spy(displayTrackMap); + doAnswer(invocation -> { + Intent expectedIntent = invocation.getArgument(0); + GPSLogger service = Robolectric + .buildService(GPSLogger.class, expectedIntent) + .create() + .get(); + + // Create the real binder from the service + GPSLogger.GPSLoggerBinder binder = service.new GPSLoggerBinder(); + shadowApplication + .setComponentNameAndServiceForBindService(componentName, + binder); + invocation.callRealMethod(); + return null; + }).when(displayTrackMap).startService(any(Intent.class)); + + // Create a mock start/stop button + mockStartStopButton = mock(ImageView.class); + setInternalState(displayTrackMap, "startStopButton", mockStartStopButton); + startStopButtonInOrder = inOrder(mockStartStopButton); + } + + @After + public void tearDown() { + // Close all databases opened by SQLiteOpenHelper + resolver = null; + displayTrackMap = null; + context = null; + System.gc(); + try { + // allow finalizers to run + Thread.sleep(10); + } catch(InterruptedException e) { + } + SQLiteDatabase.releaseMemory(); + } + + @Test + public void testToggleStartStop_WhenNotTracking_ShouldStartTracking() + throws NoSuchFieldException, NoSuchMethodException, + InvocationTargetException, IllegalAccessException { + // Given: currently not tracking + assertFalse("Initial state should be 'not tracking'", + isTracking(displayTrackMap)); + + // When: toggle is called + toggleStartStop(displayTrackMap); + + // Then: should have attempted to start the service + Intent expectedIntent = shadowApplication.getNextStartedService(); + assertTrue("Service should have been started", + expectedIntent != null); + assertTrue("Started service should be GPSLogger", + expectedIntent.getComponent().getClassName() + .contains("GPSLogger")); + assertTrue("Intent should contain track ID", + expectedIntent + .getLongExtra(TrackContentProvider.Schema.COL_TRACK_ID, -1L) == + TEST_TRACK_ID); + + // allow Android framework to process + ShadowLooper.idleMainLooper(); + + GPSLogger service = + (GPSLogger) getInternalState(displayTrackMap, "gpsLogger"); + assertNotNull("GPS Logger should not be null", service); + shadowService = Shadow.extract(service); + + assertTrue("isTracking flag should be true after starting", + isTracking(displayTrackMap)); + assertFalse("Service should not yet have been stopped", + shadowService.isStoppedBySelf()); + + // button should show "started" color: + startStopButtonInOrder + .verify(mockStartStopButton, atLeastOnce()) + .setImageResource(R.drawable.started); + + // Now try stop tracking + toggleStartStop(displayTrackMap); + ShadowLooper.idleMainLooper(100); + assertTrue("Service should have been stopped", + shadowService.isStoppedBySelf()); + + // now that we've established that the service + // did initiated a stopSelf, help robolectric along, and + // actually perform what this entails + GPSLoggerServiceConnection cnx = (GPSLoggerServiceConnection) + getInternalState(displayTrackMap, + "gpsLoggerConnection"); + cnx.onServiceDisconnected(componentName); + + assertFalse("isTracking flag should be false after stopping", + isTracking(displayTrackMap)); + // button should show "stopped" color: + startStopButtonInOrder + .verify(mockStartStopButton, atLeastOnce()) + .setImageResource(R.drawable.stopped); + } + + @Test + public void testIsTracking_WhenGpsLoggerIsNull_ShouldReturnFalse() + throws NoSuchFieldException, NoSuchMethodException, + InvocationTargetException, IllegalAccessException { + // Given: gpsLogger is null + setInternalState(displayTrackMap, "gpsLogger", null); + + // When: isTracking() is called + boolean result = isTracking(displayTrackMap); + + // Then: should return false + assertFalse("isTracking() should return false when gpsLogger is null", result); + } + + @Test + public void testSetGpsLogger_WhenNull_ShouldSetIsTrackingFalse() + throws NoSuchFieldException, NoSuchMethodException, + InvocationTargetException, IllegalAccessException { + // Given: isTracking is true + setInternalState(displayTrackMap, "isTracking", true); + + // When: setGpsLogger(null) is called + displayTrackMap.setGpsLogger(null); + + // Then: isTracking should be set to false + assertFalse("isTracking should be false when gpsLogger is set to null", + isTracking(displayTrackMap)); + } + + @Test + public void testUpdateTrackingIcon_WhenButtonNull_ShouldNotCrash() + throws NoSuchFieldException, NoSuchMethodException, + InvocationTargetException, IllegalAccessException + { + // Given: button is null + setInternalState(displayTrackMap, "startStopButton", null); + setInternalState(displayTrackMap, "isTracking", true); + + // When: updateTrackingIcon() is called + // Then: should not crash + updateTrackingIcon(displayTrackMap); + } + + // Call private methods + private void toggleStartStop(DisplayTrackMap displayTrackMap) + throws NoSuchMethodException, InvocationTargetException, + IllegalAccessException + { + invokeInternalMethod(displayTrackMap, "toggleStartStop"); + } + + private boolean isTracking(DisplayTrackMap displayTrackMap) + throws NoSuchMethodException, InvocationTargetException, + IllegalAccessException + { + return (Boolean) invokeInternalMethod(displayTrackMap, + "isTracking"); + } + + private void updateTrackingIcon(DisplayTrackMap displayTrackMap) + throws NoSuchMethodException, InvocationTargetException, + IllegalAccessException + { + invokeInternalMethod(displayTrackMap, "updateTrackingIcon"); + } + + // Helper methods + private void setInternalState(Object target, String fieldName, + Object value) + throws NoSuchFieldException, IllegalAccessException + { + Field field; + if (target instanceof Class) { + field = ((Class) target).getDeclaredField(fieldName); + } else { + field = target.getClass().getDeclaredField(fieldName); + } + field.setAccessible(true); + field.set(target instanceof Class ? null : target, value); + } + + private Object getInternalState(Object target, String fieldName) + throws NoSuchFieldException, IllegalAccessException { + Field field; + if (target instanceof Class) { + field = ((Class) target).getDeclaredField(fieldName); + } else { + field = target.getClass().getDeclaredField(fieldName); + } + field.setAccessible(true); + return field.get(target instanceof Class ? null : target); + } + + private Object invokeInternalMethod(Object target, String getterName) + throws NoSuchMethodException, InvocationTargetException, + IllegalAccessException { + Method getter = target.getClass() + .getDeclaredMethod(getterName); + getter.setAccessible(true); + return getter.invoke(target); + } +}