Index: /trunk/src/org/openstreetmap/josm/gui/Notification.java
===================================================================
--- /trunk/src/org/openstreetmap/josm/gui/Notification.java	(revision 19642)
+++ /trunk/src/org/openstreetmap/josm/gui/Notification.java	(revision 19643)
@@ -7,4 +7,5 @@
 
 import javax.swing.Icon;
+import javax.swing.ImageIcon;
 import javax.swing.JEditorPane;
 import javax.swing.JOptionPane;
@@ -14,4 +15,6 @@
 import org.openstreetmap.josm.gui.widgets.JMultilineLabel;
 import org.openstreetmap.josm.spi.preferences.Config;
+import org.openstreetmap.josm.tools.ImageProvider.ImageSizes;
+import org.openstreetmap.josm.tools.ImageResource;
 
 /**
@@ -130,4 +133,6 @@
     /**
      * Set an icon to display on the left part of the message window.
+     * <p>
+     * Icons larger than {@link ImageSizes#NOTIFICATION} are scaled down.
      *
      * @param icon the icon (null means no icon is displayed)
@@ -135,5 +140,10 @@
      */
     public Notification setIcon(Icon icon) {
-        this.icon = icon;
+        if (icon instanceof ImageIcon && (icon.getIconWidth() > ImageSizes.NOTIFICATION.getAdjustedWidth()
+                || icon.getIconHeight() > ImageSizes.NOTIFICATION.getAdjustedHeight())) {
+            this.icon = new ImageResource(((ImageIcon) icon).getImage()).getImageIconBounded(ImageSizes.NOTIFICATION.getImageDimension());
+        } else {
+            this.icon = icon;
+        }
         return this;
     }
Index: /trunk/src/org/openstreetmap/josm/gui/NotificationManager.java
===================================================================
--- /trunk/src/org/openstreetmap/josm/gui/NotificationManager.java	(revision 19642)
+++ /trunk/src/org/openstreetmap/josm/gui/NotificationManager.java	(revision 19643)
@@ -5,4 +5,5 @@
 
 import java.awt.BasicStroke;
+import java.awt.BorderLayout;
 import java.awt.Color;
 import java.awt.Component;
@@ -13,8 +14,14 @@
 import java.awt.Insets;
 import java.awt.Point;
+import java.awt.Rectangle;
 import java.awt.RenderingHints;
 import java.awt.Shape;
 import java.awt.event.ActionEvent;
 import java.awt.event.ActionListener;
+import java.awt.event.ComponentAdapter;
+import java.awt.event.ComponentEvent;
+import java.awt.event.ComponentListener;
+import java.awt.event.HierarchyEvent;
+import java.awt.event.HierarchyListener;
 import java.awt.event.MouseAdapter;
 import java.awt.event.MouseEvent;
@@ -26,5 +33,4 @@
 
 import javax.swing.AbstractAction;
-import javax.swing.BorderFactory;
 import javax.swing.GroupLayout;
 import javax.swing.JButton;
@@ -41,4 +47,5 @@
 import org.openstreetmap.josm.gui.help.HelpUtil;
 import org.openstreetmap.josm.gui.util.GuiHelper;
+import org.openstreetmap.josm.tools.GuiSizesHelper;
 import org.openstreetmap.josm.tools.ImageProvider;
 import org.openstreetmap.josm.tools.Logging;
