Merge branch 'master' into SLING-11229-SLING-11230
diff --git a/pom.xml b/pom.xml
index 222acee..8aeb840 100644
--- a/pom.xml
+++ b/pom.xml
@@ -27,7 +27,7 @@
     </parent>
 
     <artifactId>org.apache.sling.jcr.resource</artifactId>
-    <version>3.1.1-SNAPSHOT</version>
+    <version>3.2.1-SNAPSHOT</version>
 
     <name>Apache Sling JCR Resource</name>
     <description>
@@ -44,8 +44,8 @@
     <properties>
         <site.jira.version.id>12314286</site.jira.version.id>
         <site.javadoc.exclude>**.internal.**</site.javadoc.exclude>
-        <oak.version>1.5.15</oak.version>
-        <jackrabbit.version>2.13.4</jackrabbit.version>
+        <oak.version>1.10.0</oak.version><!-- first version compatible with Jackrabbit API 2.18 (https://issues.apache.org/jira/browse/OAK-7943) -->
+        <jackrabbit.version>2.18.0</jackrabbit.version><!-- required for direct binary access, https://issues.apache.org/jira/browse/JCR-4335 -->
         <project.build.outputTimestamp>1</project.build.outputTimestamp>
     </properties>
 
diff --git a/src/main/java/org/apache/sling/jcr/resource/internal/JcrModifiableValueMap.java b/src/main/java/org/apache/sling/jcr/resource/internal/JcrModifiableValueMap.java
index 445edb1..d28afdd 100644
--- a/src/main/java/org/apache/sling/jcr/resource/internal/JcrModifiableValueMap.java
+++ b/src/main/java/org/apache/sling/jcr/resource/internal/JcrModifiableValueMap.java
@@ -25,6 +25,7 @@
 import javax.jcr.RepositoryException;
 import javax.jcr.Value;
 
+import org.apache.jackrabbit.JcrConstants;
 import org.apache.sling.api.resource.ModifiableValueMap;
 import org.apache.sling.jcr.resource.internal.helper.JcrPropertyMapCacheEntry;
 
@@ -62,7 +63,7 @@
             final JcrPropertyMapCacheEntry entry = new JcrPropertyMapCacheEntry(value, this.node);
             this.cache.put(key, entry);
             final String name = escapeKeyName(key);
-            if ( NodeUtil.MIXIN_TYPES.equals(name) ) {
+            if ( JcrConstants.JCR_MIXINTYPES.equals(name) ) {
                 NodeUtil.handleMixinTypes(node, entry.convertToType(String[].class, node, this.helper.getDynamicClassLoader()));
             } else if ( "jcr:primaryType".equals(name) ) {
                 node.setPrimaryType(entry.convertToType(String.class, node, this.helper.getDynamicClassLoader()));
diff --git a/src/main/java/org/apache/sling/jcr/resource/internal/NodeUtil.java b/src/main/java/org/apache/sling/jcr/resource/internal/NodeUtil.java
index fa2d348..df852a1 100644
--- a/src/main/java/org/apache/sling/jcr/resource/internal/NodeUtil.java
+++ b/src/main/java/org/apache/sling/jcr/resource/internal/NodeUtil.java
@@ -18,21 +18,27 @@
  */
 package org.apache.sling.jcr.resource.internal;
 
+import static javax.jcr.Property.JCR_CONTENT;
+import static javax.jcr.Property.JCR_DATA;
+import static javax.jcr.Property.JCR_FROZEN_PRIMARY_TYPE;
+import static javax.jcr.nodetype.NodeType.NT_FILE;
+import static javax.jcr.nodetype.NodeType.NT_FROZEN_NODE;
+import static javax.jcr.nodetype.NodeType.NT_LINKED_FILE;
+
 import java.util.HashSet;
 import java.util.Set;
 
+import javax.jcr.Item;
+import javax.jcr.ItemNotFoundException;
 import javax.jcr.Node;
+import javax.jcr.Property;
 import javax.jcr.RepositoryException;
 import javax.jcr.nodetype.NodeType;
 
+import org.jetbrains.annotations.NotNull;
+
 public abstract class NodeUtil {
 
-    /** Property for the mixin node types. */
-    public static final String MIXIN_TYPES = "jcr:mixinTypes";
-
-    /** Property for the node type. */
-    public static final String NODE_TYPE = "jcr:primaryType";
-
     /**
      * Update the mixin node types
      *
@@ -63,4 +69,36 @@
             node.addMixin(name);
         }
     }
+
+    /**
+     * Returns the primary property of the given node. For {@code nt:file} nodes this is a property of the child node {@code jcr:content}.
+     * In case the node has a {@code jcr:data} property it is returned, otherwise the node's primary item as specified by its node type recursively until a property is found .
+     * 
+     * @param node the node for which to return the primary property
+     * @return the primary property of the given node
+     * @throws ItemNotFoundException in case the given node does neither have a {@code jcr:data} property nor a primary property given through its node type
+     * @throws RepositoryException in case some exception occurs
+     */
+    public static @NotNull Property getPrimaryProperty(@NotNull Node node) throws RepositoryException {
+        // find the content node: for nt:file it is jcr:content
+        // otherwise it is the node of this resource
+        Node content = (node.isNodeType(NT_FILE) ||
+                        (node.isNodeType(NT_FROZEN_NODE) &&
+                         node.getProperty(JCR_FROZEN_PRIMARY_TYPE).getString().equals(NT_FILE)))
+                ? node.getNode(JCR_CONTENT)
+                : node.isNodeType(NT_LINKED_FILE) ? node.getProperty(JCR_CONTENT).getNode() : node;
+        Property data;
+        // if the node has a jcr:data property, use that property
+        if (content.hasProperty(JCR_DATA)) {
+            data = content.getProperty(JCR_DATA);
+        } else {
+            // otherwise try to follow default item trail
+            Item item = content.getPrimaryItem();
+            while (item.isNode()) {
+                item = ((Node) item).getPrimaryItem();
+            }
+            data = (Property) item;
+        }
+        return data;
+    }
 }
diff --git a/src/main/java/org/apache/sling/jcr/resource/internal/helper/jcr/BinaryDownloadUriProvider.java b/src/main/java/org/apache/sling/jcr/resource/internal/helper/jcr/BinaryDownloadUriProvider.java
new file mode 100644
index 0000000..3aa5abb
--- /dev/null
+++ b/src/main/java/org/apache/sling/jcr/resource/internal/helper/jcr/BinaryDownloadUriProvider.java
@@ -0,0 +1,145 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sling.jcr.resource.internal.helper.jcr;
+
+import java.net.URI;
+
+import javax.jcr.Binary;
+import javax.jcr.ItemNotFoundException;
+import javax.jcr.Node;
+import javax.jcr.Property;
+import javax.jcr.RepositoryException;
+import javax.jcr.ValueFormatException;
+
+import org.apache.jackrabbit.api.binary.BinaryDownload;
+import org.apache.jackrabbit.api.binary.BinaryDownloadOptions;
+import org.apache.jackrabbit.api.binary.BinaryDownloadOptions.BinaryDownloadOptionsBuilder;
+import org.apache.sling.api.resource.Resource;
+import org.apache.sling.api.resource.external.URIProvider;
+import org.apache.sling.jcr.resource.internal.NodeUtil;
+import org.jetbrains.annotations.NotNull;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.ConfigurationPolicy;
+import org.osgi.service.component.propertytypes.ServiceRanking;
+import org.osgi.service.metatype.annotations.AttributeDefinition;
+import org.osgi.service.metatype.annotations.Designate;
+import org.osgi.service.metatype.annotations.ObjectClassDefinition;
+
+/**
+ * Provides URIs for direct binary read-access based on the Jackrabbit API {@link BinaryDownload}.
+ * 
+ * @see <a href="https://jackrabbit.apache.org/oak/docs/features/direct-binary-access.html">Oak Direct Binary Access</a>
+ *
+ */
+@Component(service = URIProvider.class, configurationPolicy = ConfigurationPolicy.REQUIRE)
+@ServiceRanking(-100)
+@Designate(ocd = BinaryDownloadUriProvider.Configuration.class)
+public class BinaryDownloadUriProvider implements URIProvider {
+
+    enum ContentDisposition {
+        INLINE,
+        ATTACHMENT
+    }
+
+    @ObjectClassDefinition(
+            name = "Apache Sling Binary Download URI Provider",
+            description = "Provides URIs for resources containing a primary JCR binary property backed by a blob store allowing direct HTTP access")
+    public static @interface Configuration {
+        @AttributeDefinition(
+                name = "Content-Disposition",
+                description = "The content-disposition header to send when the binary is delivered via HTTP")
+        ContentDisposition contentDisposition();
+    }
+
+    private final boolean isContentDispositionAttachment;
+
+    @Activate
+    public BinaryDownloadUriProvider(Configuration configuration) {
+        this(configuration.contentDisposition() == ContentDisposition.ATTACHMENT);
+    }
+
+    BinaryDownloadUriProvider(boolean isContentDispositionAttachment) {
+        this.isContentDispositionAttachment = isContentDispositionAttachment;
+    }
+
+    @Override
+    public @NotNull URI toURI(@NotNull Resource resource, @NotNull Scope scope, @NotNull Operation operation) {
+        if (!isRelevantScopeAndOperation(scope, operation)) {
+            throw new IllegalArgumentException("This provider only provides URIs for 'READ' operations in scope 'PUBLIC' or 'EXTERNAL', but not for scope '" + scope + "' and operation '" + operation + "'");
+        }
+        Node node = resource.adaptTo(Node.class);
+        if (node == null) {
+            throw new IllegalArgumentException("This provider only provides URIs for node-based resources");
+        }
+        try {
+            // get main property (probably containing binary data)
+            Property primaryProperty = getPrimaryProperty(node);
+            try {
+                return getUriFromProperty(resource, node, primaryProperty);
+            } catch (RepositoryException e) {
+                throw new IllegalArgumentException("Error getting URI for property '" + primaryProperty.getPath() + "'", e);
+            }
+        } catch (ItemNotFoundException e) {
+            throw new IllegalArgumentException("Node does not have a primary property", e);
+        } catch (RepositoryException e) {
+            throw new IllegalArgumentException("Error accessing primary property", e);
+        }
+    }
+
+    protected @NotNull Property getPrimaryProperty(@NotNull Node node) throws RepositoryException {
+        return NodeUtil.getPrimaryProperty(node);
+    }
+
+    private boolean isRelevantScopeAndOperation(@NotNull Scope scope, @NotNull Operation operation) {
+       return ((Scope.PUBLIC.equals(scope) || Scope.EXTERNAL.equals(scope)) && Operation.READ.equals(operation));
+    }
+
+    private @NotNull URI getUriFromProperty(@NotNull Resource resource, @NotNull Node node, @NotNull Property binaryProperty) throws ValueFormatException, RepositoryException {
+        Binary binary = binaryProperty.getBinary();
+        if (!(binary instanceof BinaryDownload)) {
+            binary.dispose();
+            throw new IllegalArgumentException("The property " + binaryProperty.getPath() + " is not backed by a blob store allowing direct HTTP access");
+        }
+        BinaryDownload binaryDownload = BinaryDownload.class.cast(binary);
+        try {
+            String encoding = resource.getResourceMetadata().getCharacterEncoding();
+            String fileName = node.getName();
+            String mediaType = resource.getResourceMetadata().getContentType();
+            BinaryDownloadOptionsBuilder optionsBuilder = BinaryDownloadOptions.builder().withFileName(fileName);
+            if (encoding != null) {
+                optionsBuilder.withCharacterEncoding(encoding);
+            }
+            if (mediaType != null) {
+                optionsBuilder.withMediaType(mediaType);
+            }
+            if (isContentDispositionAttachment) {
+                optionsBuilder.withDispositionTypeAttachment();
+            } else {
+                optionsBuilder.withDispositionTypeInline();
+            }
+            URI uri = binaryDownload.getURI(optionsBuilder.build());
+            if (uri == null) {
+                throw new IllegalArgumentException("Cannot provide url for downloading the binary property at '" + binaryProperty.getPath() + "'");
+            }
+            return uri;
+        } finally {
+            binaryDownload.dispose();
+        }
+    }
+
+}
diff --git a/src/main/java/org/apache/sling/jcr/resource/internal/helper/jcr/JcrNodeResource.java b/src/main/java/org/apache/sling/jcr/resource/internal/helper/jcr/JcrNodeResource.java
index 4ff50af..2e21900 100644
--- a/src/main/java/org/apache/sling/jcr/resource/internal/helper/jcr/JcrNodeResource.java
+++ b/src/main/java/org/apache/sling/jcr/resource/internal/helper/jcr/JcrNodeResource.java
@@ -16,13 +16,6 @@
  */
 package org.apache.sling.jcr.resource.internal.helper.jcr;
 
-import static javax.jcr.Property.JCR_CONTENT;
-import static javax.jcr.Property.JCR_DATA;
-import static javax.jcr.nodetype.NodeType.NT_FILE;
-import static javax.jcr.nodetype.NodeType.NT_LINKED_FILE;
-import static javax.jcr.nodetype.NodeType.NT_FROZEN_NODE;
-import static javax.jcr.Property.JCR_FROZEN_PRIMARY_TYPE;
-
 import java.io.InputStream;
 import java.net.URI;
 import java.security.AccessControlException;
@@ -32,8 +25,10 @@
 import javax.jcr.Item;
 import javax.jcr.ItemNotFoundException;
 import javax.jcr.Node;
+import javax.jcr.PathNotFoundException;
 import javax.jcr.Property;
 import javax.jcr.RepositoryException;
+import javax.jcr.ValueFormatException;
 
 import org.apache.sling.adapter.annotations.Adaptable;
 import org.apache.sling.adapter.annotations.Adapter;
@@ -47,6 +42,8 @@
 import org.apache.sling.jcr.resource.internal.HelperData;
 import org.apache.sling.jcr.resource.internal.JcrModifiableValueMap;
 import org.apache.sling.jcr.resource.internal.JcrValueMap;
+import org.apache.sling.jcr.resource.internal.NodeUtil;
+import org.jetbrains.annotations.Nullable;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -181,34 +178,15 @@
         final Node node = getNode();
         if (node != null) {
             try {
-                // find the content node: for nt:file it is jcr:content
-                // otherwise it is the node of this resource
-                Node content = (node.isNodeType(NT_FILE) ||
-                                (node.isNodeType(NT_FROZEN_NODE) &&
-                                 node.getProperty(JCR_FROZEN_PRIMARY_TYPE).getString().equals(NT_FILE)))
-                        ? node.getNode(JCR_CONTENT)
-                        : node.isNodeType(NT_LINKED_FILE) ? node.getProperty(JCR_CONTENT).getNode() : node;
-
                 Property data;
-
-                // if the node has a jcr:data property, use that property
-                if (content.hasProperty(JCR_DATA)) {
-                    data = content.getProperty(JCR_DATA);
-                } else {
-                    // otherwise try to follow default item trail
-                    try {
-                        Item item = content.getPrimaryItem();
-                        while (item.isNode()) {
-                            item = ((Node) item).getPrimaryItem();
-                        }
-                        data = (Property) item;
-
-                    } catch (ItemNotFoundException infe) {
-                        // we don't actually care, but log for completeness
-                        LOGGER.debug("getInputStream: No primary items for {}", toString(), infe);
-                        data = null;
-                    }
+                try {
+                    data = NodeUtil.getPrimaryProperty(node);
+                } catch (ItemNotFoundException infe) {
+                    // we don't actually care, but log for completeness
+                    LOGGER.debug("getInputStream: No primary items for {}", toString(), infe);
+                    data = null;
                 }
+                
                 URI uri =  convertToPublicURI();
                 if ( uri != null ) {
                     return new JcrExternalizableInputStream(data, uri);
@@ -235,12 +213,9 @@
     private URI convertToPublicURI() {
         for (URIProvider up : helper.getURIProviders()) {
             try {
-                URI uri = up.toURI(this, URIProvider.Scope.EXTERNAL, URIProvider.Operation.READ);
-                if ( uri != null ) {
-                    return uri;
-                }
+                return up.toURI(this, URIProvider.Scope.EXTERNAL, URIProvider.Operation.READ);
             } catch (IllegalArgumentException e) {
-                LOGGER.debug(up.getClass().toString()+" declined toURI ", e);
+                LOGGER.debug("{} declined toURI for resource '{}'", up.getClass(), getPath(), e);
             }
         }
         return null;
diff --git a/src/main/java/org/apache/sling/jcr/resource/internal/helper/jcr/JcrResourceProvider.java b/src/main/java/org/apache/sling/jcr/resource/internal/helper/jcr/JcrResourceProvider.java
index 16e39db..d7809d6 100644
--- a/src/main/java/org/apache/sling/jcr/resource/internal/helper/jcr/JcrResourceProvider.java
+++ b/src/main/java/org/apache/sling/jcr/resource/internal/helper/jcr/JcrResourceProvider.java
@@ -22,12 +22,14 @@
 import java.io.IOException;
 import java.security.Principal;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.Map;
 import java.util.Set;
-import java.util.concurrent.ConcurrentHashMap;
+import java.util.SortedMap;
+import java.util.TreeMap;
 import java.util.concurrent.atomic.AtomicReference;
 
 import javax.jcr.Item;
@@ -36,6 +38,7 @@
 import javax.jcr.RepositoryException;
 import javax.jcr.Session;
 
+import org.apache.jackrabbit.JcrConstants;
 import org.apache.jackrabbit.api.JackrabbitSession;
 import org.apache.jackrabbit.api.security.user.Authorizable;
 import org.apache.jackrabbit.api.security.user.UserManager;
@@ -94,13 +97,13 @@
     /** Logger */
     private final Logger logger = LoggerFactory.getLogger(JcrResourceProvider.class);
 
-    private static final String REPOSITORY_REFERNENCE_NAME = "repository";
+    private static final String REPOSITORY_REFERENCE_NAME = "repository";
 
-    private static final Set<String> IGNORED_PROPERTIES = new HashSet<String>();
+    private static final Set<String> IGNORED_PROPERTIES = new HashSet<>();
     static {
-        IGNORED_PROPERTIES.add(NodeUtil.MIXIN_TYPES);
-        IGNORED_PROPERTIES.add(NodeUtil.NODE_TYPE);
-        IGNORED_PROPERTIES.add("jcr:created");
+        IGNORED_PROPERTIES.add(JcrConstants.JCR_MIXINTYPES);
+        IGNORED_PROPERTIES.add(JcrConstants.JCR_PRIMARYTYPE);
+        IGNORED_PROPERTIES.add(JcrConstants.JCR_CREATED);
         IGNORED_PROPERTIES.add("jcr:createdBy");
     }
 
@@ -113,8 +116,8 @@
         @AttributeDefinition(name = "Default Query Limit", description = "The default query limit for queries using the findResources methods")
         long default_query_limit() default 10000L;
     }
-
-    @Reference(name = REPOSITORY_REFERNENCE_NAME, service = SlingRepository.class)
+  
+    @Reference(name = REPOSITORY_REFERENCE_NAME, service = SlingRepository.class)
     private ServiceReference<SlingRepository> repositoryReference;
 
     /** The JCR listener base configuration. */
@@ -123,21 +126,25 @@
     /** The JCR observation listeners. */
     private final Map<ObserverConfiguration, Closeable> listeners = new HashMap<>();
 
-    private final Map<URIProvider, URIProvider> providers = new ConcurrentHashMap<URIProvider, URIProvider>();
+    /**
+     * Map of bound URIProviders sorted by service ranking in descending order (highest ranking first).
+     * Key = service reference, value = service implementation
+     */
+    private final SortedMap<ServiceReference<URIProvider>, URIProvider> providers = Collections.synchronizedSortedMap(new TreeMap<>(Collections.reverseOrder()));
 
     private volatile SlingRepository repository;
 
     private volatile JcrProviderStateFactory stateFactory;
 
     private Config config;
+  
+    private final AtomicReference<DynamicClassLoaderManager> classLoaderManagerReference = new AtomicReference<>();
 
-    private final AtomicReference<DynamicClassLoaderManager> classLoaderManagerReference = new AtomicReference<DynamicClassLoaderManager>();
-
-    private AtomicReference<URIProvider[]> uriProviderReference = new AtomicReference<URIProvider[]>();
+    private AtomicReference<URIProvider[]> uriProviderReference = new AtomicReference<>();
 
     @Activate
     protected void activate(final ComponentContext context, final Config config) throws RepositoryException {
-        SlingRepository repository = context.locateService(REPOSITORY_REFERNENCE_NAME,
+        SlingRepository repository = context.locateService(REPOSITORY_REFERENCE_NAME,
                 this.repositoryReference);
         if (repository == null) {
             // concurrent unregistration of SlingRepository service
@@ -179,13 +186,13 @@
             bind = "bindUriProvider",
             unbind = "unbindUriProvider"
     )
-    private void bindUriProvider(URIProvider uriProvider) {
-
-        providers.put(uriProvider, uriProvider);
+    private void bindUriProvider(ServiceReference<URIProvider> srUriProvider, URIProvider uriProvider) {
+        providers.put(srUriProvider, uriProvider);
         updateURIProviders();
     }
-    private void unbindUriProvider(URIProvider uriProvider) {
-        providers.remove(uriProvider);
+
+    private void unbindUriProvider(ServiceReference<URIProvider> srUriProvider) {
+        providers.remove(srUriProvider);
         updateURIProviders();
     }
 
@@ -422,7 +429,7 @@
     public Resource create(final @NotNull ResolveContext<JcrProviderState> ctx, final String path, final Map<String, Object> properties)
     throws PersistenceException {
         // check for node type
-        final Object nodeObj = (properties != null ? properties.get(NodeUtil.NODE_TYPE) : null);
+        final Object nodeObj = (properties != null ? properties.get(JcrConstants.JCR_PRIMARYTYPE) : null);
         // check for sling:resourcetype
         final String nodeType;
         if ( nodeObj != null ) {
@@ -471,9 +478,9 @@
                 // create modifiable map
                 final JcrModifiableValueMap jcrMap = new JcrModifiableValueMap(node, ctx.getProviderState().getHelperData());
                 // check mixin types first
-                final Object value = properties.get(NodeUtil.MIXIN_TYPES);
+                final Object value = properties.get(JcrConstants.JCR_MIXINTYPES);
                 if ( value != null ) {
-                    jcrMap.put(NodeUtil.MIXIN_TYPES, value);
+                    jcrMap.put(JcrConstants.JCR_MIXINTYPES, value);
                 }
                 for(final Map.Entry<String, Object> entry : properties.entrySet()) {
                     if ( !IGNORED_PROPERTIES.contains(entry.getKey()) ) {
diff --git a/src/test/java/org/apache/sling/jcr/resource/internal/JcrResourceListenerTest.java b/src/test/java/org/apache/sling/jcr/resource/internal/JcrResourceListenerTest.java
index 7bf62d9..d491210 100644
--- a/src/test/java/org/apache/sling/jcr/resource/internal/JcrResourceListenerTest.java
+++ b/src/test/java/org/apache/sling/jcr/resource/internal/JcrResourceListenerTest.java
@@ -269,7 +269,7 @@
                 session.save();
             }
             System.out.println("Events = " + events);
-            assertEquals("Received: " + events, 7, events.size());
+            assertEquals("Received: " + events, 6, events.size());
             final Set<String> addPaths = new HashSet<String>();
             final Set<String> modifyPaths = new HashSet<String>();
             final Set<String> removePaths = new HashSet<String>();
@@ -294,12 +294,9 @@
             assertTrue("Modified set should contain /libs/" + rootName, modifyPaths.contains("/libs/" + rootName));
             assertTrue("Modified set should contain /apps/" + rootName, modifyPaths.contains("/apps/" + rootName));
 
-            // The OakEventFilter is using withIncludeAncestorsRemove, so we get also "removed" 
-            // events for all ancestors of /apps and /libs;
-            assertEquals("Received: " + removePaths, 3, removePaths.size());
+            assertEquals("Received: " + removePaths, 2, removePaths.size());
             assertTrue("Removed set should contain /libs/" + rootName, removePaths.contains("/libs/" + rootName));
             assertTrue("Removed set should contain /apps/" + rootName, removePaths.contains("/apps/" + rootName));
-            assertTrue("Removed set should contain /" + rootName, removePaths.contains("/" + rootName));
         }
     }
 
diff --git a/src/test/java/org/apache/sling/jcr/resource/internal/helper/jcr/BinaryDownloadUriProviderTest.java b/src/test/java/org/apache/sling/jcr/resource/internal/helper/jcr/BinaryDownloadUriProviderTest.java
new file mode 100644
index 0000000..00d1ba5
--- /dev/null
+++ b/src/test/java/org/apache/sling/jcr/resource/internal/helper/jcr/BinaryDownloadUriProviderTest.java
@@ -0,0 +1,125 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sling.jcr.resource.internal.helper.jcr;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertThrows;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.Collections;
+
+import javax.jcr.Node;
+import javax.jcr.Property;
+import javax.jcr.RepositoryException;
+import javax.jcr.Session;
+import javax.jcr.ValueFormatException;
+
+import org.apache.jackrabbit.api.binary.BinaryDownload;
+import org.apache.jackrabbit.api.binary.BinaryDownloadOptions;
+import org.apache.jackrabbit.commons.JcrUtils;
+import org.apache.sling.api.resource.Resource;
+import org.apache.sling.api.resource.external.URIProvider.Operation;
+import org.apache.sling.api.resource.external.URIProvider.Scope;
+import org.apache.sling.testing.mock.sling.ResourceResolverType;
+import org.apache.sling.testing.mock.sling.junit.SlingContext;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Matchers;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.runners.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class BinaryDownloadUriProviderTest {
+
+    @Rule
+    public final SlingContext context = new SlingContext(ResourceResolverType.JCR_OAK);
+
+    private Session session;
+    private BinaryDownloadUriProvider uriProvider;
+    private Resource fileResource;
+
+    @Mock
+    private BinaryDownload binaryDownload;
+
+    @Mock
+    private Property property;
+
+    @Before
+    public void setUp() throws IOException, RepositoryException {
+        uriProvider = new BinaryDownloadUriProvider(false);
+        session = context.resourceResolver().adaptTo(Session.class);
+        try (InputStream input = this.getClass().getResourceAsStream("/SLING-INF/nodetypes/folder.cnd")) {
+            JcrUtils.putFile(session.getRootNode(), "test", "myMimeType", input);
+        }
+        fileResource = context.resourceResolver().getResource("/test");
+    }
+
+    @Test
+    public void testMockedProperty() throws ValueFormatException, RepositoryException, URISyntaxException {
+        uriProvider = new BinaryDownloadUriProvider(false) {
+            @Override
+            protected Property getPrimaryProperty(Node node) throws RepositoryException {
+                return property;
+            }
+        };
+        Mockito.when(property.getBinary()).thenReturn(binaryDownload);
+        URI myUri = new URI("https://example.com/mybinary");
+        Mockito.when(binaryDownload.getURI(Matchers.any(BinaryDownloadOptions.class))).thenReturn(myUri);
+
+        assertEquals(myUri, uriProvider.toURI(fileResource, Scope.EXTERNAL, Operation.READ));
+        ArgumentCaptor<BinaryDownloadOptions> argumentCaptor = ArgumentCaptor.forClass(BinaryDownloadOptions.class);
+        Mockito.verify(binaryDownload).getURI(argumentCaptor.capture());
+        assertEquals("myMimeType", argumentCaptor.getValue().getMediaType());
+        assertEquals("test", argumentCaptor.getValue().getFileName());
+        assertNull(argumentCaptor.getValue().getCharacterEncoding());
+    }
+
+    @Test
+    public void testPropertyWithoutExternallyAccessibleBlobStore() throws URISyntaxException, RepositoryException, IOException {
+         IllegalArgumentException e = assertThrows(IllegalArgumentException.class, ()-> uriProvider.toURI(fileResource, Scope.EXTERNAL, Operation.READ));
+         assertEquals("Cannot provide url for downloading the binary property at '/test/jcr:content/jcr:data'", e.getMessage());
+    }
+
+    @Test
+    public void testNoPrimaryPropertyUri() {
+        Resource resource = context.create().resource("/content/test1", Collections.singletonMap("jcr:primaryProperty", "nt:folder"));
+        IllegalArgumentException e = assertThrows(IllegalArgumentException.class, ()-> uriProvider.toURI(resource, Scope.PUBLIC, Operation.READ));
+        assertEquals("Node does not have a primary property", e.getMessage());
+    }
+
+    @Test
+    public void testUnsupportedScope() {
+        IllegalArgumentException e = assertThrows(IllegalArgumentException.class, ()-> uriProvider.toURI(fileResource, Scope.INTERNAL, Operation.READ));
+        assertEquals("This provider only provides URIs for 'READ' operations in scope 'PUBLIC' or 'EXTERNAL', but not for scope 'INTERNAL' and operation 'READ'", e.getMessage());
+    }
+
+    @Test
+    public void testUnsupportedOperation() {
+        IllegalArgumentException e = assertThrows(IllegalArgumentException.class, ()-> uriProvider.toURI(fileResource, Scope.EXTERNAL, Operation.UPDATE));
+        assertEquals("This provider only provides URIs for 'READ' operations in scope 'PUBLIC' or 'EXTERNAL', but not for scope 'EXTERNAL' and operation 'UPDATE'", e.getMessage());
+    }
+}