Bug 564876 - Publication of IResourceChangeListener

At the moment, many plugins register their interest in Workspace updates
by calling something that looks similar to:
`ResourcesPlugin.getDefault().getWorkspace().addResourceChangeListener()`

This requires that the workspace is open, and has a static reference to
the `ResourcesPlugin` implementation class (or similar; sometimes they
are wrapped in other `BundleActivtor`s such as `PDEPlugin` or
`SearchPlugin`), which is an Activator-based start-up. As a result, the
plugin has to explicitly depend upon the workspace being up and running
before it can add itself to the list, and manage deregistering
afterwards.

Instead, if we set up a listener in the resources plugin for services
publishing IResourceChangeListener instances, and wiring them up to the
workspace, we can listen out for when the workspace is published and
then subscribe to all the listeners as they come and go.

We can use DS or ServiceTracker to manage this implementation, but
whichever we use, it will handle the registration upon bundle start and
remove the registration from bundle stop.

End user plugins need only provide a simple registration (either through
DS or through an explicit registration in an `BundleActivator`) to be
able to receive subsequent events.  We then also pick up the lifetime of
the `IWorkspace` and will automatically be de-registered if either
service goes away.

There are over 100 entries in the Eclipse platform alone that look like
<resource>.getWorkspace().addResourceChangeListener() which could all be
simplified to use publications of services instead, and that's not
including the 100+ more in tests and example code.

Change-Id: Ib384939b84aa648b1d112365461ac80b7b526353
Signed-off-by: Alex Blewitt <alex.blewitt@gmail.com>
diff --git a/bundles/org.eclipse.core.resources/META-INF/MANIFEST.MF b/bundles/org.eclipse.core.resources/META-INF/MANIFEST.MF
index 40fc1af..05f4461 100644
--- a/bundles/org.eclipse.core.resources/META-INF/MANIFEST.MF
+++ b/bundles/org.eclipse.core.resources/META-INF/MANIFEST.MF
@@ -29,5 +29,6 @@
  org.eclipse.core.filesystem;bundle-version="[1.3.0,2.0.0)",
  org.eclipse.core.runtime;bundle-version="[3.12.0,4.0.0)"
 Bundle-ActivationPolicy: lazy
+Service-Component: OSGI-INF/ResourceChangeListenerRegistrar.xml
 Bundle-RequiredExecutionEnvironment: JavaSE-1.8
 Automatic-Module-Name: org.eclipse.core.resources
diff --git a/bundles/org.eclipse.core.resources/OSGI-INF/ResourceChangeListenerRegistrar.xml b/bundles/org.eclipse.core.resources/OSGI-INF/ResourceChangeListenerRegistrar.xml
new file mode 100644
index 0000000..f8a02cf
--- /dev/null
+++ b/bundles/org.eclipse.core.resources/OSGI-INF/ResourceChangeListenerRegistrar.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<scr:component xmlns:scr="http://www.osgi.org/xmlns/scr/v1.4.0" name="ResourceChangeListenerRegistrar" init="1">
+   <implementation class="org.eclipse.core.internal.resources.ResourceChangeListenerRegistrar"/>
+   <reference name="IWorkspace"
+    interface="org.eclipse.core.resources.IWorkspace" parameter="0"/>
+   <reference name="LoggerFactory"
+    interface="org.osgi.service.log.LoggerFactory"
+    bind="setLoggerFactory"
+    unbind="unsetLogger"
+    policy="dynamic"
+    cardinality="0..1"/>
+   <reference name="IResourceChangeListener"
+    interface="org.eclipse.core.resources.IResourceChangeListener"
+    bind="addResourceChangeListener"
+    unbind="removeResourceChangeListener"
+    policy="dynamic"
+    cardinality="1..n"/>
+</scr:component>
diff --git a/bundles/org.eclipse.core.resources/build.properties b/bundles/org.eclipse.core.resources/build.properties
index 5b8163f..68418ea 100644
--- a/bundles/org.eclipse.core.resources/build.properties
+++ b/bundles/org.eclipse.core.resources/build.properties
@@ -7,7 +7,7 @@
 # https://www.eclipse.org/legal/epl-2.0/
 #
 # SPDX-License-Identifier: EPL-2.0
-# 
+#
 # Contributors:
 #     IBM Corporation - initial API and implementation
 ###############################################################################
@@ -22,6 +22,7 @@
                about.html,\
                .,\
                ant_tasks/resources-ant.jar,\
+               OSGI-INF/,\
                META-INF/
 jars.compile.order=.,ant_tasks/resources-ant.jar
 extra.ant_tasks/resources-ant.jar = platform:/plugin/org.apache.ant, platform:/plugin/org.eclipse.ant.core