@@ -46,10 +53,10 @@
 /**
  * Manages {@link Notification}s, i.e.&nbsp;displays them on screen.
- *
+ * <p>
  * Don't use this class directly, but use {@link Notification#show()}.
- *
+ * <p>
  * If multiple messages are sent in a short period of time, they are put in
  * a queue and displayed one after the other.
- *
+ * <p>
  * The user can stop the timer (freeze the message) by moving the mouse cursor
  * above the panel. As a visual cue, the background color changes from
@@ -65,4 +72,31 @@
     private Notification currentNotification;
     private NotificationPanel currentNotificationPanel;
+
+    /** the component {@link #currentNotificationPanel} is aligned to, {@code null} while nothing is displayed */
+    private Component notificationAnchor;
+
+    /** keeps the displayed notification aligned when the layout around it changes, e.g. in fullscreen mode */
+    private final ComponentListener anchorListener = new ComponentAdapter() {
+        @Override
+        public void componentResized(ComponentEvent e) {
+            updateNotificationPosition();
+        }
+
+        @Override
+        public void componentMoved(ComponentEvent e) {
+            updateNotificationPosition();
+        }
+    };
+
+    /** realigns the displayed notification when toggling fullscreen, before the window is painted with it at its old position */
+    private final HierarchyListener showingListener = e -> {
+        if ((e.getChangeFlags() & HierarchyEvent.SHOWING_CHANGED) != 0 && e.getComponent().isShowing()) {
+            updateNotificationPosition();
+        }
+    };
+
+    /** brings the displayed notification over to the map view as soon as one is opened, and back when it is closed */
+    private final MapFrameListener mapFrameListener = (oldFrame, newFrame) -> updateNotificationPosition();
+
     private final Deque<Notification> queue;
 
@@ -74,4 +108,7 @@
     private static NotificationManager instance;
 
+    /** margin between the notification panel and the borders of its anchor, in unscaled pixels */
+    private static final int MARGIN = 10;
+
     private static final Color PANEL_SEMITRANSPARENT = new Color(224, 236, 249, 230);
     private static final Color PANEL_OPAQUE = new Color(224, 236, 249);
@@ -79,5 +116,5 @@
     NotificationManager() {
         queue = new LinkedList<>();
-        hideTimer = new Timer(Notification.TIME_DEFAULT, e -> this.stopHideTimer());
+        hideTimer = new Timer(Notification.TIME_DEFAULT, e -> this.stopHideTimer(null));
         hideTimer.setRepeats(false);
         pauseTimer = new Timer(pauseTime.get(), new PauseFinishedEvent());
@@ -88,5 +125,5 @@
 
     /**
-     * Show the given notification (unless a duplicate notification is being shown at the moment or at the end of the queue)
+     * Show the given notification (unless a duplicate notification is being shown at the moment or is already queued)
      * @param note The note to show.
      * @see Notification#show()
@@ -94,11 +131,12 @@
     void showNotification(Notification note) {
         synchronized (queue) {
-            if (Objects.equals(note, currentNotification) || Objects.equals(note, queue.peekLast())) {
+            if (Objects.equals(note, currentNotification) || queue.contains(note)) {
                 Logging.debug("Dropping duplicate notification {0}", note);
                 return;
             }
             queue.add(note);
-            processQueue();
-        }
+        }
+        // must not run while the monitor is held, see processQueue()
+        processQueue();
     }
 
@@ -109,53 +147,105 @@
      */
     void replaceExistingNotification(Notification oldNotification, Notification newNotification) {
+        boolean isDisplayed;
         synchronized (queue) {
-            if (Objects.equals(oldNotification, currentNotification)) {
-                stopHideTimer();
-            } else {
+            isDisplayed = Objects.equals(oldNotification, currentNotification);
+            if (!isDisplayed) {
                 queue.remove(oldNotification);
             }
-            showNotification(newNotification);
-            processQueue();
-        }
-    }
-
+        }
+        if (isDisplayed) {
+            // must not run while the monitor is held either, it waits for the EDT as well
+            stopHideTimer(oldNotification);
+        }
+        showNotification(newNotification);
+    }
+
+    /**
+     * Displays the next queued notification, unless one is being displayed already or the queue is empty.
+     * <p>
+     * Only the state transition is guarded by the monitor of {@link #queue}. The rest waits for the EDT, and the EDT
+     * takes that very monitor as well, so holding it any longer would deadlock every caller
+     * that is not the EDT itself.
+     */
     private void processQueue() {
-        if (running) return;
-
-        currentNotification = queue.poll();
-        if (currentNotification == null) return;
+        synchronized (queue) {
+            if (running) return;
+
+            currentNotification = queue.poll();
+            if (currentNotification == null) return;
+
+            // claim the slot before releasing the monitor, so that no concurrent call displays a second notification
+            running = true;
+        }
 
         GuiHelper.runInEDTAndWait(() -> {
-            currentNotificationPanel = new NotificationPanel(currentNotification, new FreezeMouseListener(), e -> this.stopHideTimer());
+            currentNotificationPanel = new NotificationPanel(currentNotification, new FreezeMouseListener(), e -> this.stopHideTimer(null));
+            currentNotificationPanel.addHierarchyListener(showingListener);
             currentNotificationPanel.validate();
 
-            int margin = 5;
+            currentNotificationPanel.setSize(currentNotificationPanel.getPreferredSize());
+
             JFrame parentWindow = MainApplication.getMainFrame();
-            Dimension size = currentNotificationPanel.getPreferredSize();
             if (parentWindow != null) {
-                int x;
-                int y;
-                MapFrame map = MainApplication.getMap();
-                if (MainApplication.isDisplayingMapView() && map.mapView.getHeight() > 0) {
-                    MapView mv = map.mapView;
-                    Point mapViewPos = SwingUtilities.convertPoint(mv.getParent(), mv.getX(), mv.getY(), MainApplication.getMainFrame());
-                    x = mapViewPos.x + margin;
-                    y = mapViewPos.y + mv.getHeight() - map.statusLine.getHeight() - size.height - margin;
-                } else {
-                    x = margin;
-                    y = parentWindow.getHeight() - MainApplication.getToolbar().control.getSize().height - size.height - margin;
-                }
                 parentWindow.getLayeredPane().add(currentNotificationPanel, JLayeredPane.POPUP_LAYER, 0);
-
-                currentNotificationPanel.setLocation(x, y);
-            }
-            currentNotificationPanel.setSize(size);
+                MainApplication.addMapFrameListener(mapFrameListener);
+                updateNotificationPosition();
+            }
             currentNotificationPanel.setVisible(true);
         });
 
