Index: /applications/editors/josm/plugins/mapillary/src/org/openstreetmap/josm/plugins/mapillary/utils/MapillaryURL.java
===================================================================
--- /applications/editors/josm/plugins/mapillary/src/org/openstreetmap/josm/plugins/mapillary/utils/MapillaryURL.java	(revision 31830)
+++ /applications/editors/josm/plugins/mapillary/src/org/openstreetmap/josm/plugins/mapillary/utils/MapillaryURL.java	(revision 31831)
@@ -1,2 +1,3 @@
+// License: GPL. For details, see LICENSE file.
 package org.openstreetmap.josm.plugins.mapillary.utils;
 
@@ -23,4 +24,9 @@
   }
 
+  /**
+   * Gives you the URL for the online editor of a specific mapillary image.
+   * @param key the key of the image to which you want to link
+   * @return the URL of the online editor for the image with the given image key
+   */
   public static URL browseEditURL(String key) {
     if (key == null || !key.matches("[a-zA-Z0-9\\-_]{22}")) {
@@ -30,4 +36,9 @@
   }
 
+  /**
+   * Gives you the URL for the online viewer of a specific mapillary image.
+   * @param key the key of the image to which you want to link
+   * @return the URL of the online viewer for the image with the given image key
+   */
   public static URL browseImageURL(String key) {
     if (key == null || !key.matches("[a-zA-Z0-9\\-_]{22}")) {
@@ -37,11 +48,21 @@
   }
 
+  /**
+   * @return the URL where the user can view all uploaded images that are not yet published
+   */
   public static URL browseUploadImageURL() {
-    return string2URL(BASE_WEBSITE_URL + "map/upload/im");
+    return string2URL(BASE_WEBSITE_URL + "map/upload/im/");
   }
 
+  /**
+   * Gives you the URL which the user should visit to initiate the OAuth authentication process
+   * @param redirectURI the URI to which the user will be redirected when the authentication is finished
+   * @return the URL that the user should visit to start the OAuth authentication
+   */
   public static URL connectURL(String redirectURI) {
     HashMap<String, String> parts = new HashMap<>();
-    parts.put("redirect_uri", redirectURI);
+    if (redirectURI != null && redirectURI.length() >= 1) {
+      parts.put("redirect_uri", redirectURI);
+    }
     parts.put("response_type", "token");
     parts.put("scope", "user:read public:upload public:write");
@@ -49,4 +70,11 @@
   }
 
+  /**
+   * Gives you the API-URL where you get 20 images within the given bounds.
+   * For more than 20 images you have to use different URLs with different page numbers.
+   * @param bounds the bounds in which you want to search for images
+   * @param page number of the page to retrieve from the API
+   * @return the API-URL which gives you the images in the given bounds as JSON
+   */
   public static URL searchImageURL(Bounds bounds, int page) {
     HashMap<String, String> parts = new HashMap<>();
@@ -57,4 +85,11 @@
   }
 
+  /**
+   * Gives you the API-URL where you get 10 sequences within the given bounds.
+   * For more than 10 sequences you have to use different URLs with different page numbers.
+   * @param bounds the bounds in which you want to search for sequences
+   * @param page number of the page to retrieve from the API
+   * @return the API-URL which gives you the sequences in the given bounds as JSON
+   */
   public static URL searchSequenceURL(Bounds bounds, int page) {
     HashMap<String, String> parts = new HashMap<>();
@@ -65,4 +100,11 @@
   }
 
+  /**
+   * Gives you the API-URL where you get the traffic signs for 20 images within the given bounds.
+   * For the signs from more than 20 images you have to use different URLs with different page numbers.
+   * @param bounds the bounds in which you want to search for traffic signs
+   * @param page number of the page to retrieve from the API
+   * @return the API-URL which gives you the traffic signs in the given bounds as JSON
+   */
   public static URL searchTrafficSignURL(Bounds bounds, int page) {
     HashMap<String, String> parts = new HashMap<>();
@@ -73,21 +115,39 @@
   }
 
+  /**
+   * @return the URL where you'll find the upload secrets as JSON
+   */
   public static URL uploadSecretsURL() {
     return string2URL(BASE_API_URL + "me/uploads/secrets/" + queryString(null));
   }
 
+  /**
+   * @return the URL where you'll find information about the user account as JSON
+   */
   public static URL userURL() {
     return string2URL(BASE_API_URL + "me/" + queryString(null));
   }
 
+  /**
+   * Adds the given {@link Bounds} to a {@link Map} that contains the parts of a query string.
+   * @param parts the parts of a query string
+   * @param bounds the bounds that will be added to the query string
+   */
   private static void putBoundsInQueryStringParts(Map<String, String> parts, Bounds bounds) {
-    parts.put("min_lat", String.format(Locale.UK, "%f", bounds.getMin().lat()));
-    parts.put("max_lat", String.format(Locale.UK, "%f", bounds.getMax().lat()));
-    parts.put("min_lon", String.format(Locale.UK, "%f", bounds.getMin().lon()));
-    parts.put("max_lon", String.format(Locale.UK, "%f", bounds.getMax().lon()));
+    if (bounds != null) {
+      parts.put("min_lat", String.format(Locale.UK, "%f", bounds.getMin().lat()));
+      parts.put("max_lat", String.format(Locale.UK, "%f", bounds.getMax().lat()));
+      parts.put("min_lon", String.format(Locale.UK, "%f", bounds.getMin().lon()));
+      parts.put("max_lon", String.format(Locale.UK, "%f", bounds.getMax().lon()));
+    }
   }
 
+  /**
+   * Builds a query string from it's parts that are supplied as a {@link Map}
+   * @param parts the parts of the query string
+   * @return the constructed query string (including a leading ?)
+   */
   private static String queryString(Map<String, String> parts) {
-    StringBuilder ret = new StringBuilder().append("?client_id=").append(CLIENT_ID);
+    StringBuilder ret = new StringBuilder("?client_id=").append(CLIENT_ID);
     if (parts != null) {
       for (Entry<String, String> entry : parts.entrySet()) {
@@ -105,9 +165,16 @@
   }
 
+  /**
+   * Converts a {@link String} into a {@link URL} without throwing a {@link MalformedURLException}.
+   * Instead such an exception will lead to an {@link Main#error(Throwable)}.
+   * So you should be very confident that your URL is well-formed when calling this method.
+   * @param string the String describing the URL
+   * @return the URL that is constructed from the given string
+   */
   private static URL string2URL(String string) {
     try {
       return new URL(string);
     } catch (MalformedURLException e) {
-      Main.error("The MapillaryAPI class produces malformed URLs!", e);
+      Main.error(new Exception("The "+MapillaryURL.class.getSimpleName()+" class produces malformed URLs!", e));
       return null;
     }
Index: /applications/editors/josm/plugins/mapillary/test/unit/org/openstreetmap/josm/plugins/mapillary/utils/MapillaryURLTest.java
===================================================================
--- /applications/editors/josm/plugins/mapillary/test/unit/org/openstreetmap/josm/plugins/mapillary/utils/MapillaryURLTest.java	(revision 31831)
+++ /applications/editors/josm/plugins/mapillary/test/unit/org/openstreetmap/josm/plugins/mapillary/utils/MapillaryURLTest.java	(revision 31831)
@@ -0,0 +1,140 @@
+package org.openstreetmap.josm.plugins.mapillary.utils;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.fail;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.net.MalformedURLException;
+import java.net.URL;
+
+import org.junit.Test;
+import org.openstreetmap.josm.data.Bounds;
+
+public class MapillaryURLTest {
+  @Test
+  public void testBrowseEditURL() throws MalformedURLException {
+    assertEquals(
+        new URL("https://www.mapillary.com/map/e/1234567890123456789012"),
+        MapillaryURL.browseEditURL("1234567890123456789012")
+    );
+    try {
+      MapillaryURL.browseEditURL(null);
+      fail();
+    } catch (IllegalArgumentException e) {}
+    try {
+      MapillaryURL.browseEditURL("123456789012345678901");
+      fail();
+    } catch (IllegalArgumentException e) {}
+    try {
+      MapillaryURL.browseEditURL("123456789012345678901+");
+      fail();
+    } catch (IllegalArgumentException e) {}
+  }
+
+  @Test
+  public void testBrowseImageURL() throws MalformedURLException {
+    assertEquals(
+        new URL("https://www.mapillary.com/map/im/1234567890123456789012"),
+        MapillaryURL.browseImageURL("1234567890123456789012")
+    );
+    try {
+      MapillaryURL.browseImageURL(null);
+      fail();
+    } catch (IllegalArgumentException e) {}
+    try {
+      MapillaryURL.browseImageURL("123456789012345678901");
+      fail();
+    } catch (IllegalArgumentException e) {}
+    try {
+      MapillaryURL.browseImageURL("123456789012345678901+");
+      fail();
+    } catch (IllegalArgumentException e) {}
+  }
+
+  @Test
+  public void testBrowseUploadImageURL() throws MalformedURLException {
+    assertEquals(new URL("https://www.mapillary.com/map/upload/im/"), MapillaryURL.browseUploadImageURL());
+  }
+
+  @Test
+  public void testConnectURL() throws MalformedURLException {
+    assertEquals(
+        new URL("https://www.mapillary.com/connect/?client_id=T1Fzd20xZjdtR0s1VDk5OFNIOXpYdzoxNDYyOGRkYzUyYTFiMzgz&scope=user%3Aread+public%3Aupload+public%3Awrite&response_type=token&redirect_uri=http%3A%2F%2Fredirect-host%2F%C3%A4"),
+        MapillaryURL.connectURL("http://redirect-host/ä")
+    );
+    assertEquals(
+        new URL("https://www.mapillary.com/connect/?client_id=T1Fzd20xZjdtR0s1VDk5OFNIOXpYdzoxNDYyOGRkYzUyYTFiMzgz&scope=user%3Aread+public%3Aupload+public%3Awrite&response_type=token"),
+        MapillaryURL.connectURL(null)
+    );
+    assertEquals(
+        new URL("https://www.mapillary.com/connect/?client_id=T1Fzd20xZjdtR0s1VDk5OFNIOXpYdzoxNDYyOGRkYzUyYTFiMzgz&scope=user%3Aread+public%3Aupload+public%3Awrite&response_type=token"),
+        MapillaryURL.connectURL("")
+    );
+  }
+
+  @Test
+  public void testSearchImageURL() throws MalformedURLException {
+    assertEquals(
+        new URL("https://a.mapillary.com/v2/search/im/?client_id=T1Fzd20xZjdtR0s1VDk5OFNIOXpYdzoxNDYyOGRkYzUyYTFiMzgz&min_lon=2.220000&max_lat=3.333000&max_lon=4.444400&limit=20&page=42&min_lat=1.100000"),
+        MapillaryURL.searchImageURL(new Bounds(1.1, 2.22, 3.333, 4.4444), 42)
+    );
+    assertEquals(
+        new URL("https://a.mapillary.com/v2/search/im/?client_id=T1Fzd20xZjdtR0s1VDk5OFNIOXpYdzoxNDYyOGRkYzUyYTFiMzgz&limit=20&page=-73"),
+        MapillaryURL.searchImageURL(null, -73)
+    );
+  }
+
+  @Test
+  public void testSearchSequenceURL() throws MalformedURLException {
+    assertEquals(
+        new URL("https://a.mapillary.com/v2/search/s/?client_id=T1Fzd20xZjdtR0s1VDk5OFNIOXpYdzoxNDYyOGRkYzUyYTFiMzgz&min_lon=-66.666666&max_lat=77.777778&max_lon=88.888889&limit=10&page=42&min_lat=-55.555550"),
+        MapillaryURL.searchSequenceURL(new Bounds(-55.55555, -66.666666, 77.7777777, 88.88888888, false), 42)
+    );
+    assertEquals(
+        new URL("https://a.mapillary.com/v2/search/s/?client_id=T1Fzd20xZjdtR0s1VDk5OFNIOXpYdzoxNDYyOGRkYzUyYTFiMzgz&limit=10&page=-73"),
+        MapillaryURL.searchSequenceURL(null, -73)
+    );
+  }
+
+  @Test
+  public void testSearchTrafficSignURL() throws MalformedURLException {
+    assertEquals(
+        new URL("https://a.mapillary.com/v2/search/im/or/?client_id=T1Fzd20xZjdtR0s1VDk5OFNIOXpYdzoxNDYyOGRkYzUyYTFiMzgz&min_lon=2.220000&max_lat=3.333000&max_lon=4.444400&limit=20&page=-42&min_lat=1.100000"),
+        MapillaryURL.searchTrafficSignURL(new Bounds(1.1, 2.22, 3.333, 4.4444), -42)
+    );
+    assertEquals(
+        new URL("https://a.mapillary.com/v2/search/im/or/?client_id=T1Fzd20xZjdtR0s1VDk5OFNIOXpYdzoxNDYyOGRkYzUyYTFiMzgz&limit=20&page=73"),
+        MapillaryURL.searchTrafficSignURL(null, 73)
+    );
+  }
+
+  @Test
+  public void testUploadSecretsURL() throws MalformedURLException {
+    assertEquals(
+        new URL("https://a.mapillary.com/v2/me/uploads/secrets/?client_id=T1Fzd20xZjdtR0s1VDk5OFNIOXpYdzoxNDYyOGRkYzUyYTFiMzgz"),
+        MapillaryURL.uploadSecretsURL()
+    );
+  }
+
+  @Test
+  public void testUserURL() throws MalformedURLException {
+    assertEquals(
+        new URL("https://a.mapillary.com/v2/me/?client_id=T1Fzd20xZjdtR0s1VDk5OFNIOXpYdzoxNDYyOGRkYzUyYTFiMzgz"),
+        MapillaryURL.userURL()
+    );
+  }
+
+  @Test
+  public void testString2URL() throws NoSuchMethodException, SecurityException, IllegalAccessException, IllegalArgumentException, InvocationTargetException {
+    Method method = MapillaryURL.class.getDeclaredMethod("string2URL", String.class);
+    method.setAccessible(true);
+    assertNull(method.invoke(null, "bla"));
+  }
+
+  @Test
+  public void testUtilityClass() {
+    TestUtil.testUtilityClass(MapillaryURL.class);
+  }
+}
