001/*
002 * Gridarta MMORPG map editor for Crossfire, Daimonin and similar games.
003 * Copyright (C) 2000-2010 The Gridarta Developers.
004 *
005 * This program is free software; you can redistribute it and/or modify
006 * it under the terms of the GNU General Public License as published by
007 * the Free Software Foundation; either version 2 of the License, or
008 * (at your option) any later version.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
013 * GNU General Public License for more details.
014 *
015 * You should have received a copy of the GNU General Public License along
016 * with this program; if not, write to the Free Software Foundation, Inc.,
017 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
018 */
019
020package net.sf.gridarta.model.index;
021
022import java.io.File;
023import java.io.FileInputStream;
024import java.io.FileNotFoundException;
025import java.io.FileOutputStream;
026import java.io.IOException;
027import java.io.InputStream;
028import java.io.ObjectInputStream;
029import java.io.ObjectOutputStream;
030import java.io.OutputStream;
031import java.io.UnsupportedEncodingException;
032import java.util.Arrays;
033import java.util.concurrent.Semaphore;
034import net.sf.gridarta.model.archetype.Archetype;
035import net.sf.gridarta.model.gameobject.GameObject;
036import net.sf.gridarta.model.maparchobject.MapArchObject;
037import net.sf.gridarta.model.mapcontrol.DefaultMapControl;
038import net.sf.gridarta.model.mapcontrol.MapControl;
039import net.sf.gridarta.model.mapcontrol.MapControlListener;
040import net.sf.gridarta.model.mapmanager.MapManager;
041import net.sf.gridarta.model.mapmanager.MapManagerListener;
042import net.sf.gridarta.model.settings.ProjectSettings;
043import net.sf.gridarta.model.settings.ProjectSettingsListener;
044import net.sf.gridarta.utils.ConfigFileUtils;
045import net.sf.gridarta.utils.Xtea;
046import org.apache.log4j.Category;
047import org.apache.log4j.Logger;
048import org.jetbrains.annotations.NotNull;
049import org.jetbrains.annotations.Nullable;
050
051/**
052 * Maintains a {@link MapsIndex} for the maps directory. Changed maps are
053 * tracked and the index is updated.
054 * @author Andreas Kirschbaum
055 */
056public class MapsIndexer<G extends GameObject<G, A, R>, A extends MapArchObject<A>, R extends Archetype<G, A, R>> {
057
058    /**
059     * The Logger for printing log messages.
060     */
061    @NotNull
062    private static final Category log = Logger.getLogger(MapsIndexer.class);
063
064    /**
065     * A pending map may exist in {@link #mapsIndex} if a permit is available.
066     */
067    @NotNull
068    private final Semaphore mapsIndexSemaphore = new Semaphore(1);
069
070    /**
071     * The {@link MapsIndex} being updated.
072     */
073    @NotNull
074    private final MapsIndex mapsIndex;
075
076    /**
077     * The {@link MapManager} for loading map files.
078     */
079    @NotNull
080    private final MapManager<G, A, R> mapManager;
081
082    /**
083     * The {@link ProjectSettings} instance.
084     */
085    @NotNull
086    private final ProjectSettings projectSettings;
087
088    /**
089     * The object for synchronizing access to {@link #mapsDirectory} and {@link
090     * #newMapsDirectory}.
091     */
092    @NotNull
093    private final Object syncMapsDirectory = new Object();
094
095    /**
096     * The currently indexed maps directory.
097     */
098    @Nullable
099    private File mapsDirectory;
100
101    /**
102     * The maps directory to index. If <code>null</code>, {@link #mapsDirectory}
103     * is valid.
104     */
105    @Nullable
106    private File newMapsDirectory;
107
108    /**
109     * The object for synchronizing access to {@link #state}.
110     */
111    @NotNull
112    private final Object syncState = new Object();
113
114    /**
115     * The indexer state.
116     */
117    @NotNull
118    private State state = State.INIT;
119
120    /**
121     * Indexer states.
122     */
123    private enum State {
124
125        /**
126         * Indexer has been created but is not yet running.
127         */
128        INIT,
129
130        /**
131         * Indexer is scanning the maps directory searching for files differing
132         * to the current index.
133         */
134        SCAN,
135
136        /**
137         * Indexer is indexing maps differing from the current index.
138         */
139        INDEX,
140
141        /**
142         * Indexer is idle; the current index is up-to-date.
143         */
144        IDLE
145
146    }
147
148    /**
149     * The {@link MapManagerListener} attached to {@link #mapManager} to detect
150     * current map changes.
151     */
152    private final MapManagerListener<G, A, R> mapManagerListener = new MapManagerListener<G, A, R>() {
153
154        @Override
155        public void currentMapChanged(@Nullable final MapControl<G, A, R> mapControl) {
156            // ignore
157        }
158
159        @Override
160        public void mapCreated(@NotNull final MapControl<G, A, R> mapControl, final boolean interactive) {
161            if (!mapControl.isPickmap()) {
162                mapControl.addMapControlListener(mapControlListener);
163            }
164        }
165
166        @Override
167        public void mapClosing(@NotNull final MapControl<G, A, R> mapControl) {
168            // ignore
169        }
170
171        @Override
172        public void mapClosed(@NotNull final MapControl<G, A, R> mapControl) {
173            if (!mapControl.isPickmap()) {
174                mapControl.removeMapControlListener(mapControlListener);
175            }
176        }
177
178    };
179
180    /**
181     * The {@link MapControlListener} attached to all opened maps.
182     */
183    @NotNull
184    private final MapControlListener<G, A, R> mapControlListener = new MapControlListener<G, A, R>() {
185
186        @Override
187        public void saved(@NotNull final DefaultMapControl<G, A, R> mapControl) {
188            final File file = mapControl.getMapModel().getMapFile();
189            if (file != null) {
190                mapsIndex.setPending(file);
191            }
192        }
193
194    };
195
196    /**
197     * The {@link ProjectSettingsListener} attached to {@link #projectSettings}
198     * for tracking changed maps directories.
199     */
200    @NotNull
201    private final ProjectSettingsListener projectSettingsListener = new ProjectSettingsListener() {
202
203        @Override
204        public void mapsDirectoryChanged(@NotNull final File mapsDirectory) {
205            synchronized (syncMapsDirectory) {
206                newMapsDirectory = projectSettings.getMapsDirectory();
207            }
208        }
209
210    };
211
212    /**
213     * The {@link IndexListener} attached to {@link #mapsIndex} to detect newly
214     * added pending maps.
215     */
216    @NotNull
217    private final IndexListener<File> indexListener = new IndexListener<File>() {
218
219        @Override
220        public void valueAdded(@NotNull final File value) {
221            // ignore
222        }
223
224        @Override
225        public void valueRemoved(@NotNull final File value) {
226            // ignore
227        }
228
229        @Override
230        public void nameChanged() {
231            // ignore
232        }
233
234        @Override
235        public void pendingChanged() {
236            mapsIndexSemaphore.release();
237        }
238
239        @Override
240        public void indexingFinished() {
241            // ignore
242        }
243
244    };
245
246    /**
247     * The {@link Runnable} scanning the maps directory and updating the index.
248     */
249    @NotNull
250    private final Runnable runnable = new Runnable() {
251
252        @Override
253        public void run() {
254            while (!Thread.currentThread().isInterrupted()) {
255                updateMapsDirectory();
256                indexPendingMaps();
257                if (state == State.IDLE) {
258                    try {
259                        mapsIndexSemaphore.acquire();
260                    } catch (final InterruptedException ignored) {
261                        Thread.currentThread().interrupt();
262                        break;
263                    }
264                }
265            }
266        }
267
268    };
269
270    /**
271     * The {@link Thread} executing {@link #runnable}.
272     */
273    @NotNull
274    private final Thread thread = new Thread(runnable, "indexer");
275
276    /**
277     * Creates a new instance.
278     * @param mapsIndex the maps index to update
279     * @param mapManager the map manager for loading map files
280     * @param projectSettings the project settings instance; defines the indexed
281     * maps directory
282     */
283    public MapsIndexer(@NotNull final MapsIndex mapsIndex, @NotNull final MapManager<G, A, R> mapManager, @NotNull final ProjectSettings projectSettings) {
284        this.mapsIndex = mapsIndex;
285        this.mapManager = mapManager;
286        this.projectSettings = projectSettings;
287        reportStateChange();
288    }
289
290    /**
291     * Starts indexing. Must not be called more than once.
292     */
293    public void start() {
294        projectSettings.addProjectSettingsListener(projectSettingsListener);
295        mapManager.addMapManagerListener(mapManagerListener);
296        synchronized (syncMapsDirectory) {
297            newMapsDirectory = projectSettings.getMapsDirectory();
298        }
299        mapsIndex.addIndexListener(indexListener);
300        thread.start();
301    }
302
303    /**
304     * Stops indexing. Must not be called more than once or before {@link
305     * #start()} has been called.
306     * @throws InterruptedException if the current thread was interrupted
307     */
308    public void stop() throws InterruptedException {
309        try {
310            thread.interrupt();
311            thread.join();
312        } finally {
313            projectSettings.removeProjectSettingsListener(projectSettingsListener);
314            mapManager.removeMapManagerListener(mapManagerListener);
315            for (final MapControl<G, A, R> mapControl : mapManager.getOpenedMaps()) {
316                if (!mapControl.isPickmap()) {
317                    mapControl.removeMapControlListener(mapControlListener);
318                }
319            }
320            mapsIndex.removeIndexListener(indexListener);
321        }
322
323        synchronized (syncMapsDirectory) {
324            saveMapsIndex();
325        }
326    }
327
328    /**
329     * Blocks the calling thread until all pending maps have been indexed.
330     * @throws InterruptedException if the current thread was interrupted during
331     * wait
332     */
333    public void waitForIdle() throws InterruptedException {
334        synchronized (syncState) {
335            while (state != State.IDLE) {
336                syncState.wait(1000L);
337            }
338        }
339    }
340
341    /**
342     * Checks whether {@link #newMapsDirectory} has been set and updates {@link
343     * #mapsDirectory} accordingly.
344     */
345    private void updateMapsDirectory() {
346        final File tmpMapsDirectory;
347        synchronized (syncMapsDirectory) {
348            if (newMapsDirectory == null || (mapsDirectory != null && mapsDirectory.equals(newMapsDirectory))) {
349                return;
350            }
351
352            setState(State.SCAN);
353
354            saveMapsIndex();
355
356            tmpMapsDirectory = newMapsDirectory;
357            assert tmpMapsDirectory != null;
358            mapsDirectory = tmpMapsDirectory;
359            newMapsDirectory = null;
360
361            loadMapsIndex();
362        }
363        mapsIndex.beginUpdate();
364        scanMapsDirectoryInt(tmpMapsDirectory, "");
365        mapsIndex.endUpdate();
366    }
367
368    /**
369     * Saves {@link #mapsIndex} to its cache file if modified.
370     */
371    private void saveMapsIndex() {
372        assert Thread.holdsLock(syncMapsDirectory);
373        if (mapsDirectory == null) {
374            return;
375        }
376
377        if (!projectSettings.saveIndices()) {
378            return;
379        }
380
381        if (!mapsIndex.isModified()) {
382            return;
383        }
384
385        assert mapsDirectory != null;
386        final File cacheFile = getCacheFile(mapsDirectory);
387        try {
388            final OutputStream outputStream = new FileOutputStream(cacheFile);
389            try {
390                final ObjectOutputStream objectOutputStream = new ObjectOutputStream(outputStream);
391                try {
392                    mapsIndex.save(objectOutputStream);
393                } finally {
394                    objectOutputStream.close();
395                }
396            } finally {
397                outputStream.close();
398            }
399            if (log.isInfoEnabled()) {
400                log.info(cacheFile + ": saved " + mapsIndex.size() + " entries");
401            }
402        } catch (final IOException ex) {
403            log.warn(cacheFile + ": cannot save cache file: " + ex.getMessage());
404        }
405    }
406
407    /**
408     * Loads {@link #mapsIndex} from its cache file.
409     */
410    private void loadMapsIndex() {
411        assert Thread.holdsLock(syncMapsDirectory);
412        assert mapsDirectory != null;
413        final File cacheFile = getCacheFile(mapsDirectory);
414        try {
415            final InputStream inputStream = new FileInputStream(cacheFile);
416            try {
417                final ObjectInputStream objectInputStream = new ObjectInputStream(inputStream);
418                try {
419                    mapsIndex.load(objectInputStream);
420                } finally {
421                    objectInputStream.close();
422                }
423            } finally {
424                inputStream.close();
425            }
426            if (log.isInfoEnabled()) {
427                log.info(cacheFile + ": loaded " + mapsIndex.size() + " entries");
428            }
429        } catch (final FileNotFoundException ex) {
430            if (log.isDebugEnabled()) {
431                log.debug(cacheFile + ": cache file does not exist: " + ex.getMessage());
432            }
433            mapsIndex.clear();
434        } catch (final IOException ex) {
435            log.warn(cacheFile + ": cannot load cache file: " + ex.getMessage());
436            mapsIndex.clear();
437        }
438    }
439
440    /**
441     * Scans a directory for files. Adds all files found to {@link #mapsIndex}.
442     * @param dir the directory to scan
443     * @param mapPath the map path corresponding to <code>dir</code>
444     */
445    private void scanMapsDirectoryInt(@NotNull final File dir, @NotNull final String mapPath) {
446        final File[] files = dir.listFiles();
447        if (files == null) {
448            return;
449        }
450
451        for (final File file : files) {
452            if (file.isFile() && !file.getName().endsWith("~")) {
453                mapsIndex.add(file, file.lastModified());
454            } else if (file.isDirectory() && !file.getName().equalsIgnoreCase(".svn") && !file.getName().equalsIgnoreCase(".dedit")) {
455                scanMapsDirectoryInt(file, mapPath + "/" + file.getName());
456            }
457        }
458    }
459
460    /**
461     * Indexes one pending map from {@link #mapsIndex}. Does nothing if no
462     * pending map exists.
463     */
464    private void indexPendingMaps() {
465        final File file = mapsIndex.removePending();
466        if (file == null) {
467            setState(State.IDLE);
468            return;
469        }
470
471        setState(State.INDEX);
472        final long timestamp = file.lastModified();
473        final MapControl<G, A, R> mapControl;
474        try {
475            mapControl = mapManager.openMapFile(file, false);
476        } catch (final IOException ex) {
477            if (log.isInfoEnabled()) {
478                log.info(file + ": load failed:" + ex.getMessage());
479            }
480            return;
481        }
482        try {
483            final String mapName = mapControl.getMapModel().getMapArchObject().getMapName();
484            if (log.isDebugEnabled()) {
485                log.debug(file + ": indexing as '" + mapName + "'");
486            }
487            mapsIndex.setName(file, timestamp, mapName);
488        } finally {
489            mapManager.release(mapControl);
490        }
491    }
492
493    /**
494     * Returns the cache file for a given maps directory.
495     * @param mapsDirectory the maps directory
496     * @return the cache file
497     */
498    @NotNull
499    private static File getCacheFile(@NotNull final File mapsDirectory) {
500        final byte[] key = new byte[16];
501        final Xtea xtea = new Xtea(key);
502        final byte[] data;
503        try {
504            data = mapsDirectory.getAbsoluteFile().toString().getBytes("UTF-8");
505        } catch (final UnsupportedEncodingException ex) {
506            throw new AssertionError(ex); // UTF-8 must be supported
507        }
508        final byte[] hash = new byte[8];
509        final byte[] in = new byte[8];
510        final byte[] out = new byte[8];
511        int i;
512        for (i = 0; i + 8 < data.length; i++) {
513            System.arraycopy(data, i, in, 0, 8);
514            xtea.encrypt(in, out);
515            for (int j = 0; j < 8; j++) {
516                hash[j] ^= out[j];
517            }
518        }
519        final int len = data.length % 8;
520        System.arraycopy(data, i, in, 0, len);
521        in[len] = (byte) 1;
522        Arrays.fill(in, len + 1, 8, (byte) 0);
523        xtea.encrypt(in, out);
524        for (int j = 0; j < 8; j++) {
525            hash[j] ^= out[j];
526        }
527
528        final StringBuilder sb = new StringBuilder("index/maps/");
529        for (int j = 0; j < 8; j++) {
530            sb.append(String.format("%02x", hash[j] & 0xff));
531        }
532        sb.append(mapsDirectory.getName());
533        final File file = ConfigFileUtils.getHomeFile(sb.toString());
534        final File dir = file.getParentFile();
535        if (dir != null && !dir.exists() && !dir.mkdirs()) {
536            log.warn("cannot create directory: " + dir);
537        }
538        return file;
539    }
540
541    /**
542     * Sets a new value to {@link #state}. Logs changed values.
543     * @param state the new state
544     */
545    private void setState(@NotNull final State state) {
546        synchronized (syncState) {
547            if (this.state == state) {
548                return;
549            }
550
551            this.state = state;
552            reportStateChange();
553            syncState.notifyAll();
554        }
555    }
556
557    /**
558     * Logs a changed value of {@link #state}.
559     */
560    private void reportStateChange() {
561        if (log.isDebugEnabled()) {
562            log.debug("state=" + state);
563        }
564
565        mapsIndex.indexingFinished();
566    }
567
568}