-        running = true;
         elapsedTime = 0;
-
         startHideTimer();
+    }
+
+    /**
+     * Aligns the displayed notification to the map view, or to the content pane while no map view is displayed,
+     * and keeps listening to that anchor for as long as the notification is displayed.
+     */
+    private void updateNotificationPosition() {
+        JFrame parentWindow = MainApplication.getMainFrame();
+        if (currentNotificationPanel == null || parentWindow == null) {
+            return;
+        }
+        Component anchor = MainApplication.isDisplayingMapView() ? MainApplication.getMap().mapView : parentWindow.getContentPane();
+        if (anchor != notificationAnchor) {
+            // the map view is created and destroyed along with the layers, so the anchor may change while displaying
+            detachAnchorListener();
+            notificationAnchor = anchor;
+            anchor.addComponentListener(anchorListener);
+        }
+        // a map view that has just been created is not laid out yet; its first resize event brings the notification over
+        Component target = anchor.getHeight() > 0 ? anchor : parentWindow.getContentPane();
+        currentNotificationPanel.setLocation(getNotificationPosition(target, parentWindow.getLayeredPane(),
+                currentNotificationPanel.getSize(), GuiSizesHelper.getSizeDpiAdjusted(MARGIN)));
+    }
+
+    /**
+     * Stops listening to the anchor of the notification that is no longer displayed.
+     */
+    private void detachAnchorListener() {
+        if (notificationAnchor != null) {
+            notificationAnchor.removeComponentListener(anchorListener);
+            notificationAnchor = null;
+        }
+    }
+
+    /**
+     * Computes the position of a notification panel in the coordinate system of {@code container}, which unlike
+     * the one of the main window does not depend on the window decorations, i.e. on fullscreen mode.
+     * <p>
+     * The panel is aligned to the bottom left corner of {@code anchor}, or to its top left corner if it is taller,
+     * so that the beginning of a long message stays readable.
+     *
+     * @param anchor the component the notification is aligned to, e.g. the map view
+     * @param container the container the notification panel is added to
+     * @param size the size of the notification panel
+     * @param margin the margin to keep between the notification panel and the borders of {@code anchor}
+     * @return the location of the upper left corner of the notification panel
+     */
+    static Point getNotificationPosition(Component anchor, Container container, Dimension size, int margin) {
+        Rectangle bounds = SwingUtilities.convertRectangle(anchor.getParent(), anchor.getBounds(), container);
+        int y = Math.max(bounds.y + margin, bounds.y + bounds.height - size.height - margin);
+        return new Point(bounds.x + margin, y);
     }
 
