Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
/*
 * Copyright 2016 White Magic Software, Ltd.
 *
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 *  o Redistributions of source code must retain the above copyright
 *    notice, this list of conditions and the following disclaimer.
 *
 *  o Redistributions in binary form must reproduce the above copyright
 *    notice, this list of conditions and the following disclaimer in the
 *    documentation and/or other materials provided with the distribution.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */
package com.scrivenvar.service.impl;

import com.scrivenvar.service.Snitch;

import java.io.IOException;
import java.nio.file.*;
import java.util.Collections;
import java.util.Map;
import java.util.Observable;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

import static com.scrivenvar.Constants.APP_WATCHDOG_TIMEOUT;
import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY;

/**
 * Listens for file changes. Other classes can register paths to be monitored
 * and listen for changes to those paths.
 *
 * @author White Magic Software, Ltd.
 */
public class DefaultSnitch extends Observable implements Snitch {

  /**
   * Service for listening to directories for modifications.
   */
  private WatchService watchService;

  /**
   * Directories being monitored for changes.
   */
  private Map<WatchKey, Path> keys;

  /**
   * Files that will kick off notification events if modified.
   */
  private Set<Path> eavesdropped;

  /**
   * Set to true when running; set to false to stop listening.
   */
  private volatile boolean listening;

  public DefaultSnitch() {
  }

  @Override
  public void stop() {
    setListening( false );
  }

  /**
   * Adds a listener to the list of files to watch for changes. If the file is
   * already in the monitored list, this will return immediately.
   *
   * @param file Path to a file to watch for changes.
   * @throws IOException The file could not be monitored.
   */
  @Override
  public void listen( final Path file ) throws IOException {
    if( file != null && getEavesdropped().add( file ) ) {
      final Path dir = toDirectory( file );
      final WatchKey key = dir.register( getWatchService(), ENTRY_MODIFY );

      getWatchMap().put( key, dir );
    }
  }

  /**
   * Returns the given path to a file (or directory) as a directory. If the
   * given path is already a directory, it is returned. Otherwise, this returns
   * the directory that contains the file. This will fail if the file is stored
   * in the root folder.
   *
   * @param path The file to return as a directory, which should always be the
   *             case.
   * @return The given path as a directory, if a file, otherwise the path
   * itself.
   */
  private Path toDirectory( final Path path ) {
    return Files.isDirectory( path )
        ? path
        : path.toFile().getParentFile().toPath();
  }

  /**
   * Stop listening to the given file for change events. This fails silently.
   *
   * @param file The file to no longer monitor for changes.
   */
  @Override
  public void ignore( final Path file ) {
    if( file != null ) {
      final Path directory = toDirectory( file );

      // Remove all occurrences (there should be only one).
      getWatchMap().values().removeAll( Collections.singleton( directory ) );

      // Remove all occurrences (there can be only one).
      getEavesdropped().remove( file );
    }
  }

  /**
   * Loops until stop is called, or the application is terminated.
   */
  @Override
  @SuppressWarnings("BusyWait")
  public void run() {
    setListening( true );

    while( isListening() ) {
      try {
        final WatchKey key = getWatchService().take();
        final Path path = get( key );

        // Prevent receiving two separate ENTRY_MODIFY events: file modified
        // and timestamp updated. Instead, receive one ENTRY_MODIFY event
        // with two counts.
        Thread.sleep( APP_WATCHDOG_TIMEOUT );

        for( final WatchEvent<?> event : key.pollEvents() ) {
          final Path changed = path.resolve( (Path) event.context() );

          if( event.kind() == ENTRY_MODIFY && isListening( changed ) ) {
            setChanged();
            notifyObservers( changed );
          }
        }

        if( !key.reset() ) {
          ignore( path );
        }
      } catch( final IOException | InterruptedException ex ) {
        // Stop eavesdropping.
        setListening( false );
      }
    }
  }

  /**
   * Returns true if the list of files being listened to for changes contains
   * the given file.
   *
   * @param file Path to a system file.
   * @return true The given file is being monitored for changes.
   */
  private boolean isListening( final Path file ) {
    return getEavesdropped().contains( file );
  }

  /**
   * Returns a path for a given watch key.
   *
   * @param key The key to lookup its corresponding path.
   * @return The path for the given key.
   */
  private Path get( final WatchKey key ) {
    return getWatchMap().get( key );
  }

  private synchronized Map<WatchKey, Path> getWatchMap() {
    if( this.keys == null ) {
      this.keys = createWatchKeys();
    }

    return this.keys;
  }

  protected Map<WatchKey, Path> createWatchKeys() {
    return new ConcurrentHashMap<>();
  }

  /**
   * Returns a list of files that, when changed, will kick off a notification.
   *
   * @return A non-null, possibly empty, list of files.
   */
  private synchronized Set<Path> getEavesdropped() {
    if( this.eavesdropped == null ) {
      this.eavesdropped = createEavesdropped();
    }

    return this.eavesdropped;
  }

  protected Set<Path> createEavesdropped() {
    return ConcurrentHashMap.newKeySet();
  }

  /**
   * The existing watch service, or a new instance if null.
   *
   * @return A valid WatchService instance, never null.
   * @throws IOException Could not create a new watch service.
   */
  private synchronized WatchService getWatchService() throws IOException {
    if( this.watchService == null ) {
      this.watchService = createWatchService();
    }

    return this.watchService;
  }

  protected WatchService createWatchService() throws IOException {
    final FileSystem fileSystem = FileSystems.getDefault();
    return fileSystem.newWatchService();
  }

  /**
   * Answers whether the loop should continue executing.
   *
   * @return true The internal listening loop should continue listening for file
   * modification events.
   */
  protected boolean isListening() {
    return this.listening;
  }

  /**
   * Requests the snitch to stop eavesdropping on file changes.
   *
   * @param listening Use true to indicate the service should stop running.
   */
  private void setListening( final boolean listening ) {
    this.listening = listening;
  }
}