diff --git a/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/ResourceChangeListenerRegistrar.java b/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/ResourceChangeListenerRegistrar.java
new file mode 100644
index 0000000..ce78e20
--- /dev/null
+++ b/bundles/org.eclipse.core.resources/src/org/eclipse/core/internal/resources/ResourceChangeListenerRegistrar.java
@@ -0,0 +1,123 @@
+/*******************************************************************************
+ * Copyright (c) 2020 Alex Blewitt and others.
+ *
+ * This program and the accompanying materials
+ * are made available under the terms of the Eclipse Public License 2.0
+ * which accompanies this distribution, and is available at
+ * https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ *
+ * Contributors:
+ *     Alex Blewitt - initial API and implementation
+ *******************************************************************************/
+package org.eclipse.core.internal.resources;
+
+import java.util.Map;
+import org.eclipse.core.resources.*;
+import org.eclipse.osgi.service.debug.DebugOptionsListener;
+import org.osgi.service.log.Logger;
+import org.osgi.service.log.LoggerFactory;
+
+/**
+ * Provides automatic registration of {@link IResourceChangeListener} instances
+ * with {@link IWorkspace} instances.
+ *
+ * <p>
+ * This allows clients to register their {@link IResourceChangeListener}
+ * instances as services, and be called back when changes occur, in the same way
+ * that (for example) {@link DebugOptionsListener} is used to receive callbacks.
+ * </p>
+ * <p>
+ * The services can also be registered with Declarative Services, which allows a
+ * bundle to not require that the Workspace bundle be started prior to accessing
+ * the resources, as until the IWorkspace is available the bundle will not need
+ * any callbacks. This will also save potential NPEs when the {@link IWorkspace}
+ * shuts down, because the OSGi runtime will handle the deregistration of
+ * services automatically.
+ * </p>
+ * <p>
+ * Services registered with an <code>event.mask</code> property can be used to
+ * receive a sub-set of the events, by registering the value with the
+ * {@link IWorkspace#addResourceChangeListener(IResourceChangeListener, int)}
+ * method. This allows (for example) {@link IResourceChangeEvent#POST_CHANGE}
+ * events to be received by setting <code>event.mask=1</code> in the service
+ * registration.
+ * </p>
+ * <p>
+ * The following can be used to register a listener with Declarative Services:
+ * </p>
+ *
+ * <pre>
+&lt;?xml version="1.0" encoding="UTF-8"?&gt;
+&lt;scr:component xmlns:scr="http://www.osgi.org/xmlns/scr/v1.4.0" immediate="true" name="ExampleResourceListener"&gt;
+   &lt;implementation class="org.example.ExampleResourceListener"/&gt;
+   &lt;service&gt;
+      &lt;provide interface="org.eclipse.core.resources.IResourceChangeListener"/&gt;
+   &lt;/service&gt;
+   &lt;!-- 1 == IResourceChangeEvent.POST_CHANGE -->
+   &lt;property name="event.mask" type="Integer" value="1"/&gt;
+&lt;/scr:component&gt;
+ * </pre>
+ */
+public final class ResourceChangeListenerRegistrar {
+	private final IWorkspace workspace;
+	private Logger logger;
+
+	/**
+	 * Create an ResourceChangeListenerRegistrar with the given workspace.
+	 *
+	 * @param workspace the workspace to associate listeners with
+	 */
+	public ResourceChangeListenerRegistrar(IWorkspace workspace) {
+		this.workspace = workspace;
+	}
+
+	/**
+	 * Register the {@link IResourceChangeListener} with the associated workspace
+	 *
+	 * @param listener   the listener to register
+	 * @param properties the properties, including <code>event.mask</code> if
+	 *                   required
+	 *                   {@link IWorkspace#addResourceChangeListener(IResourceChangeListener, int)}
+	 */
+	public void addResourceChangeListener(IResourceChangeListener listener, Map<String, Object> properties) {
+		// TODO Add as public API https://bugs.eclipse.org/bugs/show_bug.cgi?id=564985
+		Object mask = properties.get("event.mask"); //$NON-NLS-1$
+		if (mask instanceof Integer) {
+			workspace.addResourceChangeListener(listener, ((Integer) mask).intValue());
+		} else {
+			Logger local = this.logger;
+			if (mask != null && local != null) {
+				local.warn("event.mask of IResourceChangeListener service: expected Integer but was {} (from {}): {}", //$NON-NLS-1$
+						mask.getClass(), listener.getClass(), mask);
+			}
+			workspace.addResourceChangeListener(listener);
+		}
+	}
+
+	/**
+	 * Deregister the {@link IResourceChangeListener} from this workspace
+	 *
+	 * @param listener the listener to deregister
+	 */
+	public void removeResourceChangeListener(IResourceChangeListener listener) {
+		workspace.removeResourceChangeListener(listener);
+	}
+
+	/**
+	 * Set the logger factory that can be used by this component
+	 *
+	 * @param factory the factory
+	 */
+	public void setLoggerFactory(LoggerFactory factory) {
+		this.logger = factory.getLogger(ResourceChangeListenerRegistrar.class);
+	}
+
+	/**
+	 * Unsets the logger generated from the associated logger factory
+	 */
+	public void unsetLogger() {
+		this.logger = null;
+	}
+}
diff --git a/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/resources/IResourceChangeListenerTest.java b/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/resources/IResourceChangeListenerTest.java
index 5f43308..00d6969 100644
--- a/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/resources/IResourceChangeListenerTest.java
+++ b/tests/org.eclipse.core.tests.resources/src/org/eclipse/core/tests/resources/IResourceChangeListenerTest.java
@@ -16,10 +16,15 @@
 
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
+import java.util.Dictionary;
+import java.util.Hashtable;
+import java.util.function.BooleanSupplier;
 import org.eclipse.core.internal.resources.Workspace;
 import org.eclipse.core.resources.*;
 import org.eclipse.core.runtime.*;
 import org.eclipse.core.runtime.jobs.Job;