@@ -170,7 +260,19 @@
     }
 
-    private void stopHideTimer() {
-        hideTimer.stop();
-        if (currentNotificationPanel != null) {
+    /**
+     * Hides the displayed notification and starts the pause before the next one.
+     *
+     * @param expected the notification to hide, or {@code null} for whichever is displayed. Nothing happens if
+     *                 another notification is displayed by now.
+     */
+    private void stopHideTimer(Notification expected) {
+        // may be reached from any thread through replaceExistingNotification()
+        GuiHelper.runInEDTAndWait(() -> {
+            if (currentNotificationPanel == null || (expected != null && !Objects.equals(expected, currentNotification))) {
+                return;
+            }
+            hideTimer.stop();
+            detachAnchorListener();
+            MainApplication.removeMapFrameListener(mapFrameListener);
             currentNotificationPanel.setVisible(false);
             JFrame parent = MainApplication.getMainFrame();
@@ -179,6 +281,10 @@
             }
             currentNotificationPanel = null;
-        }
-        pauseTimer.restart();
+            synchronized (queue) {
+                // forget it, or an identical notification shown during the pause is dropped as a duplicate
+                currentNotification = null;
+            }
+            pauseTimer.restart();
+        });
     }
 
@@ -189,6 +295,6 @@
             synchronized (queue) {
                 running = false;
-                processQueue();
-            }
+            }
+            processQueue();
         }
     }
@@ -198,9 +304,10 @@
         @Override
         public void actionPerformed(ActionEvent e) {
+            // AWT still delivers the mouse exit event of a panel that has just been removed
             if (currentNotificationPanel != null) {
                 currentNotificationPanel.setNotificationBackground(PANEL_SEMITRANSPARENT);
                 currentNotificationPanel.repaint();
-            }
-            startHideTimer();
+                startHideTimer();
+            }
         }
     }
