/* * AutoRecoveryManager.java 21 nov. 2006 * * Sweet Home 3D, Copyright (c) 2010 Emmanuel PUYBARET / eTeks <info@eteks.com> * * 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 2 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, write to the Free Software * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA */ package com.eteks.sweethome3d.io; import java.awt.EventQueue; import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; import java.io.File; import java.io.FileFilter; import java.io.FileOutputStream; import java.io.IOException; import java.lang.reflect.InvocationTargetException; import java.nio.channels.OverlappingFileLockException; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Timer; import java.util.TimerTask; import java.util.UUID; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; import com.eteks.sweethome3d.model.CollectionEvent; import com.eteks.sweethome3d.model.CollectionListener; import com.eteks.sweethome3d.model.Home; import com.eteks.sweethome3d.model.HomeApplication; import com.eteks.sweethome3d.model.HomeRecorder; import com.eteks.sweethome3d.model.InterruptedRecorderException; import com.eteks.sweethome3d.model.RecorderException; import com.eteks.sweethome3d.model.UserPreferences; import com.eteks.sweethome3d.model.UserPreferences.Property; import com.eteks.sweethome3d.tools.OperatingSystem; /** * Manager able to automatically save open homes in recovery folder with a timer. * The delay between two automatic save operations is specified by * {@link UserPreferences#getAutoSaveDelayForRecovery() auto save delay for recovery} * property. * @author Emmanuel Puybaret */ public class AutoRecoveryManager { private static final int MINIMUM_DELAY_BETWEEN_AUTO_SAVE_OPERATIONS = 30000; private static final String RECOVERY_SUB_FOLDER = "recovery"; private static final String RECOVERED_FILE_EXTENSION = ".recovered"; private static final String UNRECOVERABLE_FILE_EXTENSION = ".unrecoverable"; private final HomeApplication application; private final List<Home> recoveredHomes = new ArrayList<Home>(); // The auto saved files and their locked output streams are handled // only in autoSaveForRecoveryExecutor single thread executor private final Map<Home, File> autoSavedFiles = new HashMap<Home, File>(); private final Map<File, FileOutputStream> lockedOutputStreams = new HashMap<File, FileOutputStream>(); private final ExecutorService autoSaveForRecoveryExecutor; private Timer timer; private long lastAutoSaveTime; /** * Creates a manager able to automatically recover <code>application</code> homes. * As this constructor adds some listeners on <code>application</code> instance and its preferences, * it should be invoked only from the same thread where application is modified or at program startup. */ public AutoRecoveryManager(HomeApplication application) throws RecorderException { this.application = application; this.autoSaveForRecoveryExecutor = Executors.newSingleThreadExecutor(new ThreadFactory() { public Thread newThread(Runnable runnable) { Thread thread = new Thread(runnable); thread.setPriority(Thread.MIN_PRIORITY); return thread; } }); readRecoveredHomes(); // Interrupt auto saving when program stops Runtime.getRuntime().addShutdownHook(new Thread() { @Override public void run() { autoSaveForRecoveryExecutor.shutdownNow(); } }); // Remove auto saved files when a home is closed application.addHomesListener(new CollectionListener<Home>() { public void collectionChanged(CollectionEvent<Home> ev) { if (ev.getType() == CollectionEvent.Type.DELETE) { final Home home = ev.getItem(); autoSaveForRecoveryExecutor.submit(new Runnable() { public void run() { try { final File homeFile = autoSavedFiles.get(home); if (homeFile != null) { freeLockedFile(homeFile); homeFile.delete(); autoSavedFiles.remove(home); } } catch (RecorderException ex) { } } }); } } }); // Add a listener on auto save delay that will run auto save timer application.getUserPreferences().addPropertyChangeListener(Property.AUTO_SAVE_DELAY_FOR_RECOVERY, new PropertyChangeListener() { public void propertyChange(PropertyChangeEvent ev) { restartTimer(); } }); restartTimer(); } /** * Reads the homes to recover. */ private void readRecoveredHomes() throws RecorderException { File recoveryFolder = getRecoveryFolder(); File [] recoveredFiles = recoveryFolder.listFiles(new FileFilter() { public boolean accept(File file) { return file.isFile() && file.getName().endsWith(RECOVERED_FILE_EXTENSION); } }); if (recoveredFiles != null) { Arrays.sort(recoveredFiles, new Comparator<File>() { public int compare(File f1, File f2) { if (f1.lastModified() < f2.lastModified()) { return 1; } else { return -1; } } }); for (final File file : recoveredFiles) { if (!isFileLocked(file)) { try { final Home home = this.application.getHomeRecorder().readHome(file.getPath()); // Recovered homes are the ones with a name different from the file path if (home.getName() == null || !file.equals(new File(home.getName()))) { home.setRecovered(true); // Delete recovered file once home isn't recovered anymore home.addPropertyChangeListener(Home.Property.RECOVERED, new PropertyChangeListener() { public void propertyChange(PropertyChangeEvent evt) { if (!home.isRecovered()) { file.delete(); } } }); this.recoveredHomes.add(home); } } catch (RecorderException ex) { ex.printStackTrace(); // Rename file to avoid it to be read again at next launch file.renameTo(new File(recoveryFolder, file.getName().replace(RECOVERED_FILE_EXTENSION, UNRECOVERABLE_FILE_EXTENSION))); } } } } } /** * Returns <code>true</code> if the given file is locked or can't be accessed. */ private boolean isFileLocked(final File file) { FileOutputStream out = null; try { // Check file lock is free out = new FileOutputStream(file, true); return out.getChannel().tryLock() == null; } catch (IOException ex) { // Forget this file return true; } finally { if (out != null) { try { out.close(); } catch (IOException ex) { return true; } } } } /** * Opens recovered homes and adds them to application. */ public void openRecoveredHomes() { for (Home recoveredHome : this.recoveredHomes) { boolean recoveredHomeOpen = false; for (Home home : this.application.getHomes()) { // If recovered home matches an opened home, open it as a new home if (home.getName() != null && home.getName().equals(recoveredHome.getName())) { recoveredHome.setName(null); this.application.addHome(recoveredHome); recoveredHomeOpen = true; break; } } if (!recoveredHomeOpen) { this.application.addHome(recoveredHome); } } // Clear the list to avoid open twice the recovered homes this.recoveredHomes.clear(); } /** * Restarts the timer that regularly saves application homes. */ private void restartTimer() { if (this.timer != null) { this.timer.cancel(); this.timer = null; } int autoSaveDelayForRecovery = this.application.getUserPreferences().getAutoSaveDelayForRecovery(); if (autoSaveDelayForRecovery > 0) { this.timer = new Timer("autoSaveTimer", true); TimerTask task = new TimerTask() { @Override public void run() { if (System.currentTimeMillis() - lastAutoSaveTime > MINIMUM_DELAY_BETWEEN_AUTO_SAVE_OPERATIONS) { cloneAndSaveHomes(); } } }; this.timer.scheduleAtFixedRate(task, autoSaveDelayForRecovery, autoSaveDelayForRecovery); } } /** * Clones application homes and saves them in automatic save executor. */ private void cloneAndSaveHomes() { try { EventQueue.invokeAndWait(new Runnable() { public void run() { // Handle and clone application homes in Event Dispatch Thread for (final Home home : application.getHomes()) { final Home autoSavedHome = home.clone(); final HomeRecorder homeRecorder = application.getHomeRecorder(); autoSaveForRecoveryExecutor.submit(new Runnable() { public void run() { try { // Save home clone in an other thread saveHome(home, autoSavedHome, homeRecorder); } catch (RecorderException ex) { ex.printStackTrace(); } } }); } } }); } catch (InvocationTargetException ex) { throw new RuntimeException(ex); } catch (InterruptedException ex) { // Ignore saving in case of interruption } } /** * Saves the given <code>home</code> in recovery folder. * Must be run only from auto save thread. */ private void saveHome(Home home, Home autoSavedHome, HomeRecorder homeRecorder) throws RecorderException { File autoSavedHomeFile = this.autoSavedFiles.get(home); if (autoSavedHomeFile == null) { File recoveredFilesFolder = getRecoveryFolder(); if (!recoveredFilesFolder.exists()) { if (!recoveredFilesFolder.mkdirs()) { throw new RecorderException("Can't create folder " + recoveredFilesFolder + " to store recovered files"); } } // Find a unique file for home in recovered files sub folder if (autoSavedHome.getName() != null) { String homeFile = new File(autoSavedHome.getName()).getName(); autoSavedHomeFile = new File(recoveredFilesFolder, homeFile + RECOVERED_FILE_EXTENSION); if (autoSavedHomeFile.exists()) { autoSavedHomeFile = new File(recoveredFilesFolder, UUID.randomUUID() + "-" + homeFile + RECOVERED_FILE_EXTENSION); } } else { autoSavedHomeFile = new File(recoveredFilesFolder, UUID.randomUUID() + RECOVERED_FILE_EXTENSION); } } freeLockedFile(autoSavedHomeFile); if (autoSavedHome.isModified()) { this.autoSavedFiles.put(home, autoSavedHomeFile); try { // Save home and lock the saved file to avoid possible auto recovery processes to read it homeRecorder.writeHome(autoSavedHome, autoSavedHomeFile.getPath()); FileOutputStream lockedOutputStream = null; try { lockedOutputStream = new FileOutputStream(autoSavedHomeFile, true); lockedOutputStream.getChannel().lock(); this.lockedOutputStreams.put(autoSavedHomeFile, lockedOutputStream); } catch (OverlappingFileLockException ex) { // Don't try to race with other processes that acquired a lock on the file } catch (IOException ex) { if (lockedOutputStream != null) { try { lockedOutputStream.close(); } catch (IOException ex1) { // Forget it } } throw new RecorderException("Can't lock saved home", ex); } } catch (InterruptedRecorderException ex) { // Forget exception that probably happen because of shutdown hook management } } else { autoSavedHomeFile.delete(); this.autoSavedFiles.remove(home); } this.lastAutoSaveTime = Math.max(this.lastAutoSaveTime, System.currentTimeMillis()); } /** * Frees the given <code>file</code> if it's locked. * Must be run only from auto save thread. */ private void freeLockedFile(File file) throws RecorderException { FileOutputStream lockedOutputStream = this.lockedOutputStreams.get(file); if (lockedOutputStream != null) { // Close stream and free its associated lock try { lockedOutputStream.close(); this.lockedOutputStreams.remove(file); } catch (IOException ex) { throw new RecorderException("Can't close locked stream", ex); } } } /** * Returns the folder where recovered files are stored. */ private File getRecoveryFolder() throws RecorderException { try { UserPreferences userPreferences = this.application.getUserPreferences(); return new File(userPreferences instanceof FileUserPreferences ? ((FileUserPreferences)userPreferences).getApplicationFolder() : OperatingSystem.getDefaultApplicationFolder(), RECOVERY_SUB_FOLDER); } catch (IOException ex) { throw new RecorderException("Can't retrieve recovered files folder", ex); } } }