001/*
002 * Gridarta MMORPG map editor for Crossfire, Daimonin and similar games.
003 * Copyright (C) 2000-2011 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.updater;
021
022import java.awt.Component;
023import java.io.File;
024import java.io.FileOutputStream;
025import java.io.IOException;
026import java.io.InputStream;
027import java.io.InterruptedIOException;
028import java.io.OutputStream;
029import java.net.Proxy;
030import java.net.URL;
031import java.net.URLConnection;
032import java.net.UnknownHostException;
033import java.util.MissingResourceException;
034import java.util.PropertyResourceBundle;
035import java.util.ResourceBundle;
036import java.util.prefs.Preferences;
037import javax.swing.JOptionPane;
038import javax.swing.ProgressMonitor;
039import javax.swing.ProgressMonitorInputStream;
040import net.sf.gridarta.MainControl;
041import net.sf.gridarta.gui.dialog.prefs.NetPreferences;
042import net.sf.gridarta.utils.ActionBuilderUtils;
043import net.sf.gridarta.utils.Exiter;
044import net.sf.japi.swing.action.ActionBuilder;
045import net.sf.japi.swing.action.ActionBuilderFactory;
046import org.apache.log4j.Category;
047import org.apache.log4j.Logger;
048import org.jetbrains.annotations.NotNull;
049import org.jetbrains.annotations.Nullable;
050
051/**
052 * This class handles updating the map editor.
053 * @author <a href="mailto:cher@riedquat.de">Christian.Hujer</a>
054 * @fixme the updater fails on windows, the user is notified of this but still
055 * it isn't nice. The updater should be a separate application.
056 * @todo move the updater to JAPI
057 */
058public class Updater implements Runnable {
059
060    /**
061     * Action Builder to create Actions.
062     */
063    @NotNull
064    private static final ActionBuilder ACTION_BUILDER = ActionBuilderFactory.getInstance().getActionBuilder("net.sf.gridarta");
065
066    /**
067     * Logger.
068     */
069    @NotNull
070    private static final Category log = Logger.getLogger(Updater.class);
071
072    /**
073     * Preferences.
074     */
075    @NotNull
076    private static final Preferences preferences = Preferences.userNodeForPackage(MainControl.class);
077
078    /**
079     * Preferences key for last update.
080     */
081    @NotNull
082    public static final String LAST_UPDATE_KEY = "UpdateTimestamp";
083
084    /**
085     * The parentComponent to show dialogs on.
086     */
087    @Nullable
088    private final Component parentComponent;
089
090    /**
091     * The {@link Exiter} for terminating the application.
092     */
093    @NotNull
094    private final Exiter exiter;
095
096    /**
097     * The file to update.
098     */
099    @NotNull
100    private final String updateFileName;
101
102    /**
103     * Buffer size.
104     */
105    private static final int BUF_SIZE = 4096;
106
107    /**
108     * Create a new instance.
109     * @param parentComponent the parent component to show dialogs on
110     * @param exiter the exiter for terminating the application
111     * @param updateFileName the file to update
112     */
113    public Updater(@Nullable final Component parentComponent, @NotNull final Exiter exiter, @NotNull final String updateFileName) {
114        this.parentComponent = parentComponent;
115        this.exiter = exiter;
116        this.updateFileName = updateFileName;
117        if (parentComponent != null) {
118            parentComponent.setEnabled(false);
119        }
120    }
121
122    /**
123     * {@inheritDoc}
124     */
125    @Override
126    public void run() {
127        try {
128            final String propUrl = ACTION_BUILDER.getString("update.url");
129            if (propUrl == null) {
130                return;
131            }
132
133            final InputStream pin = openStream(propUrl);
134            try {
135                final ResourceBundle updateBundle = new PropertyResourceBundle(pin);
136                final String downloadUrl = updateBundle.getString("update.url");
137                if (downloadUrl == null) {
138                    ACTION_BUILDER.showMessageDialog(parentComponent, "updateError", "invalid server response: update.url is missing");
139                    return;
140                }
141                final VersionInfo update = new VersionInfo(updateBundle, "update");
142                VersionInfo active = VersionInfo.UNAVAILABLE;
143                try {
144                    active = new VersionInfo(ResourceBundle.getBundle("build"), "build");
145                } catch (final MissingResourceException e) {
146                    ACTION_BUILDER.showMessageDialog(parentComponent, "updateActiveVersionUnavailable");
147                }
148                preferences.putLong(LAST_UPDATE_KEY, System.currentTimeMillis());
149                if (active == null || update.isNewerThan(active)) {
150                    if (askIfUserWantsUpdate(active, update, propUrl, downloadUrl)) {
151                        downloadAndInstallUpdate(downloadUrl);
152                    }
153                } else {
154                    noNewUpdate(active, update, propUrl, downloadUrl);
155                }
156            } finally {
157                pin.close();
158            }
159        } catch (final UnknownHostException e) {
160            ACTION_BUILDER.showMessageDialog(parentComponent, "updateError", e.getLocalizedMessage());
161        } catch (final IOException e) {
162            ACTION_BUILDER.showMessageDialog(parentComponent, "updateError", e);
163        } finally {
164            if (parentComponent != null) {
165                parentComponent.setEnabled(true);
166            }
167        }
168    }
169
170    /**
171     * Ask the user whether he wants to update.
172     * @param active VersionInfo of currently installed version
173     * @param update VersionInfo of available update version
174     * @param propUrl URL where properties were downloaded from
175     * @param downloadUrl URL where the update would be downloaded from
176     * @return <code>true</code> if the user chose that he wants to update,
177     *         otherwise <code>false</code>
178     */
179    private boolean askIfUserWantsUpdate(@Nullable final VersionInfo active, @NotNull final VersionInfo update, @NotNull final String propUrl, @NotNull final String downloadUrl) {
180        return ACTION_BUILDER.showConfirmDialog(parentComponent, JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE, "updateAvailable", updateFileName, active == null ? "?" : active.version, update.version, active == null ? "?" : active.developer, update.developer, active == null ? "?" : active.timestamp, update.timestamp, propUrl, downloadUrl) == JOptionPane.YES_OPTION;
181    }
182
183    /**
184     * Tell the user there is no update.
185     * @param active VersionInfo of currently installed version
186     * @param update VersionInfo of available update version
187     * @param propUrl URL where properties were downloaded from
188     * @param downloadUrl URL where the update would be downloaded from
189     */
190    private void noNewUpdate(@Nullable final VersionInfo active, @NotNull final VersionInfo update, @NotNull final String propUrl, @NotNull final String downloadUrl) {
191        ACTION_BUILDER.showMessageDialog(parentComponent, "updateUnavailable", active == null ? "?" : active.version, update.version, active == null ? "?" : active.developer, update.developer, active == null ? "?" : active.timestamp, update.timestamp, propUrl, downloadUrl);
192    }
193
194    /**
195     * Download and install an update.
196     * @param url URL to get update from
197     */
198    private void downloadAndInstallUpdate(@NotNull final String url) {
199        final File download = new File(updateFileName + ".tmp"); // TODO: print error message if file already exists
200        final File backup = new File(updateFileName + ".bak");
201        final File orig = new File(updateFileName);
202        try {
203            final InputStream in = openStream(url);
204            try {
205                final OutputStream out = new FileOutputStream(download);
206                try {
207                    final byte[] buf = new byte[BUF_SIZE];
208                    while (true) {
209                        final int bytesRead = in.read(buf);
210                        if (bytesRead == -1) {
211                            break;
212                        }
213                        out.write(buf, 0, bytesRead);
214                    }
215                    out.close();
216                    if (/* !backup.delete() || */ !orig.renameTo(backup)) {
217                        ACTION_BUILDER.showMessageDialog(parentComponent, "updateFailedNoBackup", updateFileName);
218                    } else if (!download.renameTo(orig)) {
219                        backup.renameTo(orig);
220                        ACTION_BUILDER.showMessageDialog(parentComponent, "updateFailedNoDownload");
221                    } else {
222                        ACTION_BUILDER.showMessageDialog(parentComponent, "updateRestart", updateFileName);
223                        exiter.doExit(0);
224                    }
225                } finally {
226                    out.close();
227                }
228            } finally {
229                in.close();
230            }
231        } catch (final InterruptedIOException e) {
232            ACTION_BUILDER.showMessageDialog(parentComponent, "updateAborted");
233        } catch (final Exception e) {
234            log.warn("updateError", e);
235            ACTION_BUILDER.showMessageDialog(parentComponent, "updateError", e);
236        }
237    }
238
239    /**
240     * Opens an InputStream on a URL.
241     * @param url the URL to open InputStream on
242     * @return the InputStream for URL
243     * @throws IOException in case of I/O problems
244     */
245    @NotNull
246    // The stream is closed by caller
247    private InputStream openStream(@NotNull final String url) throws IOException {
248        final Proxy proxy = NetPreferences.getProxy();
249        final URLConnection con = new URL(url).openConnection(proxy);
250        final ProgressMonitorInputStream stream = new ProgressMonitorInputStream(parentComponent, ActionBuilderUtils.getString(ACTION_BUILDER, "updateProgress.title"), con.getInputStream());
251        final ProgressMonitor monitor = stream.getProgressMonitor();
252        monitor.setMaximum(con.getContentLength());
253        monitor.setNote(ActionBuilderUtils.getString(ACTION_BUILDER, "updateProgress"));
254        monitor.setMillisToDecideToPopup(10);
255        monitor.setMillisToPopup(10);
256        return stream;
257    }
258
259    /**
260     * Class for holding version information and quickly comparing it.
261     */
262    private static class VersionInfo {
263
264        /**
265         * Update information: Version of update version, usually the build
266         * number.
267         */
268        @NotNull
269        private final String version;
270
271        /**
272         * Update information: Time stamp of update version.
273         */
274        @NotNull
275        private final String timestamp;
276
277        /**
278         * Update information: Developer that created the update version.
279         */
280        @NotNull
281        private final String developer;
282
283        /**
284         * Special Version "unavailable".
285         */
286        @NotNull
287        private static final VersionInfo UNAVAILABLE = new VersionInfo();
288
289        /**
290         * Private constructor used for unavailable versions.
291         */
292        private VersionInfo() {
293            this("unavailable", "unavailable", "unavailable");
294        }
295
296        /**
297         * Private constructor to map the strings.
298         * @param version the version
299         * @param timestamp the timestamp
300         * @param developer the developer
301         */
302        private VersionInfo(@NotNull final String version, @NotNull final String timestamp, @NotNull final String developer) {
303            this.version = version;
304            this.timestamp = timestamp;
305            this.developer = developer;
306        }
307
308        /**
309         * Create update information from a ResourceBundle. The ResourceBundle
310         * should have Strings for <var>prefix</var> + <code>.number</code>,
311         * <code>.tstamp</code> and <code>.developer</code>.
312         * @param bundle ResourceBundle to create update information from
313         * @param prefix Prefix for update information within the resource
314         * bundle
315         */
316        private VersionInfo(@NotNull final ResourceBundle bundle, @NotNull final String prefix) {
317            this(bundle.getString(prefix + ".number"), bundle.getString(prefix + ".tstamp"), bundle.getString(prefix + ".developer"));
318        }
319
320        /**
321         * Check whether this version is newer than another version.
322         * @param other Other version information to compare to
323         * @return <code>true</code> if this version is newer than
324         *         <var>other</var>, otherwise <code>false</code>
325         */
326        @SuppressWarnings("ObjectEquality")
327        boolean isNewerThan(@NotNull final VersionInfo other) {
328            return this != UNAVAILABLE && (other == UNAVAILABLE || timestamp.compareTo(other.timestamp) > 0);
329        }
330
331    }
332
333}