+import org.osgi.framework.*;
+import org.osgi.service.log.*;
 
 /**
  * Tests behavior of IResourceChangeListener, including validation
@@ -1298,6 +1303,112 @@
 		}
 	}
 
+	public void testAutoPublishService() {
+		class Loggy implements LogListener {
+			public boolean done = false;
+			@Override
+			public void logged(LogEntry entry) {
+				String message = entry.getMessage();
+				LogLevel level = entry.getLogLevel();
+				if (level == LogLevel.WARN && message.startsWith("event.mask of IResourceChangeListener")) {
+					done = true;
+					assertEquals(
+						"event.mask of IResourceChangeListener service: expected Integer but was class java.lang.String (from class org.eclipse.core.tests.resources.IResourceChangeListenerTest$1Listener3): Not an integer",
+						message);
+				}
+			}
+		}
+		class Listener1 implements IResourceChangeListener {
+			public boolean done = false;
+			@Override
+			public void resourceChanged(IResourceChangeEvent event) {
+				assertEquals("1.0", IResourceChangeEvent.POST_CHANGE, event.getType());
+				done = event.getType() == IResourceChangeEvent.POST_CHANGE;
+			}
+		}
+		class Listener2 extends Listener1 implements IResourceChangeListener {
+			@Override
+			public void resourceChanged(IResourceChangeEvent event) {
+				assertEquals("2.0", IResourceChangeEvent.POST_BUILD, event.getType());
+				done = true;
+			}
+		}
+		class Listener3 extends Listener1 implements IResourceChangeListener {
+			@Override
+			public void resourceChanged(IResourceChangeEvent event) {
+				assertEquals("3.0", IResourceChangeEvent.POST_CHANGE, event.getType());
+				done = true;
+			}
+		}
+		Loggy loggy = new Loggy();
+		Listener1 listener1 = new Listener1();
+		Listener2 listener2 = new Listener2();
+		Listener3 listener3 = new Listener3();
+		Bundle bundle = FrameworkUtil.getBundle(getWorkspace().getClass());
+		BundleContext context = bundle.getBundleContext();
+		ServiceReference<LogReaderService> logReaderService = context.getServiceReference(LogReaderService.class);
+		LogReaderService reader = logReaderService == null ? null : context.getService(logReaderService);
+		if (reader != null) {
+			reader.addLogListener(loggy);
+		}
+		// Default is event.mask = IResourceChangeEvent.PRE_CLOSE |
+		// IResourceChangeEvent.PRE_DELETE | IResourceChangeEvent.POST_CHANGE
+		ServiceRegistration<IResourceChangeListener> reg1 = context.registerService(IResourceChangeListener.class,
+				listener1, null);
+		ServiceRegistration<IResourceChangeListener> reg2 = context.registerService(IResourceChangeListener.class,
+				listener2, with("event.mask", IResourceChangeEvent.POST_BUILD));
+		ServiceRegistration<IResourceChangeListener> reg3 = context.registerService(IResourceChangeListener.class,
+				listener3, with("event.mask", "Not an integer"));
+		try {
+			assertTrue(waitUntil(() -> reg1.getReference().getUsingBundles() != null));
+			assertTrue(waitUntil(() -> reg2.getReference().getUsingBundles() != null));
+			assertTrue(waitUntil(() -> reg3.getReference().getUsingBundles() != null));
+			try {
+				project1.touch(getMonitor());
+			} catch (CoreException e) {
+				handleCoreException(e);
+			}
+			assertTrue(waitUntil(
+					() -> listener1.done && listener2.done && listener3.done && (loggy.done || reader == null)));
+		} finally {
+			if (reader != null) {
+				reader.removeLogListener(loggy);
+			}
+			if (logReaderService != null) {
+				context.ungetService(logReaderService);
+			}
+			if (reg1 != null) {
+				reg1.unregister();
+			}
+			if (reg2 != null) {
+				reg2.unregister();
+			}
+			if (reg3 != null) {
+				reg3.unregister();
+			}
+		}
+	}
+
+	public boolean waitUntil(BooleanSupplier condition) {
+		int i = 0;
+		while (!condition.getAsBoolean()) {
+			if (i++ > 600) {
+				return false;
+			}
+			try {
+				Thread.sleep(100);
+			} catch (InterruptedException e1) {
+			}
+		}
+		return true;
+	}
+
+	private static Dictionary<String, Object> with(String key, Object value) {
+		Hashtable<String, Object> dict = new Hashtable<>();
+		dict.put(key, value);
+		return dict;
+	}
+
 	public void testProjectDescriptionComment() {
 		try {
 			/* change file1's contents */