@@ -236,8 +343,10 @@
 
         private void build(final Notification note, MouseListener freeze, ActionListener hideListener) {
+            // the default FlowLayout would add gaps around the visible notification
+            setLayout(new BorderLayout());
             JButton btnClose = new JButton();
             btnClose.addActionListener(hideListener);
             btnClose.setIcon(ImageProvider.get("misc", "grey_x"));
-            btnClose.setPreferredSize(new Dimension(50, 50));
+            btnClose.setPreferredSize(GuiSizesHelper.getDimensionDpiAdjusted(new Dimension(50, 50)));
             btnClose.setMargin(new Insets(0, 0, 1, 1));
             btnClose.setContentAreaFilled(false);
@@ -271,6 +380,5 @@
             layout.setAutoCreateContainerGaps(true);
 
-            innerPanel.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
-            add(innerPanel);
+            add(innerPanel, BorderLayout.CENTER);
 
             JLabel icon = null;
@@ -342,5 +450,6 @@
             if (unfreezeDelayTimer.isRunning()) {
                 unfreezeDelayTimer.stop();
-            } else {
+            } else if (currentNotificationPanel != null) {
+                // AWT still delivers events for a panel that has just been removed
                 hideTimer.stop();
                 elapsedTime += System.currentTimeMillis() - displayTimeStart;
@@ -368,20 +477,26 @@
         @Override
         protected void paintComponent(Graphics graphics) {
-            Graphics2D g = (Graphics2D) graphics;
-            g.setRenderingHint(
-                    RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
-            g.setColor(getBackground());
-            float lineWidth = 1.4f;
-            Shape rect = new RoundRectangle2D.Double(
-                    lineWidth/2d + getInsets().left,
-                    lineWidth/2d + getInsets().top,
-                    getWidth() - lineWidth/2d - getInsets().left - getInsets().right,
-                    getHeight() - lineWidth/2d - getInsets().top - getInsets().bottom,
-                    20, 20);
-
-            g.fill(rect);
-            g.setColor(getForeground());
-            g.setStroke(new BasicStroke(lineWidth));
-            g.draw(rect);
+            // paint on a copy, so that neither the antialiasing hint nor the stroke leak into the given context
+            Graphics2D g = (Graphics2D) graphics.create();
+            try {
+                g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+                g.setColor(getBackground());
+                float lineWidth = 1.4f;
+                // the outline goes on the content box, not around the empty border
+                Insets insets = getInsets();
+                Shape rect = new RoundRectangle2D.Double(
+                        insets.left + lineWidth/2d,
+                        insets.top + lineWidth/2d,
+                        getWidth() - insets.left - insets.right - lineWidth,
+                        getHeight() - insets.top - insets.bottom - lineWidth,
+                        20, 20);
+
+                g.fill(rect);
+                g.setColor(getForeground());
+                g.setStroke(new BasicStroke(lineWidth));
+                g.draw(rect);
+            } finally {
+                g.dispose();
+            }
             super.paintComponent(graphics);
         }
Index: /trunk/src/org/openstreetmap/josm/tools/ImageProvider.java
===================================================================
--- /trunk/src/org/openstreetmap/josm/tools/ImageProvider.java	(revision 19642)
+++ /trunk/src/org/openstreetmap/josm/tools/ImageProvider.java	(revision 19643)
@@ -183,5 +183,10 @@
          * @since 16872
          */
-        HTMLINLINE(24, 24);
+        HTMLINLINE(24, 24),
+        /**
+         * Notification icon maximum size
+         * @since 19643
+         */
+        NOTIFICATION(Config.getPref().getInt("iconsize.notification", 32));
 
         private final int virtualWidth;
Index: /trunk/test/unit/org/openstreetmap/josm/gui/NotificationManagerTest.java
===================================================================
--- /trunk/test/unit/org/openstreetmap/josm/gui/NotificationManagerTest.java	(revision 19643)
+++ /trunk/test/unit/org/openstreetmap/josm/gui/NotificationManagerTest.java	(revision 19643)
@@ -0,0 +1,293 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.awt.Component;
+import java.awt.Dimension;
+import java.awt.Graphics2D;
+import java.awt.Point;
+import java.awt.Rectangle;
+import java.awt.RenderingHints;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.awt.event.MouseEvent;
+import java.awt.event.MouseListener;
+import java.awt.image.BufferedImage;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.Deque;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import javax.swing.JLayeredPane;
+import javax.swing.JPanel;
+import javax.swing.SwingUtilities;
+import javax.swing.Timer;
+
+import org.junit.jupiter.api.Test;
+import org.openstreetmap.josm.testutils.annotations.BasicPreferences;
+import org.openstreetmap.josm.tools.ReflectionUtils;
+
+/**
+ * Unit tests of {@link NotificationManager} class.
+ */
+@BasicPreferences
+class NotificationManagerTest {
+
+    private static final int MARGIN = 10;
+    private static final Dimension NOTIFICATION_SIZE = new Dimension(300, 60);
+
+    /**
+     * Builds a layered pane holding a content pane below the menu bar, which holds the map view right of the side
+     * toolbar and above the status line.
+     * @param layeredPane the layered pane to fill
+     * @return the map view stand-in
+     */
+    private static JPanel buildMainWindowHierarchy(JLayeredPane layeredPane) {
+        layeredPane.setBounds(0, 0, 1000, 800);
+        JPanel contentPane = new JPanel(null);
+        contentPane.setBounds(0, 25, 1000, 775);
+        layeredPane.add(contentPane);
+        JPanel mapView = new JPanel();
+        mapView.setBounds(30, 0, 970, 740);
+        contentPane.add(mapView);
+        return mapView;
+    }
+
+    @SuppressWarnings("unchecked")
+    private static <T> T field(NotificationManager manager, String name) throws ReflectiveOperationException {
+        Field field = NotificationManager.class.getDeclaredField(name);
+        ReflectionUtils.setObjectsAccessible(field);
+        return (T) field.get(manager);
+    }
+
+    private static void stopHideTimer(NotificationManager manager, Notification expected) throws ReflectiveOperationException {
+        Method method = NotificationManager.class.getDeclaredMethod("stopHideTimer", Notification.class);
+        ReflectionUtils.setObjectsAccessible(method);
+        method.invoke(manager, expected);
+    }
+
+    /**
+     * The notification is aligned to the bottom left corner of the map view.
+     */
+    @Test
+    void testGetNotificationPositionMapView() {
+        JLayeredPane layeredPane = new JLayeredPane();
+        JPanel mapView = buildMainWindowHierarchy(layeredPane);
+
+        assertEquals(new Point(30 + MARGIN, 25 + 740 - NOTIFICATION_SIZE.height - MARGIN),
+                NotificationManager.getNotificationPosition(mapView, layeredPane, NOTIFICATION_SIZE, MARGIN));
+    }
+
+    /**
+     * Non-regression test for <a href="https://josm.openstreetmap.de/ticket/23002">#23002</a>: the position does not
+     * depend on the window decorations, which are only there when not in fullscreen mode.
+     */
+    @Test
+    void testTicket23002() {
+        JLayeredPane layeredPane = new JLayeredPane();
+        JPanel mapView = buildMainWindowHierarchy(layeredPane);
+        Point fullscreen = NotificationManager.getNotificationPosition(mapView, layeredPane, NOTIFICATION_SIZE, MARGIN);
+
+        // within the frame, the layered pane is offset by the window decorations
+        JPanel frame = new JPanel(null);
+        frame.setBounds(0, 0, 1010, 840);
+        JPanel rootPane = new JPanel(null);
+        rootPane.setBounds(5, 35, 1000, 800);
+        frame.add(rootPane);
+        rootPane.add(layeredPane);
+
+        Point windowed = NotificationManager.getNotificationPosition(mapView, layeredPane, NOTIFICATION_SIZE, MARGIN);
+        assertEquals(fullscreen, windowed);
+
+        Rectangle notification = new Rectangle(windowed, NOTIFICATION_SIZE);
+        Rectangle mapViewBounds = SwingUtilities.convertRectangle(mapView.getParent(), mapView.getBounds(), layeredPane);
+        assertTrue(mapViewBounds.contains(notification), notification + " is not inside the map view " + mapViewBounds);
+    }
+
+    /**
+     * A notification taller than the map view is aligned to its top, so that the beginning of the message stays
+     * readable.
+     */
+    @Test
+    void testGetNotificationPositionTallerThanAnchor() {
+        JLayeredPane layeredPane = new JLayeredPane();
+        JPanel mapView = buildMainWindowHierarchy(layeredPane);
+
+        assertEquals(new Point(30 + MARGIN, 25 + MARGIN),
+                NotificationManager.getNotificationPosition(mapView, layeredPane, new Dimension(480, 800), MARGIN));
+    }
+
+    /**
+     * Tries to take the monitor of {@code lock} from another thread.
+     * @param lock the object to synchronize on
+     * @return {@code false} if some other thread holds the monitor for longer than a second
+     * @throws InterruptedException if interrupted while waiting
+     */
+    private static boolean canTakeMonitorOf(Object lock) throws InterruptedException {
+        CountDownLatch taken = new CountDownLatch(1);
+        Thread probe = new Thread(() -> {
+            synchronized (lock) {
+                taken.countDown();
+            }
+        }, "notification-manager-test-monitor");
+        probe.setDaemon(true);
+        probe.start();
+        return taken.await(1, TimeUnit.SECONDS);
+    }
+
+    /**
+     * Showing a notification from a worker thread must not hold the monitor of the queue while waiting for the EDT,
+     * which takes it as well. The monitor is checked from the EDT, as a real deadlock would wedge it for good.
+     * @throws Exception in case of error
+     */
+    @Test
+    void testShowNotificationFromWorkerThreadDoesNotFreezeEdt() throws Exception {
+        NotificationManager manager = new NotificationManager();
+        Object queue = field(manager, "queue");
+        AtomicBoolean monitorWasHeld = new AtomicBoolean();
+        // getContent() is called on the EDT, while showNotification() is still running on the worker thread
+        Notification note = new Notification("shown from a worker thread") {
+            @Override
+            public Component getContent() {
+                try {
+                    monitorWasHeld.set(!canTakeMonitorOf(queue));
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                }
+                return super.getContent();
+            }
+        };
+
+        Thread worker = new Thread(() -> manager.showNotification(note), "notification-manager-test");
+        worker.setDaemon(true);
+        worker.start();
+        worker.join(TimeUnit.SECONDS.toMillis(10));
+
+        assertFalse(worker.isAlive(), "showNotification() never returned");
+        assertFalse(monitorWasHeld.get(), "showNotification() held the monitor of the queue while waiting for the EDT");
+    }
+
+    /**
+     * AWT still delivers mouse events to a notification panel that has just been removed.
+     * @throws Exception in case of error
+     */
+    @Test
+    void testMouseEnteredAfterNotificationHidden() throws Exception {
+        NotificationManager manager = new NotificationManager();
+        manager.showNotification(new Notification("hover me").setDuration(Notification.TIME_LONG));
+        Component panel = field(manager, "currentNotificationPanel");
+        assertNotNull(panel);
+
+        stopHideTimer(manager, null);
+        assertNull(field(manager, "currentNotificationPanel"));
+
+        MouseEvent event = new MouseEvent(panel, MouseEvent.MOUSE_ENTERED, System.currentTimeMillis(),
+                0, 1, 1, 0, false, MouseEvent.NOBUTTON);
+        for (MouseListener listener : panel.getMouseListeners()) {
+            assertDoesNotThrow(() -> listener.mouseEntered(event));
+        }
+    }
+
+    /**
+     * The mouse leaving a notification panel that has just been removed must not restart the hide timer, which
+     * would only delay the next notification.
+     * @throws Exception in case of error
+     */
+    @Test
+    void testUnfreezeAfterNotificationHidden() throws Exception {
+        NotificationManager manager = new NotificationManager();
+        manager.showNotification(new Notification("hover me").setDuration(Notification.TIME_LONG));
+        assertNotNull(field(manager, "currentNotificationPanel"));
+
+        stopHideTimer(manager, null);
+        Timer hideTimer = field(manager, "hideTimer");
+        assertFalse(hideTimer.isRunning());
+
+        Timer unfreezeDelayTimer = field(manager, "unfreezeDelayTimer");
+        ActionEvent event = new ActionEvent(unfreezeDelayTimer, ActionEvent.ACTION_PERFORMED, null);
+        for (ActionListener listener : unfreezeDelayTimer.getActionListeners()) {
+            listener.actionPerformed(event);
+        }
+        assertFalse(hideTimer.isRunning(), "the hide timer was restarted for a notification that is gone");
+    }
+
+    /**
+     * Replacing a notification that is no longer displayed must not hide the one that took its place.
+     * @throws Exception in case of error
+     */
+    @Test
+    void testReplaceNotificationThatIsNoLongerDisplayed() throws Exception {
+        NotificationManager manager = new NotificationManager();
+        manager.showNotification(new Notification("displayed"));
+        Component panel = field(manager, "currentNotificationPanel");
+        assertNotNull(panel);
+
+        stopHideTimer(manager, new Notification("replaced while the queue moved on"));
+        assertSame(panel, field(manager, "currentNotificationPanel"), "the displayed notification was hidden instead");
+
+        stopHideTimer(manager, field(manager, "currentNotification"));
+        assertNull(field(manager, "currentNotificationPanel"));
+    }
+
+    /**
+     * A notification equal to any queued one is dropped, not only one equal to the last.
+     * @throws Exception in case of error
+     */
+    @Test
+    void testDuplicateAnywhereInQueueIsDropped() throws Exception {
+        NotificationManager manager = new NotificationManager();
+        manager.showNotification(new Notification("displayed"));
+        manager.showNotification(new Notification("queued first"));
+        manager.showNotification(new Notification("queued second"));
+        manager.showNotification(new Notification("queued first"));
+
+        Deque<Notification> queue = field(manager, "queue");
+        assertEquals(2, queue.size(), "duplicate was queued again: " + queue);
+    }
+
+    /**
+     * The same message shown right after the previous one disappeared is not a duplicate.
+     * @throws Exception in case of error
+     */
+    @Test
+    void testSameNotificationRightAfterTheLastOneDisappeared() throws Exception {
+        NotificationManager manager = new NotificationManager();
+        manager.showNotification(new Notification("same message"));
+        assertNotNull(field(manager, "currentNotificationPanel"));
+
+        stopHideTimer(manager, null);
+        assertNull(field(manager, "currentNotification"));
+
+        manager.showNotification(new Notification("same message"));
+        Deque<Notification> queue = field(manager, "queue");
+        assertEquals(1, queue.size(), "the notification was dropped as a duplicate");
+    }
+
+    /**
+     * The rounded panel paints with antialiasing, without changing the hint of the graphics context it is given.
+     */
+    @Test
+    void testRoundedPanelKeepsRenderingHints() {
+        NotificationManager.RoundedPanel panel = new NotificationManager.RoundedPanel();
+        panel.setSize(100, 50);
+
+        BufferedImage image = new BufferedImage(100, 50, BufferedImage.TYPE_INT_ARGB);
+        Graphics2D g = image.createGraphics();
+        try {
+            g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF);
+            panel.paintComponent(g);
+            assertEquals(RenderingHints.VALUE_ANTIALIAS_OFF, g.getRenderingHint(RenderingHints.KEY_ANTIALIASING));
+        } finally {
+            g.dispose();
+        }
+    }
+}
Index: /trunk/test/unit/org/openstreetmap/josm/gui/NotificationTest.java
===================================================================
--- /trunk/test/unit/org/openstreetmap/josm/gui/NotificationTest.java	(revision 19643)
+++ /trunk/test/unit/org/openstreetmap/josm/gui/NotificationTest.java	(revision 19643)
@@ -0,0 +1,31 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.gui;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.awt.image.BufferedImage;
+
+import javax.swing.Icon;
+import javax.swing.ImageIcon;
+
+import org.junit.jupiter.api.Test;
+import org.openstreetmap.josm.testutils.annotations.BasicPreferences;
+import org.openstreetmap.josm.tools.ImageProvider.ImageSizes;
+
+/**
+ * Unit tests of {@link Notification}.
+ */
+@BasicPreferences
+class NotificationTest {
+
+    /**
+     * A plugin may hand over its logo in full size, which must not blow up the notification.
+     */
+    @Test
+    void testLargeIconIsScaledDown() {
+        int maxSize = ImageSizes.NOTIFICATION.getAdjustedWidth();
+        Icon icon = new Notification().setIcon(new ImageIcon(new BufferedImage(128, 64, BufferedImage.TYPE_INT_ARGB))).getIcon();
+        assertEquals(maxSize, icon.getIconWidth(), "the icon does not fit");
+        assertEquals(maxSize / 2, icon.getIconHeight(), "the aspect ratio of the icon is not kept");
+    }
+}
