Merge branch 'last-working' into next
authorDavid ‘Bombe’ Roden <bombe@pterodactylus.net>
Fri, 12 Jun 2015 18:47:55 +0000 (20:47 +0200)
committerDavid ‘Bombe’ Roden <bombe@pterodactylus.net>
Fri, 12 Jun 2015 21:01:04 +0000 (23:01 +0200)
This is the current state with lots and lots of changes since the last
release. I have been using this state during the last year and it should
work pretty much the same as before (minus a couple of bugs).

Conflicts:
src/main/java/net/pterodactylus/sone/core/FreenetInterface.java
src/main/java/net/pterodactylus/sone/core/SoneInserter.java
src/main/java/net/pterodactylus/sone/freenet/PluginStoreConfigurationBackend.java

1  2 
pom.xml
src/main/java/net/pterodactylus/sone/core/FreenetInterface.java
src/main/java/net/pterodactylus/sone/core/SoneDownloaderImpl.java
src/main/java/net/pterodactylus/sone/core/SoneInserter.java
src/main/java/net/pterodactylus/sone/freenet/PluginStoreConfigurationBackend.java
src/main/java/net/pterodactylus/sone/main/SonePlugin.java
src/test/java/net/pterodactylus/sone/core/FreenetInterfaceTest.java
src/test/java/net/pterodactylus/sone/core/SoneInserterTest.java
src/test/java/net/pterodactylus/sone/core/UpdateCheckerTest.java

diff --combined pom.xml
+++ b/pom.xml
                        <scope>test</scope>
                </dependency>
                <dependency>
+                       <groupId>org.hamcrest</groupId>
+                       <artifactId>hamcrest-all</artifactId>
+                       <version>1.3</version>
+               </dependency>
+               <dependency>
                        <groupId>org.freenetproject</groupId>
                        <artifactId>fred</artifactId>
 -                      <version>0.7.5.1405</version>
 +                      <version>0.7.5.1467.99.3</version>
                        <scope>provided</scope>
                </dependency>
                <dependency>
                        <groupId>org.freenetproject</groupId>
                        <artifactId>freenet-ext</artifactId>
 -                      <version>26</version>
 +                      <version>29</version>
                        <scope>provided</scope>
                </dependency>
                <dependency>
@@@ -41,7 -46,7 +46,7 @@@
                <dependency>
                        <groupId>com.google.guava</groupId>
                        <artifactId>guava</artifactId>
-                       <version>14.0-rc1</version>
+                       <version>14.0.1</version>
                </dependency>
                <dependency>
                        <groupId>commons-lang</groupId>
                                        <footer>© 2010–2013 David ‘Bombe’ Roden</footer>
                                </configuration>
                        </plugin>
+                       <plugin>
+                               <groupId>org.jacoco</groupId>
+                               <artifactId>jacoco-maven-plugin</artifactId>
+                               <version>0.7.1.201405082137</version>
+                               <executions>
+                                       <execution>
+                                               <id>default-prepare-agent</id>
+                                               <goals>
+                                                       <goal>prepare-agent</goal>
+                                               </goals>
+                                       </execution>
+                                       <execution>
+                                               <id>default-report</id>
+                                               <phase>prepare-package</phase>
+                                               <goals>
+                                                       <goal>report</goal>
+                                               </goals>
+                                       </execution>
+                                       <execution>
+                                               <id>default-check</id>
+                                               <goals>
+                                                       <goal>check</goal>
+                                               </goals>
+                                               <configuration>
+                                                       <rules>
+                                                               <!--  implmentation is needed only for Maven 2  -->
+                                                               <rule implementation="org.jacoco.maven.RuleConfiguration">
+                                                                       <element>BUNDLE</element>
+                                                                       <limits>
+                                                                               <!--  implmentation is needed only for Maven 2  -->
+                                                                               <limit implementation="org.jacoco.report.check.Limit">
+                                                                                       <counter>COMPLEXITY</counter>
+                                                                                       <value>COVEREDRATIO</value>
+                                                                                       <minimum>0.60</minimum>
+                                                                               </limit>
+                                                                       </limits>
+                                                               </rule>
+                                                       </rules>
+                                               </configuration>
+                                       </execution>
+                               </executions>
+                       </plugin>
                </plugins>
        </build>
  </project>
  
  package net.pterodactylus.sone.core;
  
+ import static freenet.keys.USK.create;
+ import static java.lang.String.format;
+ import static java.util.logging.Level.WARNING;
+ import static java.util.logging.Logger.getLogger;
+ import static net.pterodactylus.sone.freenet.Key.routingKey;
  import java.net.MalformedURLException;
  import java.util.Collections;
  import java.util.HashMap;
  import java.util.Map;
- import java.util.concurrent.TimeUnit;
  import java.util.logging.Level;
  import java.util.logging.Logger;
  
@@@ -32,17 -37,18 +37,17 @@@ import net.pterodactylus.sone.core.even
  import net.pterodactylus.sone.data.Image;
  import net.pterodactylus.sone.data.Sone;
  import net.pterodactylus.sone.data.TemporaryImage;
- import net.pterodactylus.util.logging.Logging;
  
 -import com.db4o.ObjectContainer;
 -
+ import com.google.common.base.Function;
  import com.google.common.eventbus.EventBus;
  import com.google.inject.Inject;
+ import com.google.inject.Singleton;
  
  import freenet.client.ClientMetadata;
  import freenet.client.FetchException;
 +import freenet.client.FetchException.FetchExceptionMode;
  import freenet.client.FetchResult;
  import freenet.client.HighLevelSimpleClient;
- import freenet.client.HighLevelSimpleClientImpl;
  import freenet.client.InsertBlock;
  import freenet.client.InsertContext;
  import freenet.client.InsertException;
@@@ -58,19 -64,18 +63,20 @@@ import freenet.node.Node
  import freenet.node.RequestClient;
  import freenet.node.RequestStarter;
  import freenet.support.api.Bucket;
 +import freenet.support.api.RandomAccessBucket;
  import freenet.support.io.ArrayBucket;
 +import freenet.support.io.ResumeFailedException;
  
  /**
   * Contains all necessary functionality for interacting with the Freenet node.
   *
   * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
   */
+ @Singleton
  public class FreenetInterface {
  
        /** The logger. */
-       private static final Logger logger = Logging.getLogger(FreenetInterface.class);
+       private static final Logger logger = getLogger("Sone.FreenetInterface");
  
        /** The event bus. */
        private final EventBus eventBus;
        /** The not-Sone-related USK callbacks. */
        private final Map<FreenetURI, USKCallback> uriUskCallbacks = Collections.synchronizedMap(new HashMap<FreenetURI, USKCallback>());
  
 +      private final RequestClient imageInserts = new RequestClient() {
 +              @Override
 +              public boolean persistent() {
 +                      return false;
 +              }
 +
 +              @Override
 +              public boolean realTimeFlag() {
 +                      return true;
 +              }
 +      };
 +
        /**
         * Creates a new Freenet interface.
         *
         * @return The result of the fetch, or {@code null} if an error occured
         */
        public Fetched fetchUri(FreenetURI uri) {
-               FetchResult fetchResult = null;
                FreenetURI currentUri = new FreenetURI(uri);
                while (true) {
                        try {
-                               fetchResult = client.fetch(currentUri);
+                               FetchResult fetchResult = client.fetch(currentUri);
                                return new Fetched(currentUri, fetchResult);
                        } catch (FetchException fe1) {
 -                              if (fe1.getMode() == FetchException.PERMANENT_REDIRECT) {
 +                              if (fe1.getMode() == FetchExceptionMode.PERMANENT_REDIRECT) {
                                        currentUri = fe1.newURI;
                                        continue;
                                }
        }
  
        /**
-        * Creates a key pair.
-        *
-        * @return The request key at index 0, the insert key at index 1
-        */
-       public String[] generateKeyPair() {
-               FreenetURI[] keyPair = client.generateKeyPair("");
-               return new String[] { keyPair[1].toString(), keyPair[0].toString() };
-       }
-       /**
         * Inserts the image data of the given {@link TemporaryImage} and returns
         * the given insert token that can be used to add listeners or cancel the
         * insert.
                InsertableClientSSK key = InsertableClientSSK.createRandom(node.random, "");
                FreenetURI targetUri = key.getInsertURI().setDocName(filenameHint);
                InsertContext insertContext = client.getInsertContext(true);
 -              Bucket bucket = new ArrayBucket(temporaryImage.getImageData());
 +              RandomAccessBucket bucket = new ArrayBucket(temporaryImage.getImageData());
 +              insertToken.setBucket(bucket);
                ClientMetadata metadata = new ClientMetadata(temporaryImage.getMimeType());
                InsertBlock insertBlock = new InsertBlock(bucket, metadata, targetUri);
                try {
 -                      ClientPutter clientPutter = client.insert(insertBlock, false, null, false, insertContext, insertToken, RequestStarter.INTERACTIVE_PRIORITY_CLASS);
 +                      ClientPutter clientPutter = client.insert(insertBlock, null, false, insertContext, insertToken, RequestStarter.INTERACTIVE_PRIORITY_CLASS);
                        insertToken.setClientPutter(clientPutter);
                } catch (InsertException ie1) {
                        throw new SoneInsertException("Could not start image insert.", ie1);
                }
        }
  
-       /**
-        * Registers the USK for the given Sone and notifies the given
-        * {@link SoneDownloader} if an update was found.
-        *
-        * @param sone
-        *            The Sone to watch
-        * @param soneDownloader
-        *            The Sone download to notify on updates
-        */
-       public void registerUsk(final Sone sone, final SoneDownloader soneDownloader) {
+       public void registerActiveUsk(FreenetURI requestUri,
+                       USKCallback uskCallback) {
                try {
-                       logger.log(Level.FINE, String.format("Registering Sone “%s” for USK updates at %s…", sone, sone.getRequestUri().setMetaString(new String[] { "sone.xml" })));
-                       USKCallback uskCallback = new USKCallback() {
-                               @Override
-                               @SuppressWarnings("synthetic-access")
-                               public void onFoundEdition(long edition, USK key, ClientContext clientContext, boolean metadata, short codec, byte[] data, boolean newKnownGood, boolean newSlotToo) {
-                                       logger.log(Level.FINE, String.format("Found USK update for Sone “%s” at %s, new known good: %s, new slot too: %s.", sone, key, newKnownGood, newSlotToo));
-                                       if (edition > sone.getLatestEdition()) {
-                                               sone.setLatestEdition(edition);
-                                               new Thread(new Runnable() {
-                                                       @Override
-                                                       public void run() {
-                                                               soneDownloader.fetchSone(sone);
-                                                       }
-                                               }, "Sone Downloader").start();
-                                       }
-                               }
-                               @Override
-                               public short getPollingPriorityProgress() {
-                                       return RequestStarter.INTERACTIVE_PRIORITY_CLASS;
-                               }
+                       soneUskCallbacks.put(routingKey(requestUri), uskCallback);
+                       node.clientCore.uskManager.subscribe(create(requestUri),
+                                       uskCallback, true, (RequestClient) client);
+               } catch (MalformedURLException mue1) {
+                       logger.log(WARNING, format("Could not subscribe USK “%s”!",
+                                       requestUri), mue1);
+               }
+       }
  
-                               @Override
-                               public short getPollingPriorityNormal() {
-                                       return RequestStarter.INTERACTIVE_PRIORITY_CLASS;
-                               }
-                       };
-                       soneUskCallbacks.put(sone.getId(), uskCallback);
-                       boolean runBackgroundFetch = (System.currentTimeMillis() - sone.getTime()) < TimeUnit.DAYS.toMillis(7);
-                       node.clientCore.uskManager.subscribe(USK.create(sone.getRequestUri()), uskCallback, runBackgroundFetch, (HighLevelSimpleClientImpl) client);
+       public void registerPassiveUsk(FreenetURI requestUri,
+                       USKCallback uskCallback) {
+               try {
+                       soneUskCallbacks.put(routingKey(requestUri), uskCallback);
+                       node.clientCore
+                                       .uskManager
+                                       .subscribe(create(requestUri), uskCallback, false,
+                                                       (RequestClient) client);
                } catch (MalformedURLException mue1) {
-                       logger.log(Level.WARNING, String.format("Could not subscribe USK “%s”!", sone.getRequestUri()), mue1);
+                       logger.log(WARNING,
+                                       format("Could not subscribe USK “%s”!", requestUri),
+                                       mue1);
                }
        }
  
                USKCallback uskCallback = new USKCallback() {
  
                        @Override
 -                      public void onFoundEdition(long edition, USK key, ObjectContainer objectContainer, ClientContext clientContext, boolean metadata, short codec, byte[] data, boolean newKnownGood, boolean newSlotToo) {
 +                      public void onFoundEdition(long edition, USK key, ClientContext clientContext, boolean metadata, short codec, byte[] data, boolean newKnownGood, boolean newSlotToo) {
                                callback.editionFound(key.getURI(), edition, newKnownGood, newSlotToo);
                        }
  
  
                };
                try {
-                       node.clientCore.uskManager.subscribe(USK.create(uri), uskCallback, true, (HighLevelSimpleClientImpl) client);
+                       node.clientCore.uskManager.subscribe(USK.create(uri), uskCallback, true, (RequestClient) client);
                        uriUskCallbacks.put(uri, uskCallback);
                } catch (MalformedURLException mue1) {
                        logger.log(Level.WARNING, String.format("Could not subscribe to USK: %s", uri), mue1);
  
                /** The client putter. */
                private ClientPutter clientPutter;
 +              private Bucket bucket;
  
                /** The final URI. */
                private volatile FreenetURI resultingUri;
                        eventBus.post(new ImageInsertStartedEvent(image));
                }
  
 +              public void setBucket(Bucket bucket) {
 +                      this.bucket = bucket;
 +              }
 +
                //
                // ACTIONS
                //
                 */
                @SuppressWarnings("synthetic-access")
                public void cancel() {
 -                      clientPutter.cancel(null, node.clientCore.clientContext);
 +                      clientPutter.cancel(node.clientCore.clientContext);
                        eventBus.post(new ImageInsertAbortedEvent(image));
++                      bucket.free();
                }
  
                //
                // INTERFACE ClientPutCallback
                //
  
 -              /**
 -               * {@inheritDoc}
 -               */
                @Override
 -              public void onMajorProgress(ObjectContainer objectContainer) {
 -                      /* ignore, we don’t care. */
 +              public RequestClient getRequestClient() {
 +                      return imageInserts;
 +              }
 +
 +              @Override
 +              public void onResume(ClientContext context) throws ResumeFailedException {
 +                      /* ignore. */
                }
  
                /**
                 */
                @Override
                @SuppressWarnings("synthetic-access")
 -              public void onFailure(InsertException insertException, BaseClientPutter clientPutter, ObjectContainer objectContainer) {
 +              public void onFailure(InsertException insertException, BaseClientPutter clientPutter) {
                        if ((insertException != null) && ("Cancelled by user".equals(insertException.getMessage()))) {
                                eventBus.post(new ImageInsertAbortedEvent(image));
                        } else {
                                eventBus.post(new ImageInsertFailedEvent(image, insertException));
                        }
 +                      bucket.free();
                }
  
                /**
                 * {@inheritDoc}
                 */
                @Override
 -              public void onFetchable(BaseClientPutter clientPutter, ObjectContainer objectContainer) {
 +              public void onFetchable(BaseClientPutter clientPutter) {
                        /* ignore, we don’t care. */
                }
  
                 * {@inheritDoc}
                 */
                @Override
 -              public void onGeneratedMetadata(Bucket metadata, BaseClientPutter clientPutter, ObjectContainer objectContainer) {
 +              public void onGeneratedMetadata(Bucket metadata, BaseClientPutter clientPutter) {
                        /* ignore, we don’t care. */
                }
  
                 * {@inheritDoc}
                 */
                @Override
 -              public void onGeneratedURI(FreenetURI generatedUri, BaseClientPutter clientPutter, ObjectContainer objectContainer) {
 +              public void onGeneratedURI(FreenetURI generatedUri, BaseClientPutter clientPutter) {
                        resultingUri = generatedUri;
                }
  
                 */
                @Override
                @SuppressWarnings("synthetic-access")
 -              public void onSuccess(BaseClientPutter clientPutter, ObjectContainer objectContainer) {
 +              public void onSuccess(BaseClientPutter clientPutter) {
                        eventBus.post(new ImageInsertFinishedEvent(image, resultingUri));
 +                      bucket.free();
                }
  
        }
  
+       public class InsertTokenSupplier implements Function<Image, InsertToken> {
+               @Override
+               public InsertToken apply(Image image) {
+                       return new InsertToken(image);
+               }
+       }
  }
index 0000000,ff0eaf7..b36c2d3
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,275 +1,274 @@@
 -                                      ObjectContainer objectContainer,
+ /*
+  * Sone - SoneDownloader.java - Copyright © 2010–2013 David Roden
+  *
+  * This program is free software: you can redistribute it and/or modify
+  * it under the terms of the GNU General Public License as published by
+  * the Free Software Foundation, either version 3 of the License, or
+  * (at your option) any later version.
+  *
+  * This program is distributed in the hope that it will be useful,
+  * but WITHOUT ANY WARRANTY; without even the implied warranty of
+  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  * GNU General Public License for more details.
+  *
+  * You should have received a copy of the GNU General Public License
+  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+  */
+ package net.pterodactylus.sone.core;
+ import static freenet.support.io.Closer.close;
+ import static java.lang.String.format;
+ import static java.lang.System.currentTimeMillis;
+ import static java.util.concurrent.TimeUnit.DAYS;
+ import static java.util.logging.Logger.getLogger;
+ import java.io.InputStream;
+ import java.util.HashSet;
+ import java.util.Set;
+ import java.util.logging.Level;
+ import java.util.logging.Logger;
+ import net.pterodactylus.sone.core.FreenetInterface.Fetched;
+ import net.pterodactylus.sone.data.Sone;
+ import net.pterodactylus.sone.data.Sone.SoneStatus;
+ import net.pterodactylus.util.service.AbstractService;
+ import freenet.client.FetchResult;
+ import freenet.client.async.ClientContext;
+ import freenet.client.async.USKCallback;
+ import freenet.keys.FreenetURI;
+ import freenet.keys.USK;
+ import freenet.node.RequestStarter;
+ import freenet.support.api.Bucket;
+ import freenet.support.io.Closer;
+ import com.db4o.ObjectContainer;
+ import com.google.common.annotations.VisibleForTesting;
+ /**
+  * The Sone downloader is responsible for download Sones as they are updated.
+  *
+  * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+  */
+ public class SoneDownloaderImpl extends AbstractService implements SoneDownloader {
+       /** The logger. */
+       private static final Logger logger = getLogger("Sone.Downloader");
+       /** The maximum protocol version. */
+       private static final int MAX_PROTOCOL_VERSION = 0;
+       /** The core. */
+       private final Core core;
+       private final SoneParser soneParser;
+       /** The Freenet interface. */
+       private final FreenetInterface freenetInterface;
+       /** The sones to update. */
+       private final Set<Sone> sones = new HashSet<Sone>();
+       /**
+        * Creates a new Sone downloader.
+        *
+        * @param core
+        *              The core
+        * @param freenetInterface
+        *              The Freenet interface
+        */
+       public SoneDownloaderImpl(Core core, FreenetInterface freenetInterface) {
+               this(core, freenetInterface, new SoneParser(core));
+       }
+       /**
+        * Creates a new Sone downloader.
+        *
+        * @param core
+        *              The core
+        * @param freenetInterface
+        *              The Freenet interface
+        * @param soneParser
+        */
+       @VisibleForTesting
+       SoneDownloaderImpl(Core core, FreenetInterface freenetInterface, SoneParser soneParser) {
+               super("Sone Downloader", false);
+               this.core = core;
+               this.freenetInterface = freenetInterface;
+               this.soneParser = soneParser;
+       }
+       //
+       // ACTIONS
+       //
+       /**
+        * Adds the given Sone to the set of Sones that will be watched for updates.
+        *
+        * @param sone
+        *              The Sone to add
+        */
+       @Override
+       public void addSone(final Sone sone) {
+               if (!sones.add(sone)) {
+                       freenetInterface.unregisterUsk(sone);
+               }
+               final USKCallback uskCallback = new USKCallback() {
+                       @Override
+                       @SuppressWarnings("synthetic-access")
+                       public void onFoundEdition(long edition, USK key,
+                                       ClientContext clientContext, boolean metadata,
+                                       short codec, byte[] data, boolean newKnownGood,
+                                       boolean newSlotToo) {
+                               logger.log(Level.FINE, format(
+                                               "Found USK update for Sone “%s” at %s, new known good: %s, new slot too: %s.",
+                                               sone, key, newKnownGood, newSlotToo));
+                               if (edition > sone.getLatestEdition()) {
+                                       sone.setLatestEdition(edition);
+                                       new Thread(fetchSoneAction(sone),
+                                                       "Sone Downloader").start();
+                               }
+                       }
+                       @Override
+                       public short getPollingPriorityProgress() {
+                               return RequestStarter.INTERACTIVE_PRIORITY_CLASS;
+                       }
+                       @Override
+                       public short getPollingPriorityNormal() {
+                               return RequestStarter.INTERACTIVE_PRIORITY_CLASS;
+                       }
+               };
+               if (soneHasBeenActiveRecently(sone)) {
+                       freenetInterface.registerActiveUsk(sone.getRequestUri(),
+                                       uskCallback);
+               } else {
+                       freenetInterface.registerPassiveUsk(sone.getRequestUri(),
+                                       uskCallback);
+               }
+       }
+       private boolean soneHasBeenActiveRecently(Sone sone) {
+               return (currentTimeMillis() - sone.getTime()) < DAYS.toMillis(7);
+       }
+       private void fetchSone(Sone sone) {
+               fetchSone(sone, sone.getRequestUri().sskForUSK());
+       }
+       /**
+        * Fetches the updated Sone. This method can be used to fetch a Sone from a
+        * specific URI.
+        *
+        * @param sone
+        *              The Sone to fetch
+        * @param soneUri
+        *              The URI to fetch the Sone from
+        */
+       @Override
+       public void fetchSone(Sone sone, FreenetURI soneUri) {
+               fetchSone(sone, soneUri, false);
+       }
+       /**
+        * Fetches the Sone from the given URI.
+        *
+        * @param sone
+        *              The Sone to fetch
+        * @param soneUri
+        *              The URI of the Sone to fetch
+        * @param fetchOnly
+        *              {@code true} to only fetch and parse the Sone, {@code false}
+        *              to {@link Core#updateSone(Sone) update} it in the core
+        * @return The downloaded Sone, or {@code null} if the Sone could not be
+        *         downloaded
+        */
+       @Override
+       public Sone fetchSone(Sone sone, FreenetURI soneUri, boolean fetchOnly) {
+               logger.log(Level.FINE, String.format("Starting fetch for Sone “%s” from %s…", sone, soneUri));
+               FreenetURI requestUri = soneUri.setMetaString(new String[] { "sone.xml" });
+               sone.setStatus(SoneStatus.downloading);
+               try {
+                       Fetched fetchResults = freenetInterface.fetchUri(requestUri);
+                       if (fetchResults == null) {
+                               /* TODO - mark Sone as bad. */
+                               return null;
+                       }
+                       logger.log(Level.FINEST, String.format("Got %d bytes back.", fetchResults.getFetchResult().size()));
+                       Sone parsedSone = parseSone(sone, fetchResults.getFetchResult(), fetchResults.getFreenetUri());
+                       if (parsedSone != null) {
+                               if (!fetchOnly) {
+                                       parsedSone.setStatus((parsedSone.getTime() == 0) ? SoneStatus.unknown : SoneStatus.idle);
+                                       core.updateSone(parsedSone);
+                                       addSone(parsedSone);
+                               }
+                       }
+                       return parsedSone;
+               } finally {
+                       sone.setStatus((sone.getTime() == 0) ? SoneStatus.unknown : SoneStatus.idle);
+               }
+       }
+       /**
+        * Parses a Sone from a fetch result.
+        *
+        * @param originalSone
+        *              The sone to parse, or {@code null} if the Sone is yet unknown
+        * @param fetchResult
+        *              The fetch result
+        * @param requestUri
+        *              The requested URI
+        * @return The parsed Sone, or {@code null} if the Sone could not be parsed
+        */
+       private Sone parseSone(Sone originalSone, FetchResult fetchResult, FreenetURI requestUri) {
+               logger.log(Level.FINEST, String.format("Parsing FetchResult (%d bytes, %s) for %s…", fetchResult.size(), fetchResult.getMimeType(), originalSone));
+               Bucket soneBucket = fetchResult.asBucket();
+               InputStream soneInputStream = null;
+               try {
+                       soneInputStream = soneBucket.getInputStream();
+                       Sone parsedSone = soneParser.parseSone(originalSone,
+                                       soneInputStream);
+                       if (parsedSone != null) {
+                               parsedSone.setLatestEdition(requestUri.getEdition());
+                       }
+                       return parsedSone;
+               } catch (Exception e1) {
+                       logger.log(Level.WARNING, String.format("Could not parse Sone from %s!", requestUri), e1);
+               } finally {
+                       close(soneInputStream);
+                       close(soneBucket);
+               }
+               return null;
+       }
+       @Override
+       public Runnable fetchSoneWithUriAction(final Sone sone) {
+               return new Runnable() {
+                       @Override
+                       public void run() {
+                               fetchSone(sone, sone.getRequestUri());
+                       }
+               };
+       }
+       @Override
+       public Runnable fetchSoneAction(final Sone sone) {
+               return new Runnable() {
+                       @Override
+                       public void run() {
+                               fetchSone(sone);
+                       }
+               };
+       }
+       /** {@inheritDoc} */
+       @Override
+       protected void serviceStop() {
+               for (Sone sone : sones) {
+                       freenetInterface.unregisterUsk(sone);
+               }
+       }
+ }
  
  package net.pterodactylus.sone.core;
  
- import static com.google.common.base.Preconditions.checkArgument;
+ import static java.lang.String.format;
+ import static java.lang.System.currentTimeMillis;
+ import static java.util.logging.Logger.getLogger;
  import static net.pterodactylus.sone.data.Album.NOT_EMPTY;
  
++import java.io.Closeable;
+ import java.io.InputStream;
  import java.io.InputStreamReader;
  import java.io.StringWriter;
  import java.nio.charset.Charset;
  import java.util.HashMap;
  import java.util.HashSet;
  import java.util.Map;
 +import java.util.Set;
+ import java.util.concurrent.atomic.AtomicInteger;
  import java.util.logging.Level;
  import java.util.logging.Logger;
  
+ import net.pterodactylus.sone.core.SoneModificationDetector.LockableFingerprintProvider;
+ import net.pterodactylus.sone.core.event.InsertionDelayChangedEvent;
  import net.pterodactylus.sone.core.event.SoneInsertAbortedEvent;
  import net.pterodactylus.sone.core.event.SoneInsertedEvent;
  import net.pterodactylus.sone.core.event.SoneInsertingEvent;
@@@ -38,9 -43,9 +45,8 @@@ import net.pterodactylus.sone.data.Post
  import net.pterodactylus.sone.data.Reply;
  import net.pterodactylus.sone.data.Sone;
  import net.pterodactylus.sone.data.Sone.SoneStatus;
 -import net.pterodactylus.sone.freenet.StringBucket;
  import net.pterodactylus.sone.main.SonePlugin;
  import net.pterodactylus.util.io.Closer;
- import net.pterodactylus.util.logging.Logging;
  import net.pterodactylus.util.service.AbstractService;
  import net.pterodactylus.util.template.HtmlFilter;
  import net.pterodactylus.util.template.ReflectionAccessor;
@@@ -51,16 -56,15 +57,19 @@@ import net.pterodactylus.util.template.
  import net.pterodactylus.util.template.TemplateParser;
  import net.pterodactylus.util.template.XmlFilter;
  
+ import com.google.common.annotations.VisibleForTesting;
 +import com.google.common.base.Charsets;
+ import com.google.common.base.Optional;
  import com.google.common.collect.FluentIterable;
  import com.google.common.collect.Ordering;
  import com.google.common.eventbus.EventBus;
+ import com.google.common.eventbus.Subscribe;
  
 -import freenet.client.async.ManifestElement;
  import freenet.keys.FreenetURI;
 +import freenet.support.api.Bucket;
 +import freenet.support.api.ManifestElement;
 +import freenet.support.api.RandomAccessBucket;
 +import freenet.support.io.ArrayBucket;
  
  /**
   * A Sone inserter is responsible for inserting a Sone if it has changed.
  public class SoneInserter extends AbstractService {
  
        /** The logger. */
-       private static final Logger logger = Logging.getLogger(SoneInserter.class);
+       private static final Logger logger = getLogger("Sone.Inserter");
  
        /** The insertion delay (in seconds). */
-       private static volatile int insertionDelay = 60;
+       private static final AtomicInteger insertionDelay = new AtomicInteger(60);
  
        /** The template factory used to create the templates. */
        private static final TemplateContextFactory templateContextFactory = new TemplateContextFactory();
        /** The Freenet interface. */
        private final FreenetInterface freenetInterface;
  
-       /** The Sone to insert. */
-       private volatile Sone sone;
-       /** Whether a modification has been detected. */
-       private volatile boolean modified = false;
-       /** The fingerprint of the last insert. */
-       private volatile String lastInsertFingerprint;
+       private final SoneModificationDetector soneModificationDetector;
+       private final long delay;
+       private final String soneId;
  
        /**
         * Creates a new Sone inserter.
         *            The event bus
         * @param freenetInterface
         *            The freenet interface
-        * @param sone
-        *            The Sone to insert
+        * @param soneId
+        *            The ID of the Sone to insert
         */
-       public SoneInserter(Core core, EventBus eventBus, FreenetInterface freenetInterface, Sone sone) {
-               super("Sone Inserter for “" + sone.getName() + "”", false);
+       public SoneInserter(final Core core, EventBus eventBus, FreenetInterface freenetInterface, final String soneId) {
+               this(core, eventBus, freenetInterface, soneId, new SoneModificationDetector(new LockableFingerprintProvider() {
+                       @Override
+                       public boolean isLocked() {
+                               final Optional<Sone> sone = core.getSone(soneId);
+                               if (!sone.isPresent()) {
+                                       return false;
+                               }
+                               return core.isLocked(sone.get());
+                       }
+                       @Override
+                       public String getFingerprint() {
+                               final Optional<Sone> sone = core.getSone(soneId);
+                               if (!sone.isPresent()) {
+                                       return null;
+                               }
+                               return sone.get().getFingerprint();
+                       }
+               }, insertionDelay), 1000);
+       }
+       @VisibleForTesting
+       SoneInserter(Core core, EventBus eventBus, FreenetInterface freenetInterface, String soneId, SoneModificationDetector soneModificationDetector, long delay) {
+               super("Sone Inserter for “" + soneId + "”", false);
                this.core = core;
                this.eventBus = eventBus;
                this.freenetInterface = freenetInterface;
-               this.sone = sone;
+               this.soneId = soneId;
+               this.soneModificationDetector = soneModificationDetector;
+               this.delay = delay;
        }
  
        //
        // ACCESSORS
        //
  
-       /**
-        * Sets the Sone to insert.
-        *
-        * @param sone
-        *              The Sone to insert
-        * @return This Sone inserter
-        */
-       public SoneInserter setSone(Sone sone) {
-               checkArgument((this.sone == null) || sone.equals(this.sone), "Sone to insert can not be set to a different Sone");
-               this.sone = sone;
-               return this;
+       @VisibleForTesting
+       static AtomicInteger getInsertionDelay() {
+               return insertionDelay;
        }
  
        /**
         * @param insertionDelay
         *            The insertion delay (in seconds)
         */
-       public static void setInsertionDelay(int insertionDelay) {
-               SoneInserter.insertionDelay = insertionDelay;
+       private static void setInsertionDelay(int insertionDelay) {
+               SoneInserter.insertionDelay.set(insertionDelay);
        }
  
        /**
         * @return The fingerprint of the last insert
         */
        public String getLastInsertFingerprint() {
-               return lastInsertFingerprint;
+               return soneModificationDetector.getOriginalFingerprint();
        }
  
        /**
         *            The fingerprint of the last insert
         */
        public void setLastInsertFingerprint(String lastInsertFingerprint) {
-               this.lastInsertFingerprint = lastInsertFingerprint;
+               soneModificationDetector.setFingerprint(lastInsertFingerprint);
        }
  
        /**
         *         otherwise
         */
        public boolean isModified() {
-               return modified;
+               return soneModificationDetector.isModified();
        }
  
        //
         */
        @Override
        protected void serviceRun() {
-               long lastModificationTime = 0;
-               String lastInsertedFingerprint = lastInsertFingerprint;
-               String lastFingerprint = "";
-               Sone sone;
                while (!shouldStop()) {
                        try {
-                               /* check every seconds. */
-                               sleep(1000);
-                               /* don’t insert locked Sones. */
-                               sone = this.sone;
-                               if (core.isLocked(sone)) {
-                                       /* trigger redetection when the Sone is unlocked. */
-                                       synchronized (sone) {
-                                               modified = !sone.getFingerprint().equals(lastInsertedFingerprint);
+                               /* check every second. */
+                               sleep(delay);
+                               if (soneModificationDetector.isEligibleForInsert()) {
+                                       Optional<Sone> soneOptional = core.getSone(soneId);
+                                       if (!soneOptional.isPresent()) {
+                                               logger.log(Level.WARNING, format("Sone %s has disappeared, exiting inserter.", soneId));
+                                               return;
                                        }
-                                       lastFingerprint = "";
-                                       lastModificationTime = 0;
-                                       continue;
-                               }
-                               InsertInformation insertInformation = null;
-                               synchronized (sone) {
-                                       String fingerprint = sone.getFingerprint();
-                                       if (!fingerprint.equals(lastFingerprint)) {
-                                               if (fingerprint.equals(lastInsertedFingerprint)) {
-                                                       modified = false;
-                                                       lastModificationTime = 0;
-                                                       logger.log(Level.FINE, String.format("Sone %s has been reverted to last insert state.", sone));
-                                               } else {
-                                                       lastModificationTime = System.currentTimeMillis();
-                                                       modified = true;
-                                                       logger.log(Level.FINE, String.format("Sone %s has been modified, waiting %d seconds before inserting.", sone.getName(), insertionDelay));
-                                               }
-                                               lastFingerprint = fingerprint;
-                                       }
-                                       if (modified && (lastModificationTime > 0) && ((System.currentTimeMillis() - lastModificationTime) > (insertionDelay * 1000))) {
-                                               lastInsertedFingerprint = fingerprint;
-                                               insertInformation = new InsertInformation(sone);
-                                       }
-                               }
-                               if (insertInformation != null) {
+                                       Sone sone = soneOptional.get();
+                                       InsertInformation insertInformation = new InsertInformation(sone);
                                        logger.log(Level.INFO, String.format("Inserting Sone “%s”…", sone.getName()));
  
                                        boolean success = false;
                                        try {
                                                sone.setStatus(SoneStatus.inserting);
-                                               long insertTime = System.currentTimeMillis();
-                                               insertInformation.setTime(insertTime);
+                                               long insertTime = currentTimeMillis();
                                                eventBus.post(new SoneInsertingEvent(sone));
-                                               FreenetURI finalUri = freenetInterface.insertDirectory(insertInformation.getInsertUri(), insertInformation.generateManifestEntries(), "index.html");
-                                               eventBus.post(new SoneInsertedEvent(sone, System.currentTimeMillis() - insertTime));
+                                               FreenetURI finalUri = freenetInterface.insertDirectory(sone.getInsertUri(), insertInformation.generateManifestEntries(), "index.html");
+                                               eventBus.post(new SoneInsertedEvent(sone, currentTimeMillis() - insertTime, insertInformation.getFingerprint()));
                                                /* at this point we might already be stopped. */
                                                if (shouldStop()) {
                                                        /* if so, bail out, don’t change anything. */
                                                eventBus.post(new SoneInsertAbortedEvent(sone, se1));
                                                logger.log(Level.WARNING, String.format("Could not insert Sone “%s”!", sone.getName()), se1);
                                        } finally {
 +                                              insertInformation.close();
                                                sone.setStatus(SoneStatus.idle);
                                        }
  
                                         */
                                        if (success) {
                                                synchronized (sone) {
-                                                       if (lastInsertedFingerprint.equals(sone.getFingerprint())) {
+                                                       if (insertInformation.getFingerprint().equals(sone.getFingerprint())) {
                                                                logger.log(Level.FINE, String.format("Sone “%s” was not modified further, resetting counter…", sone));
-                                                               lastModificationTime = 0;
-                                                               lastInsertFingerprint = lastInsertedFingerprint;
+                                                               soneModificationDetector.setFingerprint(insertInformation.getFingerprint());
                                                                core.touchConfiguration();
-                                                               modified = false;
                                                        }
                                                }
                                        }
                }
        }
  
+       @Subscribe
+       public void insertionDelayChanged(InsertionDelayChangedEvent insertionDelayChangedEvent) {
+               setInsertionDelay(insertionDelayChangedEvent.getInsertionDelay());
+       }
        /**
         * Container for information that are required to insert a Sone. This
         * container merely exists to copy all relevant data without holding a lock
         *
         * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
         */
-       private class InsertInformation {
+       @VisibleForTesting
 -      class InsertInformation {
++      class InsertInformation implements Closeable {
  
-               private final Set<Bucket> buckets = new HashSet<Bucket>();
 +              /** All properties of the Sone, copied for thread safety. */
 +              private final Map<String, Object> soneProperties = new HashMap<String, Object>();
+               private final String fingerprint;
+               private final ManifestCreator manifestCreator;
  
                /**
                 * Creates a new insert information container.
                 *            The sone to insert
                 */
                public InsertInformation(Sone sone) {
+                       this.fingerprint = sone.getFingerprint();
+                       Map<String, Object> soneProperties = new HashMap<String, Object>();
                        soneProperties.put("id", sone.getId());
                        soneProperties.put("name", sone.getName());
-                       soneProperties.put("time", sone.getTime());
+                       soneProperties.put("time", currentTimeMillis());
                        soneProperties.put("requestUri", sone.getRequestUri());
-                       soneProperties.put("insertUri", sone.getInsertUri());
                        soneProperties.put("profile", sone.getProfile());
                        soneProperties.put("posts", Ordering.from(Post.TIME_COMPARATOR).sortedCopy(sone.getPosts()));
                        soneProperties.put("replies", Ordering.from(Reply.TIME_COMPARATOR).reverse().sortedCopy(sone.getReplies()));
                        soneProperties.put("likedPostIds", new HashSet<String>(sone.getLikedPostIds()));
                        soneProperties.put("likedReplyIds", new HashSet<String>(sone.getLikedReplyIds()));
                        soneProperties.put("albums", FluentIterable.from(sone.getRootAlbum().getAlbums()).transformAndConcat(Album.FLATTENER).filter(NOT_EMPTY).toList());
+                       manifestCreator = new ManifestCreator(core, soneProperties);
                }
  
                //
                // ACCESSORS
                //
  
-               /**
-                * Returns the insert URI of the Sone.
-                *
-                * @return The insert URI of the Sone
-                */
-               public FreenetURI getInsertUri() {
-                       return (FreenetURI) soneProperties.get("insertUri");
-               }
-               /**
-                * Sets the time of the Sone at the time of the insert.
-                *
-                * @param time
-                *            The time of the Sone
-                */
-               public void setTime(long time) {
-                       soneProperties.put("time", time);
+               @VisibleForTesting
+               String getFingerprint() {
+                       return fingerprint;
                }
  
                //
                        HashMap<String, Object> manifestEntries = new HashMap<String, Object>();
  
                        /* first, create an index.html. */
-                       manifestEntries.put("index.html", createManifestElement("index.html", "text/html; charset=utf-8", "/templates/insert/index.html"));
+                       manifestEntries.put("index.html", manifestCreator.createManifestElement(
+                                       "index.html", "text/html; charset=utf-8",
+                                       "/templates/insert/index.html"));
  
                        /* now, store the sone. */
-                       manifestEntries.put("sone.xml", createManifestElement("sone.xml", "text/xml; charset=utf-8", "/templates/insert/sone.xml"));
+                       manifestEntries.put("sone.xml", manifestCreator.createManifestElement(
+                                       "sone.xml", "text/xml; charset=utf-8",
+                                       "/templates/insert/sone.xml"));
  
                        return manifestEntries;
                }
  
-               //
-               // PRIVATE METHODS
-               //
++              @Override
++              public void close() {
++                      manifestCreator.close();
++              }
 +
-               /**
-                * Creates a new manifest element.
-                *
-                * @param name
-                *            The name of the file
-                * @param contentType
-                *            The content type of the file
-                * @param templateName
-                *            The name of the template to render
-                * @return The manifest element
-                */
-               @SuppressWarnings("synthetic-access")
-               private ManifestElement createManifestElement(String name, String contentType, String templateName) {
+       }
+       /**
+        * Creates manifest elements for an insert by rendering a template.
+        *
+        * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+        */
+       @VisibleForTesting
 -      static class ManifestCreator {
++      static class ManifestCreator implements Closeable {
+               private final Core core;
+               private final Map<String, Object> soneProperties;
++              private final Set<Bucket> buckets = new HashSet<Bucket>();
+               ManifestCreator(Core core, Map<String, Object> soneProperties) {
+                       this.core = core;
+                       this.soneProperties = soneProperties;
+               }
+               public ManifestElement createManifestElement(String name, String contentType, String templateName) {
                        InputStreamReader templateInputStreamReader = null;
+                       InputStream templateInputStream = null;
                        Template template;
                        try {
-                               templateInputStreamReader = new InputStreamReader(getClass().getResourceAsStream(templateName), utf8Charset);
+                               templateInputStream = getClass().getResourceAsStream(templateName);
+                               templateInputStreamReader = new InputStreamReader(templateInputStream, utf8Charset);
                                template = TemplateParser.parse(templateInputStreamReader);
                        } catch (TemplateException te1) {
                                logger.log(Level.SEVERE, String.format("Could not parse template “%s”!", templateName), te1);
                                return null;
                        } finally {
                                Closer.close(templateInputStreamReader);
+                               Closer.close(templateInputStream);
                        }
  
                        TemplateContext templateContext = templateContextFactory.createTemplateContext();
                        templateContext.set("currentEdition", core.getUpdateChecker().getLatestEdition());
                        templateContext.set("version", SonePlugin.VERSION);
                        StringWriter writer = new StringWriter();
 -                      StringBucket bucket = null;
                        try {
                                template.render(templateContext, writer);
 -                              bucket = new StringBucket(writer.toString(), utf8Charset);
 +                              RandomAccessBucket bucket = new ArrayBucket(writer.toString().getBytes(Charsets.UTF_8));
 +                              buckets.add(bucket);
                                return new ManifestElement(name, bucket, contentType, bucket.size());
                        } catch (TemplateException te1) {
                                logger.log(Level.SEVERE, String.format("Could not render template “%s”!", templateName), te1);
                                return null;
                        } finally {
                                Closer.close(writer);
 -                              if (bucket != null) {
 -                                      bucket.free();
 -                              }
 +                      }
 +              }
 +
 +              public void close() {
 +                      for (Bucket bucket : buckets) {
 +                              bucket.free();
                        }
                }
  
  
  package net.pterodactylus.sone.freenet;
  
+ import static java.util.logging.Logger.getLogger;
  import java.util.logging.Logger;
  
  import net.pterodactylus.util.config.AttributeNotFoundException;
  import net.pterodactylus.util.config.Configuration;
  import net.pterodactylus.util.config.ConfigurationException;
  import net.pterodactylus.util.config.ExtendedConfigurationBackend;
- import net.pterodactylus.util.logging.Logging;
 -import freenet.client.async.DatabaseDisabledException;
 +import freenet.client.async.PersistenceDisabledException;
  import freenet.pluginmanager.PluginRespirator;
  import freenet.pluginmanager.PluginStore;
  
@@@ -37,7 -38,7 +38,7 @@@ public class PluginStoreConfigurationBa
  
        /** The logger. */
        @SuppressWarnings("unused")
-       private static final Logger logger = Logging.getLogger(PluginStoreConfigurationBackend.class);
+       private static final Logger logger = getLogger("Sone.Fred");
  
        /** The plugin respirator. */
        private final PluginRespirator pluginRespirator;
         *
         * @param pluginRespirator
         *            The plugin respirator
 -       * @throws DatabaseDisabledException
 +       * @throws PersistenceDisabledException
         *             if the plugin store is not available
         */
 -      public PluginStoreConfigurationBackend(PluginRespirator pluginRespirator) throws DatabaseDisabledException {
 +      public PluginStoreConfigurationBackend(PluginRespirator pluginRespirator) throws PersistenceDisabledException {
                this.pluginRespirator = pluginRespirator;
                this.pluginStore = pluginRespirator.getStore();
 -              if (this.pluginStore == null) {
 -                      throw new DatabaseDisabledException();
 -              }
        }
  
        /**
        public void save() throws ConfigurationException {
                try {
                        pluginRespirator.putStore(pluginStore);
 -              } catch (DatabaseDisabledException dde1) {
 -                      throw new ConfigurationException("Could not store plugin store, database is disabled.", dde1);
 +              } catch (PersistenceDisabledException pde1) {
 +                      throw new ConfigurationException("Could not store plugin store, persistence is disabled.", pde1);
                }
        }
  
  
  package net.pterodactylus.sone.main;
  
+ import static com.google.common.base.Optional.of;
+ import static java.util.logging.Logger.getLogger;
  import java.io.File;
+ import java.util.logging.Handler;
  import java.util.logging.Level;
  import java.util.logging.LogRecord;
  import java.util.logging.Logger;
@@@ -25,6 -29,7 +29,7 @@@
  import net.pterodactylus.sone.core.Core;
  import net.pterodactylus.sone.core.FreenetInterface;
  import net.pterodactylus.sone.core.WebOfTrustUpdater;
+ import net.pterodactylus.sone.core.WebOfTrustUpdaterImpl;
  import net.pterodactylus.sone.database.Database;
  import net.pterodactylus.sone.database.PostBuilderFactory;
  import net.pterodactylus.sone.database.PostProvider;
@@@ -34,16 -39,17 +39,17 @@@ import net.pterodactylus.sone.database.
  import net.pterodactylus.sone.fcp.FcpInterface;
  import net.pterodactylus.sone.freenet.PluginStoreConfigurationBackend;
  import net.pterodactylus.sone.freenet.plugin.PluginConnector;
+ import net.pterodactylus.sone.freenet.wot.Context;
  import net.pterodactylus.sone.freenet.wot.IdentityManager;
+ import net.pterodactylus.sone.freenet.wot.IdentityManagerImpl;
  import net.pterodactylus.sone.freenet.wot.WebOfTrustConnector;
  import net.pterodactylus.sone.web.WebInterface;
  import net.pterodactylus.util.config.Configuration;
  import net.pterodactylus.util.config.ConfigurationException;
  import net.pterodactylus.util.config.MapConfigurationBackend;
- import net.pterodactylus.util.logging.Logging;
- import net.pterodactylus.util.logging.LoggingListener;
  import net.pterodactylus.util.version.Version;
  
+ import com.google.common.base.Optional;
  import com.google.common.eventbus.EventBus;
  import com.google.inject.AbstractModule;
  import com.google.inject.Guice;
@@@ -51,12 -57,11 +57,11 @@@ import com.google.inject.Injector
  import com.google.inject.Singleton;
  import com.google.inject.TypeLiteral;
  import com.google.inject.matcher.Matchers;
- import com.google.inject.name.Names;
  import com.google.inject.spi.InjectionListener;
  import com.google.inject.spi.TypeEncounter;
  import com.google.inject.spi.TypeListener;
  
 -import freenet.client.async.DatabaseDisabledException;
 +import freenet.client.async.PersistenceDisabledException;
  import freenet.l10n.BaseL10n.LANGUAGE;
  import freenet.l10n.PluginL10n;
  import freenet.node.Node;
@@@ -81,25 -86,32 +86,32 @@@ public class SonePlugin implements Fred
  
        static {
                /* initialize logging. */
-               Logging.setup("sone");
-               Logging.addLoggingListener(new LoggingListener() {
+               Logger soneLogger = getLogger("Sone");
+               soneLogger.setUseParentHandlers(false);
+               soneLogger.addHandler(new Handler() {
                        @Override
-                       public void logged(LogRecord logRecord) {
-                               Class<?> loggerClass = Logging.getLoggerClass(logRecord.getLoggerName());
+                       public void publish(LogRecord logRecord) {
                                int recordLevel = logRecord.getLevel().intValue();
                                if (recordLevel < Level.FINE.intValue()) {
-                                       freenet.support.Logger.debug(loggerClass, logRecord.getMessage(), logRecord.getThrown());
+                                       freenet.support.Logger.debug(logRecord.getLoggerName(), logRecord.getMessage(), logRecord.getThrown());
                                } else if (recordLevel < Level.INFO.intValue()) {
-                                       freenet.support.Logger.minor(loggerClass, logRecord.getMessage(), logRecord.getThrown());
+                                       freenet.support.Logger.minor(logRecord.getLoggerName(), logRecord.getMessage(), logRecord.getThrown());
                                } else if (recordLevel < Level.WARNING.intValue()) {
-                                       freenet.support.Logger.normal(loggerClass, logRecord.getMessage(), logRecord.getThrown());
+                                       freenet.support.Logger.normal(logRecord.getLoggerName(), logRecord.getMessage(), logRecord.getThrown());
                                } else if (recordLevel < Level.SEVERE.intValue()) {
-                                       freenet.support.Logger.warning(loggerClass, logRecord.getMessage(), logRecord.getThrown());
+                                       freenet.support.Logger.warning(logRecord.getLoggerName(), logRecord.getMessage(), logRecord.getThrown());
                                } else {
-                                       freenet.support.Logger.error(loggerClass, logRecord.getMessage(), logRecord.getThrown());
+                                       freenet.support.Logger.error(logRecord.getLoggerName(), logRecord.getMessage(), logRecord.getThrown());
                                }
                        }
+                       @Override
+                       public void flush() {
+                       }
+                       @Override
+                       public void close() {
+                       }
                });
        }
  
        public static final Version VERSION = new Version(0, 8, 9);
  
        /** The logger. */
-       private static final Logger logger = Logging.getLogger(SonePlugin.class);
+       private static final Logger logger = getLogger("Sone.Plugin");
  
        /** The plugin respirator. */
        private PluginRespirator pluginRespirator;
                        try {
                                oldConfiguration = new Configuration(new PluginStoreConfigurationBackend(pluginRespirator));
                                logger.log(Level.INFO, "Plugin store loaded.");
 -                      } catch (DatabaseDisabledException dde1) {
 +                      } catch (PersistenceDisabledException pde1) {
                                logger.log(Level.SEVERE, "Could not load any configuration, using empty configuration!");
                                oldConfiguration = new Configuration(new MapConfigurationBackend());
                        }
                }
  
-               final Configuration startConfiguration = oldConfiguration;
+               final Configuration startConfiguration;
+               if ((newConfiguration != null) && (oldConfiguration != newConfiguration)) {
+                       logger.log(Level.INFO, "Setting configuration to file-based configuration.");
+                       startConfiguration = newConfiguration;
+               } else {
+                       startConfiguration = oldConfiguration;
+               }
                final EventBus eventBus = new EventBus();
  
                /* Freenet injector configuration. */
  
                        @Override
                        protected void configure() {
-                               bind(Core.class).in(Singleton.class);
-                               bind(MemoryDatabase.class).in(Singleton.class);
                                bind(EventBus.class).toInstance(eventBus);
                                bind(Configuration.class).toInstance(startConfiguration);
-                               bind(FreenetInterface.class).in(Singleton.class);
-                               bind(PluginConnector.class).in(Singleton.class);
-                               bind(WebOfTrustConnector.class).in(Singleton.class);
-                               bind(WebOfTrustUpdater.class).in(Singleton.class);
-                               bind(IdentityManager.class).in(Singleton.class);
-                               bind(String.class).annotatedWith(Names.named("WebOfTrustContext")).toInstance("Sone");
+                               Context context = new Context("Sone");
+                               bind(Context.class).toInstance(context);
+                               bind(getOptionalContextTypeLiteral()).toInstance(of(context));
                                bind(SonePlugin.class).toInstance(SonePlugin.this);
-                               bind(FcpInterface.class).in(Singleton.class);
-                               bind(Database.class).to(MemoryDatabase.class);
-                               bind(PostBuilderFactory.class).to(MemoryDatabase.class);
-                               bind(PostReplyBuilderFactory.class).to(MemoryDatabase.class);
-                               bind(SoneProvider.class).to(Core.class).in(Singleton.class);
-                               bind(PostProvider.class).to(MemoryDatabase.class);
                                bindListener(Matchers.any(), new TypeListener() {
  
                                        @Override
                                });
                        }
  
+                       private TypeLiteral<Optional<Context>> getOptionalContextTypeLiteral() {
+                               return new TypeLiteral<Optional<Context>>() {
+                               };
+                       }
                };
                Injector injector = Guice.createInjector(freenetModule, soneModule);
                core = injector.getInstance(Core.class);
  
                /* create FCP interface. */
                fcpInterface = injector.getInstance(FcpInterface.class);
-               core.setFcpInterface(fcpInterface);
  
                /* create the web interface. */
                webInterface = injector.getInstance(WebInterface.class);
  
-               boolean startupFailed = true;
-               try {
-                       /* start core! */
-                       core.start();
-                       if ((newConfiguration != null) && (oldConfiguration != newConfiguration)) {
-                               logger.log(Level.INFO, "Setting configuration to file-based configuration.");
-                               core.setConfiguration(newConfiguration);
-                       }
-                       webInterface.start();
-                       webInterface.setFirstStart(firstStart);
-                       webInterface.setNewConfig(newConfig);
-                       startupFailed = false;
-               } finally {
-                       if (startupFailed) {
-                               /*
-                                * we let the exception bubble up but shut the logging down so
-                                * that the logfile is not swamped by the installed logging
-                                * handlers of the failed instances.
-                                */
-                               Logging.shutdown();
-                       }
-               }
+               /* start core! */
+               core.start();
+               webInterface.start();
+               webInterface.setFirstStart(firstStart);
+               webInterface.setNewConfig(newConfig);
        }
  
        /**
                        webOfTrustConnector.stop();
                } catch (Throwable t1) {
                        logger.log(Level.SEVERE, "Error while shutting down!", t1);
-               } finally {
-                       /* shutdown logger. */
-                       Logging.shutdown();
                }
        }
  
index 0000000,c753740..091c93f
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,393 +1,401 @@@
 -import static freenet.client.InsertException.CANCELLED;
 -import static freenet.client.InsertException.INTERNAL_ERROR;
+ package net.pterodactylus.sone.core;
 -import net.pterodactylus.sone.freenet.StringBucket;
+ import static freenet.keys.InsertableClientSSK.createRandom;
+ import static freenet.node.RequestStarter.INTERACTIVE_PRIORITY_CLASS;
+ import static freenet.node.RequestStarter.PREFETCH_PRIORITY_CLASS;
+ import static net.pterodactylus.sone.Matchers.delivers;
+ import static net.pterodactylus.sone.TestUtil.setFinalField;
+ import static org.hamcrest.MatcherAssert.assertThat;
+ import static org.hamcrest.Matchers.is;
+ import static org.hamcrest.Matchers.notNullValue;
+ import static org.hamcrest.Matchers.nullValue;
+ import static org.mockito.ArgumentCaptor.forClass;
+ import static org.mockito.Matchers.any;
+ import static org.mockito.Matchers.anyBoolean;
+ import static org.mockito.Matchers.anyShort;
+ import static org.mockito.Matchers.eq;
+ import static org.mockito.Mockito.doNothing;
+ import static org.mockito.Mockito.mock;
+ import static org.mockito.Mockito.never;
+ import static org.mockito.Mockito.times;
+ import static org.mockito.Mockito.verify;
+ import static org.mockito.Mockito.when;
+ import static org.mockito.Mockito.withSettings;
+ import java.io.IOException;
+ import java.net.MalformedURLException;
+ import java.util.HashMap;
+ import net.pterodactylus.sone.TestUtil;
+ import net.pterodactylus.sone.core.FreenetInterface.Callback;
+ import net.pterodactylus.sone.core.FreenetInterface.Fetched;
+ import net.pterodactylus.sone.core.FreenetInterface.InsertToken;
+ import net.pterodactylus.sone.core.FreenetInterface.InsertTokenSupplier;
+ import net.pterodactylus.sone.core.event.ImageInsertAbortedEvent;
+ import net.pterodactylus.sone.core.event.ImageInsertFailedEvent;
+ import net.pterodactylus.sone.core.event.ImageInsertFinishedEvent;
+ import net.pterodactylus.sone.core.event.ImageInsertStartedEvent;
+ import net.pterodactylus.sone.data.Image;
+ import net.pterodactylus.sone.data.impl.ImageImpl;
+ import net.pterodactylus.sone.data.Sone;
+ import net.pterodactylus.sone.data.TemporaryImage;
 -              FetchException fetchException = new FetchException(FetchException.PERMANENT_REDIRECT, newFreenetUri);
+ import freenet.client.ClientMetadata;
+ import freenet.client.FetchException;
++import freenet.client.FetchException.FetchExceptionMode;
+ import freenet.client.FetchResult;
+ import freenet.client.HighLevelSimpleClient;
+ import freenet.client.InsertBlock;
+ import freenet.client.InsertContext;
+ import freenet.client.InsertException;
++import freenet.client.InsertException.InsertExceptionMode;
+ import freenet.client.async.ClientPutter;
+ import freenet.client.async.USKCallback;
+ import freenet.client.async.USKManager;
+ import freenet.crypt.DummyRandomSource;
+ import freenet.crypt.RandomSource;
+ import freenet.keys.FreenetURI;
+ import freenet.keys.InsertableClientSSK;
+ import freenet.keys.USK;
+ import freenet.node.Node;
+ import freenet.node.NodeClientCore;
+ import freenet.node.RequestClient;
+ import freenet.support.Base64;
+ import freenet.support.api.Bucket;
++import freenet.support.io.ArrayBucket;
++import freenet.support.io.ResumeFailedException;
+ import com.google.common.eventbus.EventBus;
+ import org.junit.Before;
+ import org.junit.Test;
+ import org.mockito.ArgumentCaptor;
+ /**
+  * Unit test for {@link FreenetInterface}.
+  *
+  * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+  */
+ public class FreenetInterfaceTest {
+       private final EventBus eventBus = mock(EventBus.class);
+       private final Node node = mock(Node.class);
+       private final NodeClientCore nodeClientCore = mock(NodeClientCore.class);
+       private final HighLevelSimpleClient highLevelSimpleClient = mock(HighLevelSimpleClient.class, withSettings().extraInterfaces(RequestClient.class));
+       private final RandomSource randomSource = new DummyRandomSource();
+       private final USKManager uskManager = mock(USKManager.class);
+       private FreenetInterface freenetInterface;
+       private final Sone sone = mock(Sone.class);
+       private final ArgumentCaptor<USKCallback> callbackCaptor = forClass(USKCallback.class);
+       private final Image image = mock(Image.class);
+       private InsertToken insertToken;
++      private final Bucket bucket = mock(Bucket.class);
+       @Before
+       public void setupFreenetInterface() {
+               when(nodeClientCore.makeClient(anyShort(), anyBoolean(), anyBoolean())).thenReturn(highLevelSimpleClient);
+               setFinalField(node, "clientCore", nodeClientCore);
+               setFinalField(node, "random", randomSource);
+               setFinalField(nodeClientCore, "uskManager", uskManager);
+               freenetInterface = new FreenetInterface(eventBus, node);
+               insertToken = freenetInterface.new InsertToken(image);
++              insertToken.setBucket(bucket);
+       }
+       @Before
+       public void setupSone() {
+               InsertableClientSSK insertSsk = createRandom(randomSource, "test-0");
+               when(sone.getId()).thenReturn(Base64.encode(insertSsk.getURI().getRoutingKey()));
+               when(sone.getRequestUri()).thenReturn(insertSsk.getURI().uskForSSK());
+       }
+       @Before
+       public void setupCallbackCaptorAndUskManager() {
+               doNothing().when(uskManager).subscribe(any(USK.class), callbackCaptor.capture(), anyBoolean(), any(RequestClient.class));
+       }
+       @Test
+       public void canFetchUri() throws MalformedURLException, FetchException {
+               FreenetURI freenetUri = new FreenetURI("KSK@GPLv3.txt");
+               FetchResult fetchResult = createFetchResult();
+               when(highLevelSimpleClient.fetch(freenetUri)).thenReturn(fetchResult);
+               Fetched fetched = freenetInterface.fetchUri(freenetUri);
+               assertThat(fetched, notNullValue());
+               assertThat(fetched.getFetchResult(), is(fetchResult));
+               assertThat(fetched.getFreenetUri(), is(freenetUri));
+       }
+       @Test
+       public void fetchFollowsRedirect() throws MalformedURLException, FetchException {
+               FreenetURI freenetUri = new FreenetURI("KSK@GPLv2.txt");
+               FreenetURI newFreenetUri = new FreenetURI("KSK@GPLv3.txt");
+               FetchResult fetchResult = createFetchResult();
 -              FetchException fetchException = new FetchException(FetchException.ALL_DATA_NOT_FOUND);
++              FetchException fetchException = new FetchException(FetchExceptionMode.PERMANENT_REDIRECT, newFreenetUri);
+               when(highLevelSimpleClient.fetch(freenetUri)).thenThrow(fetchException);
+               when(highLevelSimpleClient.fetch(newFreenetUri)).thenReturn(fetchResult);
+               Fetched fetched = freenetInterface.fetchUri(freenetUri);
+               assertThat(fetched.getFetchResult(), is(fetchResult));
+               assertThat(fetched.getFreenetUri(), is(newFreenetUri));
+       }
+       @Test
+       public void fetchReturnsNullOnFetchExceptions() throws MalformedURLException, FetchException {
+               FreenetURI freenetUri = new FreenetURI("KSK@GPLv2.txt");
 -              Bucket bucket = new StringBucket("Some Data.");
++              FetchException fetchException = new FetchException(FetchExceptionMode.ALL_DATA_NOT_FOUND);
+               when(highLevelSimpleClient.fetch(freenetUri)).thenThrow(fetchException);
+               Fetched fetched = freenetInterface.fetchUri(freenetUri);
+               assertThat(fetched, nullValue());
+       }
+       private FetchResult createFetchResult() {
+               ClientMetadata clientMetadata = new ClientMetadata("text/plain");
 -              when(highLevelSimpleClient.insert(insertBlockCaptor.capture(), eq(false), eq((String) null), eq(false), eq(insertContext), eq(insertToken), anyShort())).thenReturn(clientPutter);
++              Bucket bucket = new ArrayBucket("Some Data.".getBytes());
+               return new FetchResult(clientMetadata, bucket);
+       }
+       @Test
+       public void insertingAnImage() throws SoneException, InsertException, IOException {
+               TemporaryImage temporaryImage = new TemporaryImage("image-id");
+               temporaryImage.setMimeType("image/png");
+               byte[] imageData = new byte[] { 1, 2, 3, 4 };
+               temporaryImage.setImageData(imageData);
+               Image image = new ImageImpl("image-id");
+               InsertToken insertToken = freenetInterface.new InsertToken(image);
+               InsertContext insertContext = mock(InsertContext.class);
+               when(highLevelSimpleClient.getInsertContext(anyBoolean())).thenReturn(insertContext);
+               ClientPutter clientPutter = mock(ClientPutter.class);
+               ArgumentCaptor<InsertBlock> insertBlockCaptor = forClass(InsertBlock.class);
 -              when(highLevelSimpleClient.insert(insertBlockCaptor.capture(), eq(false), eq((String) null), eq(false), eq(insertContext), eq(insertToken), anyShort())).thenThrow(InsertException.class);
++              when(highLevelSimpleClient.insert(insertBlockCaptor.capture(), eq((String) null), eq(false), eq(insertContext), eq(insertToken), anyShort())).thenReturn(clientPutter);
+               freenetInterface.insertImage(temporaryImage, image, insertToken);
+               assertThat(insertBlockCaptor.getValue().getData().getInputStream(), delivers(new byte[] { 1, 2, 3, 4 }));
+               assertThat(TestUtil.<ClientPutter>getPrivateField(insertToken, "clientPutter"), is(clientPutter));
+               verify(eventBus).post(any(ImageInsertStartedEvent.class));
+       }
+       @Test(expected = SoneInsertException.class)
+       public void insertExceptionCausesASoneException() throws InsertException, SoneException, IOException {
+               TemporaryImage temporaryImage = new TemporaryImage("image-id");
+               temporaryImage.setMimeType("image/png");
+               byte[] imageData = new byte[] { 1, 2, 3, 4 };
+               temporaryImage.setImageData(imageData);
+               Image image = new ImageImpl("image-id");
+               InsertToken insertToken = freenetInterface.new InsertToken(image);
+               InsertContext insertContext = mock(InsertContext.class);
+               when(highLevelSimpleClient.getInsertContext(anyBoolean())).thenReturn(insertContext);
+               ArgumentCaptor<InsertBlock> insertBlockCaptor = forClass(InsertBlock.class);
 -              callbackCaptor.getValue().onFoundEdition(3, key, null, null, false, (short) 0, null, true, true);
++              when(highLevelSimpleClient.insert(insertBlockCaptor.capture(), eq((String) null), eq(false), eq(insertContext), eq(insertToken), anyShort())).thenThrow(InsertException.class);
+               freenetInterface.insertImage(temporaryImage, image, insertToken);
+       }
+       @Test
+       public void insertingADirectory() throws InsertException, SoneException {
+               FreenetURI freenetUri = mock(FreenetURI.class);
+               HashMap<String, Object> manifestEntries = new HashMap<String, Object>();
+               String defaultFile = "index.html";
+               FreenetURI resultingUri = mock(FreenetURI.class);
+               when(highLevelSimpleClient.insertManifest(eq(freenetUri), eq(manifestEntries), eq(defaultFile))).thenReturn(resultingUri);
+               assertThat(freenetInterface.insertDirectory(freenetUri, manifestEntries, defaultFile), is(resultingUri));
+       }
+       @Test(expected = SoneException.class)
+       public void insertExceptionIsForwardedAsSoneException() throws InsertException, SoneException {
+               when(highLevelSimpleClient.insertManifest(any(FreenetURI.class), any(HashMap.class), any(String.class))).thenThrow(InsertException.class);
+               freenetInterface.insertDirectory(null, null, null);
+       }
+       @Test
+       public void soneWithWrongRequestUriWillNotBeSubscribed() throws MalformedURLException {
+               when(sone.getRequestUri()).thenReturn(new FreenetURI("KSK@GPLv3.txt"));
+               freenetInterface.registerUsk(new FreenetURI("KSK@GPLv3.txt"), null);
+               verify(uskManager, never()).subscribe(any(USK.class), any(USKCallback.class), anyBoolean(), any(RequestClient.class));
+       }
+       @Test
+       public void registeringAUsk() {
+               FreenetURI freenetUri = createRandom(randomSource, "test-0").getURI().uskForSSK();
+               Callback callback = mock(Callback.class);
+               freenetInterface.registerUsk(freenetUri, callback);
+               verify(uskManager).subscribe(any(USK.class), any(USKCallback.class), anyBoolean(), eq((RequestClient) highLevelSimpleClient));
+       }
+       @Test
+       public void registeringANonUskKeyWillNotBeSubscribed() throws MalformedURLException {
+               FreenetURI freenetUri = new FreenetURI("KSK@GPLv3.txt");
+               Callback callback = mock(Callback.class);
+               freenetInterface.registerUsk(freenetUri, callback);
+               verify(uskManager, never()).subscribe(any(USK.class), any(USKCallback.class), anyBoolean(), eq((RequestClient) highLevelSimpleClient));
+       }
+       @Test
+       public void registeringAnActiveUskWillSubscribeToItCorrectly() {
+               FreenetURI freenetUri = createRandom(randomSource, "test-0").getURI().uskForSSK();
+               final USKCallback uskCallback = mock(USKCallback.class);
+               freenetInterface.registerActiveUsk(freenetUri, uskCallback);
+               verify(uskManager).subscribe(any(USK.class), eq(uskCallback), eq(true), any(RequestClient.class));
+       }
+       @Test
+       public void registeringAnInactiveUskWillSubscribeToItCorrectly() {
+               FreenetURI freenetUri = createRandom(randomSource, "test-0").getURI().uskForSSK();
+               final USKCallback uskCallback = mock(USKCallback.class);
+               freenetInterface.registerPassiveUsk(freenetUri, uskCallback);
+               verify(uskManager).subscribe(any(USK.class), eq(uskCallback), eq(false), any(RequestClient.class));
+       }
+       @Test
+       public void registeringAnActiveNonUskWillNotSubscribeToAUsk()
+       throws MalformedURLException {
+               FreenetURI freenetUri = createRandom(randomSource, "test-0").getURI();
+           freenetInterface.registerActiveUsk(freenetUri, null);
+               verify(uskManager, never()).subscribe(any(USK.class),
+                               any(USKCallback.class), anyBoolean(),
+                               eq((RequestClient) highLevelSimpleClient));
+       }
+       @Test
+       public void registeringAnInactiveNonUskWillNotSubscribeToAUsk()
+       throws MalformedURLException {
+               FreenetURI freenetUri = createRandom(randomSource, "test-0").getURI();
+           freenetInterface.registerPassiveUsk(freenetUri, null);
+               verify(uskManager, never()).subscribe(any(USK.class),
+                               any(USKCallback.class), anyBoolean(),
+                               eq((RequestClient) highLevelSimpleClient));
+       }
+       @Test
+       public void unregisteringANotRegisteredUskDoesNothing() {
+               FreenetURI freenetURI = createRandom(randomSource, "test-0").getURI().uskForSSK();
+               freenetInterface.unregisterUsk(freenetURI);
+               verify(uskManager, never()).unsubscribe(any(USK.class), any(USKCallback.class));
+       }
+       @Test
+       public void unregisteringARegisteredUsk() {
+               FreenetURI freenetURI = createRandom(randomSource, "test-0").getURI().uskForSSK();
+               Callback callback = mock(Callback.class);
+               freenetInterface.registerUsk(freenetURI, callback);
+               freenetInterface.unregisterUsk(freenetURI);
+               verify(uskManager).unsubscribe(any(USK.class), any(USKCallback.class));
+       }
+       @Test
+       public void unregisteringANotRegisteredSoneDoesNothing() {
+               freenetInterface.unregisterUsk(sone);
+               verify(uskManager, never()).unsubscribe(any(USK.class), any(USKCallback.class));
+       }
+       @Test
+       public void unregisteringARegisteredSoneUnregistersTheSone()
+       throws MalformedURLException {
+               freenetInterface.registerActiveUsk(sone.getRequestUri(), mock(USKCallback.class));
+               freenetInterface.unregisterUsk(sone);
+               verify(uskManager).unsubscribe(any(USK.class), any(USKCallback.class));
+       }
+       @Test
+       public void unregisteringASoneWithAWrongRequestKeyWillNotUnsubscribe() throws MalformedURLException {
+               when(sone.getRequestUri()).thenReturn(new FreenetURI("KSK@GPLv3.txt"));
+               freenetInterface.registerUsk(sone.getRequestUri(), null);
+               freenetInterface.unregisterUsk(sone);
+               verify(uskManager, never()).unsubscribe(any(USK.class), any(USKCallback.class));
+       }
+       @Test
+       public void callbackForNormalUskUsesDifferentPriorities() {
+               Callback callback = mock(Callback.class);
+               FreenetURI soneUri = createRandom(randomSource, "test-0").getURI().uskForSSK();
+               freenetInterface.registerUsk(soneUri, callback);
+               assertThat(callbackCaptor.getValue().getPollingPriorityNormal(), is(PREFETCH_PRIORITY_CLASS));
+               assertThat(callbackCaptor.getValue().getPollingPriorityProgress(), is(INTERACTIVE_PRIORITY_CLASS));
+       }
+       @Test
+       public void callbackForNormalUskForwardsImportantParameters() throws MalformedURLException {
+               Callback callback = mock(Callback.class);
+               FreenetURI uri = createRandom(randomSource, "test-0").getURI().uskForSSK();
+               freenetInterface.registerUsk(uri, callback);
+               USK key = mock(USK.class);
+               when(key.getURI()).thenReturn(uri);
 -              insertToken.onFailure(null, null, null);
++              callbackCaptor.getValue().onFoundEdition(3, key, null, false, (short) 0, null, true, true);
+               verify(callback).editionFound(eq(uri), eq(3L), eq(true), eq(true));
+       }
+       @Test
+       public void fetchedRetainsUriAndFetchResult() {
+               FreenetURI freenetUri = mock(FreenetURI.class);
+               FetchResult fetchResult = mock(FetchResult.class);
+               Fetched fetched = new Fetched(freenetUri, fetchResult);
+               assertThat(fetched.getFreenetUri(), is(freenetUri));
+               assertThat(fetched.getFetchResult(), is(fetchResult));
+       }
+       @Test
+       public void cancellingAnInsertWillFireImageInsertAbortedEvent() {
+               ClientPutter clientPutter = mock(ClientPutter.class);
+               insertToken.setClientPutter(clientPutter);
+               ArgumentCaptor<ImageInsertStartedEvent> imageInsertStartedEvent = forClass(ImageInsertStartedEvent.class);
+               verify(eventBus).post(imageInsertStartedEvent.capture());
+               assertThat(imageInsertStartedEvent.getValue().image(), is(image));
+               insertToken.cancel();
+               ArgumentCaptor<ImageInsertAbortedEvent> imageInsertAbortedEvent = forClass(ImageInsertAbortedEvent.class);
+               verify(eventBus, times(2)).post(imageInsertAbortedEvent.capture());
++              verify(bucket).free();
+               assertThat(imageInsertAbortedEvent.getValue().image(), is(image));
+       }
+       @Test
+       public void failureWithoutExceptionSendsFailedEvent() {
 -              InsertException insertException = new InsertException(INTERNAL_ERROR, "Internal error", null);
 -              insertToken.onFailure(insertException, null, null);
++              insertToken.onFailure(null, null);
+               ArgumentCaptor<ImageInsertFailedEvent> imageInsertFailedEvent = forClass(ImageInsertFailedEvent.class);
+               verify(eventBus).post(imageInsertFailedEvent.capture());
++              verify(bucket).free();
+               assertThat(imageInsertFailedEvent.getValue().image(), is(image));
+               assertThat(imageInsertFailedEvent.getValue().cause(), nullValue());
+       }
+       @Test
+       public void failureSendsFailedEventWithException() {
 -              InsertException insertException = new InsertException(CANCELLED, null);
 -              insertToken.onFailure(insertException, null, null);
++              InsertException insertException = new InsertException(InsertExceptionMode.INTERNAL_ERROR, "Internal error", null);
++              insertToken.onFailure(insertException, null);
+               ArgumentCaptor<ImageInsertFailedEvent> imageInsertFailedEvent = forClass(ImageInsertFailedEvent.class);
+               verify(eventBus).post(imageInsertFailedEvent.capture());
++              verify(bucket).free();
+               assertThat(imageInsertFailedEvent.getValue().image(), is(image));
+               assertThat(imageInsertFailedEvent.getValue().cause(), is((Throwable) insertException));
+       }
+       @Test
+       public void failureBecauseCancelledByUserSendsAbortedEvent() {
 -      public void ignoredMethodsDoNotThrowExceptions() {
 -              insertToken.onMajorProgress(null);
 -              insertToken.onFetchable(null, null);
 -              insertToken.onGeneratedMetadata(null, null, null);
++              InsertException insertException = new InsertException(InsertExceptionMode.CANCELLED, null);
++              insertToken.onFailure(insertException, null);
+               ArgumentCaptor<ImageInsertAbortedEvent> imageInsertAbortedEvent = forClass(ImageInsertAbortedEvent.class);
+               verify(eventBus).post(imageInsertAbortedEvent.capture());
++              verify(bucket).free();
+               assertThat(imageInsertAbortedEvent.getValue().image(), is(image));
+       }
+       @Test
 -              insertToken.onGeneratedURI(generatedUri, null, null);
 -              insertToken.onSuccess(null, null);
++      public void ignoredMethodsDoNotThrowExceptions() throws ResumeFailedException {
++              insertToken.onResume(null);
++              insertToken.onFetchable(null);
++              insertToken.onGeneratedMetadata(null, null);
+       }
+       @Test
+       public void generatedUriIsPostedOnSuccess() {
+               FreenetURI generatedUri = mock(FreenetURI.class);
++              insertToken.onGeneratedURI(generatedUri, null);
++              insertToken.onSuccess(null);
+               ArgumentCaptor<ImageInsertFinishedEvent> imageInsertFinishedEvent = forClass(ImageInsertFinishedEvent.class);
+               verify(eventBus).post(imageInsertFinishedEvent.capture());
++              verify(bucket).free();
+               assertThat(imageInsertFinishedEvent.getValue().image(), is(image));
+               assertThat(imageInsertFinishedEvent.getValue().resultingUri(), is(generatedUri));
+       }
+       @Test
+       public void insertTokenSupplierSuppliesInsertTokens() {
+               InsertTokenSupplier insertTokenSupplier = freenetInterface.new InsertTokenSupplier();
+               assertThat(insertTokenSupplier.apply(image), notNullValue());
+       }
+ }
index 0000000,e0ff3a5..552746e
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,291 +1,289 @@@
 -import static org.hamcrest.Matchers.containsInAnyOrder;
+ package net.pterodactylus.sone.core;
+ import static com.google.common.base.Optional.of;
+ import static com.google.common.io.ByteStreams.toByteArray;
+ import static com.google.common.util.concurrent.MoreExecutors.sameThreadExecutor;
+ import static java.lang.System.currentTimeMillis;
+ import static org.hamcrest.MatcherAssert.assertThat;
 -import net.pterodactylus.sone.core.SoneInserter.InsertInformation;
+ import static org.hamcrest.Matchers.containsString;
+ import static org.hamcrest.Matchers.instanceOf;
+ import static org.hamcrest.Matchers.is;
+ import static org.hamcrest.Matchers.nullValue;
+ import static org.mockito.Matchers.any;
+ import static org.mockito.Matchers.anyString;
+ import static org.mockito.Matchers.argThat;
+ import static org.mockito.Matchers.eq;
+ import static org.mockito.Mockito.doAnswer;
+ import static org.mockito.Mockito.mock;
+ import static org.mockito.Mockito.never;
+ import static org.mockito.Mockito.times;
+ import static org.mockito.Mockito.verify;
+ import static org.mockito.Mockito.when;
+ import java.io.IOException;
+ import java.util.HashMap;
+ import java.util.Map;
 -import freenet.client.async.ManifestElement;
+ import net.pterodactylus.sone.core.SoneInserter.ManifestCreator;
+ import net.pterodactylus.sone.core.event.InsertionDelayChangedEvent;
+ import net.pterodactylus.sone.core.event.SoneEvent;
+ import net.pterodactylus.sone.core.event.SoneInsertAbortedEvent;
+ import net.pterodactylus.sone.core.event.SoneInsertedEvent;
+ import net.pterodactylus.sone.core.event.SoneInsertingEvent;
+ import net.pterodactylus.sone.data.Album;
+ import net.pterodactylus.sone.data.Sone;
+ import net.pterodactylus.sone.main.SonePlugin;
+ import freenet.keys.FreenetURI;
++import freenet.support.api.ManifestElement;
+ import com.google.common.base.Charsets;
+ import com.google.common.base.Optional;
+ import com.google.common.eventbus.AsyncEventBus;
+ import com.google.common.eventbus.EventBus;
+ import org.junit.Before;
+ import org.junit.Test;
+ import org.mockito.ArgumentCaptor;
+ import org.mockito.invocation.InvocationOnMock;
+ import org.mockito.stubbing.Answer;
+ /**
+  * Unit test for {@link SoneInserter} and its subclasses.
+  *
+  * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+  */
+ public class SoneInserterTest {
+       private final Core core = mock(Core.class);
+       private final EventBus eventBus = mock(EventBus.class);
+       private final FreenetInterface freenetInterface = mock(FreenetInterface.class);
+       @Before
+       public void setupCore() {
+               UpdateChecker updateChecker = mock(UpdateChecker.class);
+               when(core.getUpdateChecker()).thenReturn(updateChecker);
+               when(core.getSone(anyString())).thenReturn(Optional.<Sone>absent());
+       }
+       @Test
+       public void insertionDelayIsForwardedToSoneInserter() {
+               EventBus eventBus = new AsyncEventBus(sameThreadExecutor());
+               eventBus.register(new SoneInserter(core, eventBus, freenetInterface, "SoneId"));
+               eventBus.post(new InsertionDelayChangedEvent(15));
+               assertThat(SoneInserter.getInsertionDelay().get(), is(15));
+       }
+       private Sone createSone(FreenetURI insertUri, String fingerprint) {
+               Sone sone = mock(Sone.class);
+               when(sone.getInsertUri()).thenReturn(insertUri);
+               when(sone.getFingerprint()).thenReturn(fingerprint);
+               when(sone.getRootAlbum()).thenReturn(mock(Album.class));
+               when(core.getSone(anyString())).thenReturn(of(sone));
+               return sone;
+       }
+       @Test
+       public void isModifiedIsTrueIfModificationDetectorSaysSo() {
+               SoneModificationDetector soneModificationDetector = mock(SoneModificationDetector.class);
+               when(soneModificationDetector.isModified()).thenReturn(true);
+               SoneInserter soneInserter = new SoneInserter(core, eventBus, freenetInterface, "SoneId", soneModificationDetector, 1);
+               assertThat(soneInserter.isModified(), is(true));
+       }
+       @Test
+       public void isModifiedIsFalseIfModificationDetectorSaysSo() {
+               SoneModificationDetector soneModificationDetector = mock(SoneModificationDetector.class);
+               SoneInserter soneInserter = new SoneInserter(core, eventBus, freenetInterface, "SoneId", soneModificationDetector, 1);
+               assertThat(soneInserter.isModified(), is(false));
+       }
+       @Test
+       public void lastFingerprintIsStoredCorrectly() {
+               SoneInserter soneInserter = new SoneInserter(core, eventBus, freenetInterface, "SoneId");
+               soneInserter.setLastInsertFingerprint("last-fingerprint");
+               assertThat(soneInserter.getLastInsertFingerprint(), is("last-fingerprint"));
+       }
+       @Test
+       public void soneInserterStopsWhenItShould() {
+               SoneInserter soneInserter = new SoneInserter(core, eventBus, freenetInterface, "SoneId");
+               soneInserter.stop();
+               soneInserter.serviceRun();
+       }
+       @Test
+       public void soneInserterInsertsASoneIfItIsEligible() throws SoneException {
+               FreenetURI insertUri = mock(FreenetURI.class);
+               final FreenetURI finalUri = mock(FreenetURI.class);
+               String fingerprint = "fingerprint";
+               Sone sone = createSone(insertUri, fingerprint);
+               SoneModificationDetector soneModificationDetector = mock(SoneModificationDetector.class);
+               when(soneModificationDetector.isEligibleForInsert()).thenReturn(true);
+               when(freenetInterface.insertDirectory(eq(insertUri), any(HashMap.class), eq("index.html"))).thenReturn(finalUri);
+               final SoneInserter soneInserter = new SoneInserter(core, eventBus, freenetInterface, "SoneId", soneModificationDetector, 1);
+               doAnswer(new Answer<Void>() {
+                       @Override
+                       public Void answer(InvocationOnMock invocation) throws Throwable {
+                               soneInserter.stop();
+                               return null;
+                       }
+               }).when(core).touchConfiguration();
+               soneInserter.serviceRun();
+               ArgumentCaptor<SoneEvent> soneEvents = ArgumentCaptor.forClass(SoneEvent.class);
+               verify(freenetInterface).insertDirectory(eq(insertUri), any(HashMap.class), eq("index.html"));
+               verify(eventBus, times(2)).post(soneEvents.capture());
+               assertThat(soneEvents.getAllValues().get(0), instanceOf(SoneInsertingEvent.class));
+               assertThat(soneEvents.getAllValues().get(0).sone(), is(sone));
+               assertThat(soneEvents.getAllValues().get(1), instanceOf(SoneInsertedEvent.class));
+               assertThat(soneEvents.getAllValues().get(1).sone(), is(sone));
+       }
+       @Test
+       public void soneInserterBailsOutIfItIsStoppedWhileInserting() throws SoneException {
+               FreenetURI insertUri = mock(FreenetURI.class);
+               final FreenetURI finalUri = mock(FreenetURI.class);
+               String fingerprint = "fingerprint";
+               Sone sone = createSone(insertUri, fingerprint);
+               SoneModificationDetector soneModificationDetector = mock(SoneModificationDetector.class);
+               when(soneModificationDetector.isEligibleForInsert()).thenReturn(true);
+               final SoneInserter soneInserter = new SoneInserter(core, eventBus, freenetInterface, "SoneId", soneModificationDetector, 1);
+               when(freenetInterface.insertDirectory(eq(insertUri), any(HashMap.class), eq("index.html"))).thenAnswer(new Answer<FreenetURI>() {
+                       @Override
+                       public FreenetURI answer(InvocationOnMock invocation) throws Throwable {
+                               soneInserter.stop();
+                               return finalUri;
+                       }
+               });
+               soneInserter.serviceRun();
+               ArgumentCaptor<SoneEvent> soneEvents = ArgumentCaptor.forClass(SoneEvent.class);
+               verify(freenetInterface).insertDirectory(eq(insertUri), any(HashMap.class), eq("index.html"));
+               verify(eventBus, times(2)).post(soneEvents.capture());
+               assertThat(soneEvents.getAllValues().get(0), instanceOf(SoneInsertingEvent.class));
+               assertThat(soneEvents.getAllValues().get(0).sone(), is(sone));
+               assertThat(soneEvents.getAllValues().get(1), instanceOf(SoneInsertedEvent.class));
+               assertThat(soneEvents.getAllValues().get(1).sone(), is(sone));
+               verify(core, never()).touchConfiguration();
+       }
+       @Test
+       public void soneInserterDoesNotInsertSoneIfItIsNotEligible() throws SoneException {
+               FreenetURI insertUri = mock(FreenetURI.class);
+               String fingerprint = "fingerprint";
+               Sone sone = createSone(insertUri, fingerprint);
+               SoneModificationDetector soneModificationDetector = mock(SoneModificationDetector.class);
+               final SoneInserter soneInserter = new SoneInserter(core, eventBus, freenetInterface, "SoneId", soneModificationDetector, 1);
+               new Thread(new Runnable() {
+                       @Override
+                       public void run() {
+                               try {
+                                       Thread.sleep(500);
+                               } catch (InterruptedException ie1) {
+                                       throw new RuntimeException(ie1);
+                               }
+                               soneInserter.stop();
+                       }
+               }).start();
+               soneInserter.serviceRun();
+               verify(freenetInterface, never()).insertDirectory(eq(insertUri), any(HashMap.class), eq("index.html"));
+               verify(eventBus, never()).post(argThat(org.hamcrest.Matchers.any(SoneEvent.class)));
+       }
+       @Test
+       public void soneInserterPostsAbortedEventIfAnExceptionOccurs() throws SoneException {
+               FreenetURI insertUri = mock(FreenetURI.class);
+               String fingerprint = "fingerprint";
+               Sone sone = createSone(insertUri, fingerprint);
+               SoneModificationDetector soneModificationDetector = mock(SoneModificationDetector.class);
+               when(soneModificationDetector.isEligibleForInsert()).thenReturn(true);
+               final SoneInserter soneInserter = new SoneInserter(core, eventBus, freenetInterface, "SoneId", soneModificationDetector, 1);
+               final SoneException soneException = new SoneException(new Exception());
+               when(freenetInterface.insertDirectory(eq(insertUri), any(HashMap.class), eq("index.html"))).thenAnswer(new Answer<FreenetURI>() {
+                       @Override
+                       public FreenetURI answer(InvocationOnMock invocation) throws Throwable {
+                               soneInserter.stop();
+                               throw soneException;
+                       }
+               });
+               soneInserter.serviceRun();
+               ArgumentCaptor<SoneEvent> soneEvents = ArgumentCaptor.forClass(SoneEvent.class);
+               verify(freenetInterface).insertDirectory(eq(insertUri), any(HashMap.class), eq("index.html"));
+               verify(eventBus, times(2)).post(soneEvents.capture());
+               assertThat(soneEvents.getAllValues().get(0), instanceOf(SoneInsertingEvent.class));
+               assertThat(soneEvents.getAllValues().get(0).sone(), is(sone));
+               assertThat(soneEvents.getAllValues().get(1), instanceOf(SoneInsertAbortedEvent.class));
+               assertThat(soneEvents.getAllValues().get(1).sone(), is(sone));
+               verify(core, never()).touchConfiguration();
+       }
+       @Test
+       public void soneInserterExitsIfSoneIsUnknown() {
+               SoneModificationDetector soneModificationDetector =
+                               mock(SoneModificationDetector.class);
+               SoneInserter soneInserter =
+                               new SoneInserter(core, eventBus, freenetInterface, "SoneId",
+                                               soneModificationDetector, 1);
+               when(soneModificationDetector.isEligibleForInsert()).thenReturn(true);
+               when(core.getSone("SoneId")).thenReturn(Optional.<Sone>absent());
+               soneInserter.serviceRun();
+       }
+       @Test
+       public void soneInserterCatchesExceptionAndContinues() {
+               SoneModificationDetector soneModificationDetector =
+                               mock(SoneModificationDetector.class);
+               final SoneInserter soneInserter =
+                               new SoneInserter(core, eventBus, freenetInterface, "SoneId",
+                                               soneModificationDetector, 1);
+               Answer<Optional<Sone>> stopInserterAndThrowException =
+                               new Answer<Optional<Sone>>() {
+                                       @Override
+                                       public Optional<Sone> answer(
+                                                       InvocationOnMock invocation) {
+                                               soneInserter.stop();
+                                               throw new NullPointerException();
+                                       }
+                               };
+               when(soneModificationDetector.isEligibleForInsert()).thenAnswer(
+                               stopInserterAndThrowException);
+               soneInserter.serviceRun();
+       }
+       @Test
+       public void templateIsRenderedCorrectlyForManifestElement()
+       throws IOException {
+               Map<String, Object> soneProperties = new HashMap<String, Object>();
+               soneProperties.put("id", "SoneId");
+               ManifestCreator manifestCreator = new ManifestCreator(core, soneProperties);
+               long now = currentTimeMillis();
+               when(core.getStartupTime()).thenReturn(now);
+               ManifestElement manifestElement = manifestCreator.createManifestElement("test.txt", "plain/text; charset=utf-8", "sone-inserter-manifest.txt");
+               assertThat(manifestElement.getName(), is("test.txt"));
+               assertThat(manifestElement.getMimeTypeOverride(), is("plain/text; charset=utf-8"));
+               String templateContent = new String(toByteArray(manifestElement.getData().getInputStream()), Charsets.UTF_8);
+               assertThat(templateContent, containsString("Sone Version: " + SonePlugin.VERSION.toString() + "\n"));
+               assertThat(templateContent, containsString("Core Startup: " + now + "\n"));
+               assertThat(templateContent, containsString("Sone ID: " + "SoneId" + "\n"));
+       }
+       @Test
+       public void invalidTemplateReturnsANullManifestElement() {
+               Map<String, Object> soneProperties = new HashMap<String, Object>();
+               ManifestCreator manifestCreator = new ManifestCreator(core, soneProperties);
+               assertThat(manifestCreator.createManifestElement("test.txt",
+                               "plain/text; charset=utf-8",
+                               "sone-inserter-invalid-manifest.txt"),
+                               nullValue());
+       }
+       @Test
+       public void errorWhileRenderingTemplateReturnsANullManifestElement() {
+               Map<String, Object> soneProperties = new HashMap<String, Object>();
+               ManifestCreator manifestCreator = new ManifestCreator(core, soneProperties);
+               when(core.toString()).thenThrow(NullPointerException.class);
+               assertThat(manifestCreator.createManifestElement("test.txt",
+                               "plain/text; charset=utf-8",
+                               "sone-inserter-faulty-manifest.txt"),
+                               nullValue());
+       }
+ }
index 0000000,b3f8c08..a5e3b2a
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,233 +1,233 @@@
 -import net.pterodactylus.sone.freenet.StringBucket;
+ package net.pterodactylus.sone.core;
+ import static java.lang.Long.MAX_VALUE;
+ import static net.pterodactylus.sone.main.SonePlugin.VERSION;
+ import static org.hamcrest.MatcherAssert.assertThat;
+ import static org.hamcrest.Matchers.instanceOf;
+ import static org.hamcrest.Matchers.is;
+ import static org.mockito.ArgumentCaptor.forClass;
+ import static org.mockito.Matchers.any;
+ import static org.mockito.Matchers.argThat;
+ import static org.mockito.Mockito.mock;
+ import static org.mockito.Mockito.never;
+ import static org.mockito.Mockito.times;
+ import static org.mockito.Mockito.verify;
+ import static org.mockito.Mockito.when;
+ import java.io.IOException;
+ import java.io.InputStream;
+ import net.pterodactylus.sone.core.FreenetInterface.Callback;
+ import net.pterodactylus.sone.core.FreenetInterface.Fetched;
+ import net.pterodactylus.sone.core.event.UpdateFoundEvent;
 -              Bucket fetched = new StringBucket("# MapConfigurationBackendVersion=1\n" +
+ import net.pterodactylus.util.version.Version;
+ import freenet.client.ClientMetadata;
+ import freenet.client.FetchResult;
+ import freenet.keys.FreenetURI;
+ import freenet.support.api.Bucket;
++import freenet.support.io.ArrayBucket;
+ import com.google.common.eventbus.EventBus;
+ import org.junit.Before;
+ import org.junit.Test;
+ import org.mockito.ArgumentCaptor;
+ import org.mockito.invocation.InvocationOnMock;
+ import org.mockito.stubbing.Answer;
+ /**
+  * Unit test for {@link UpdateChecker}.
+  *
+  * @author <a href="mailto:bombe@pterodactylus.net">David ‘Bombe’ Roden</a>
+  */
+ public class UpdateCheckerTest {
+       private final EventBus eventBus = mock(EventBus.class);
+       private final FreenetInterface freenetInterface = mock(FreenetInterface.class);
+       private final UpdateChecker updateChecker = new UpdateChecker(eventBus, freenetInterface);
+       @Before
+       public void startUpdateChecker() {
+               updateChecker.start();
+       }
+       @Test
+       public void newUpdateCheckerDoesNotHaveALatestVersion() {
+               assertThat(updateChecker.hasLatestVersion(), is(false));
+               assertThat(updateChecker.getLatestVersion(), is(VERSION));
+       }
+       @Test
+       public void startingAnUpdateCheckerRegisterAUsk() {
+               verify(freenetInterface).registerUsk(any(FreenetURI.class), any(Callback.class));
+       }
+       @Test
+       public void stoppingAnUpdateCheckerUnregistersAUsk() {
+               updateChecker.stop();
+               verify(freenetInterface).unregisterUsk(any(FreenetURI.class));
+       }
+       @Test
+       public void callbackDoesNotDownloadIfNewEditionIsNotFound() {
+               setupCallbackWithEdition(MAX_VALUE, false, false);
+               verify(freenetInterface, never()).fetchUri(any(FreenetURI.class));
+               verify(eventBus, never()).post(argThat(instanceOf(UpdateFoundEvent.class)));
+       }
+       private void setupCallbackWithEdition(long edition, boolean newKnownGood, boolean newSlot) {
+               ArgumentCaptor<FreenetURI> uri = forClass(FreenetURI.class);
+               ArgumentCaptor<Callback> callback = forClass(Callback.class);
+               verify(freenetInterface).registerUsk(uri.capture(), callback.capture());
+               callback.getValue().editionFound(uri.getValue(), edition, newKnownGood, newSlot);
+       }
+       @Test
+       public void callbackStartsIfNewEditionIsFound() {
+               setupFetchResult(createFutureFetchResult());
+               setupCallbackWithEdition(MAX_VALUE, true, false);
+               verifyAFreenetUriIsFetched();
+               ArgumentCaptor<UpdateFoundEvent> updateFoundEvent = forClass(UpdateFoundEvent.class);
+               verify(eventBus, times(1)).post(updateFoundEvent.capture());
+               assertThat(updateFoundEvent.getValue().version(), is(new Version(99, 0, 0)));
+               assertThat(updateFoundEvent.getValue().releaseTime(), is(11865368297000L));
+               assertThat(updateChecker.getLatestVersion(), is(new Version(99, 0, 0)));
+               assertThat(updateChecker.getLatestVersionDate(), is(11865368297000L));
+               assertThat(updateChecker.hasLatestVersion(), is(true));
+       }
+       private FetchResult createFutureFetchResult() {
+               ClientMetadata clientMetadata = new ClientMetadata("application/xml");
 -                              "CurrentVersion/ReleaseTime: 11865368297000");
++              Bucket fetched = new ArrayBucket(("# MapConfigurationBackendVersion=1\n" +
+                               "CurrentVersion/Version: 99.0.0\n" +
 -              Bucket fetched = new StringBucket("# MapConfigurationBackendVersion=1\n" +
++                              "CurrentVersion/ReleaseTime: 11865368297000").getBytes());
+               return new FetchResult(clientMetadata, fetched);
+       }
+       @Test
+       public void callbackDoesNotStartIfNoNewEditionIsFound() {
+               setupFetchResult(createPastFetchResult());
+               setupCallbackWithEdition(updateChecker.getLatestEdition(), true, false);
+               verifyAFreenetUriIsFetched();
+               verifyNoUpdateFoundEventIsFired();
+       }
+       private void setupFetchResult(final FetchResult pastFetchResult) {
+               when(freenetInterface.fetchUri(any(FreenetURI.class))).thenAnswer(new Answer<Fetched>() {
+                       @Override
+                       public Fetched answer(InvocationOnMock invocation) throws Throwable {
+                               FreenetURI freenetUri = (FreenetURI) invocation.getArguments()[0];
+                               return new Fetched(freenetUri, pastFetchResult);
+                       }
+               });
+       }
+       private FetchResult createPastFetchResult() {
+               ClientMetadata clientMetadata = new ClientMetadata("application/xml");
 -                              "CurrentVersion/ReleaseTime: 1289417883000");
++              Bucket fetched = new ArrayBucket(("# MapConfigurationBackendVersion=1\n" +
+                               "CurrentVersion/Version: 0.2\n" +
 -              Bucket fetched = new StringBucket("Some other data.");
++                              "CurrentVersion/ReleaseTime: 1289417883000").getBytes());
+               return new FetchResult(clientMetadata, fetched);
+       }
+       @Test
+       public void invalidUpdateFileDoesNotStartCallback() {
+               setupFetchResult(createInvalidFetchResult());
+               setupCallbackWithEdition(MAX_VALUE, true, false);
+               verifyAFreenetUriIsFetched();
+               verifyNoUpdateFoundEventIsFired();
+       }
+       private FetchResult createInvalidFetchResult() {
+               ClientMetadata clientMetadata = new ClientMetadata("text/plain");
 -              Bucket fetched = new StringBucket("Some other data.") {
++              Bucket fetched = new ArrayBucket("Some other data.".getBytes());
+               return new FetchResult(clientMetadata, fetched);
+       }
+       @Test
+       public void nonExistingPropertiesWillNotCauseUpdateToBeFound() {
+               setupCallbackWithEdition(MAX_VALUE, true, false);
+               verifyAFreenetUriIsFetched();
+               verifyNoUpdateFoundEventIsFired();
+       }
+       private void verifyNoUpdateFoundEventIsFired() {
+               verify(eventBus, never()).post(any(UpdateFoundEvent.class));
+       }
+       private void verifyAFreenetUriIsFetched() {
+               verify(freenetInterface).fetchUri(any(FreenetURI.class));
+       }
+       @Test
+       public void brokenBucketDoesNotCauseUpdateToBeFound() {
+               setupFetchResult(createBrokenBucketFetchResult());
+               setupCallbackWithEdition(MAX_VALUE, true, false);
+               verifyAFreenetUriIsFetched();
+               verifyNoUpdateFoundEventIsFired();
+       }
+       private FetchResult createBrokenBucketFetchResult() {
+               ClientMetadata clientMetadata = new ClientMetadata("text/plain");
 -              Bucket fetched = new StringBucket("# MapConfigurationBackendVersion=1\n" +
++              Bucket fetched = new ArrayBucket("Some other data.".getBytes()) {
+                       @Override
+                       public InputStream getInputStream() {
+                               try {
+                                       return when(mock(InputStream.class).read()).thenThrow(IOException.class).getMock();
+                               } catch (IOException ioe1) {
+                                       /* won’t throw here. */
+                                       return null;
+                               }
+                       }
+               };
+               return new FetchResult(clientMetadata, fetched);
+       }
+       @Test
+       public void invalidTimeDoesNotCauseAnUpdateToBeFound() {
+               setupFetchResult(createInvalidTimeFetchResult());
+               setupCallbackWithEdition(MAX_VALUE, true, false);
+               verifyAFreenetUriIsFetched();
+               verifyNoUpdateFoundEventIsFired();
+       }
+       private FetchResult createInvalidTimeFetchResult() {
+               ClientMetadata clientMetadata = new ClientMetadata("application/xml");
 -                              "CurrentVersion/ReleaseTime: invalid");
++              Bucket fetched = new ArrayBucket(("# MapConfigurationBackendVersion=1\n" +
+                               "CurrentVersion/Version: 0.2\n" +
 -              Bucket fetched = new StringBucket("# MapConfigurationBackendVersion=1\n" +
 -                              "CurrentVersion/Version: 0.2\n");
++                              "CurrentVersion/ReleaseTime: invalid").getBytes());
+               return new FetchResult(clientMetadata, fetched);
+       }
+       @Test
+       public void invalidPropertiesDoesNotCauseAnUpdateToBeFound() {
+               setupFetchResult(createMissingTimeFetchResult());
+               setupCallbackWithEdition(MAX_VALUE, true, false);
+               verifyAFreenetUriIsFetched();
+               verifyNoUpdateFoundEventIsFired();
+       }
+       private FetchResult createMissingTimeFetchResult() {
+               ClientMetadata clientMetadata = new ClientMetadata("application/xml");
 -              Bucket fetched = new StringBucket("# MapConfigurationBackendVersion=1\n" +
++              Bucket fetched = new ArrayBucket(("# MapConfigurationBackendVersion=1\n" +
++                              "CurrentVersion/Version: 0.2\n").getBytes());
+               return new FetchResult(clientMetadata, fetched);
+       }
+       @Test
+       public void invalidVersionDoesNotCauseAnUpdateToBeFound() {
+               setupFetchResult(createInvalidVersionFetchResult());
+               setupCallbackWithEdition(MAX_VALUE, true, false);
+               verifyAFreenetUriIsFetched();
+               verifyNoUpdateFoundEventIsFired();
+       }
+       private FetchResult createInvalidVersionFetchResult() {
+               ClientMetadata clientMetadata = new ClientMetadata("application/xml");
 -                              "CurrentVersion/ReleaseTime: 1289417883000");
++              Bucket fetched = new ArrayBucket(("# MapConfigurationBackendVersion=1\n" +
+                               "CurrentVersion/Version: foo\n" +
++                              "CurrentVersion/ReleaseTime: 1289417883000").getBytes());
+               return new FetchResult(clientMetadata, fetched);
+       }
+ }