// Near Infinity - An Infinity Engine Browser and Editor
// Copyright (C) 2001 - 2005 Jon Olav Hauglid
// See LICENSE.txt for license information
package org.infinity.updater;
import java.io.FileInputStream;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.MalformedURLException;
import java.net.Proxy;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.jar.JarFile;
import java.util.prefs.Preferences;
import java.util.zip.ZipEntry;
import org.infinity.NearInfinity;
import org.infinity.util.io.FileManager;
/**
* Provides functions for checking, downloading and updating new versions of Near Infinity.
*/
public class Updater
{
// Auto-check interval constants
static final int UPDATE_INTERVAL_SESSION = 0;
static final int UPDATE_INTERVAL_DAILY = 1;
static final int UPDATE_INTERVAL_PER_WEEK = 2; // default
static final int UPDATE_INTERVAL_PER_MONTH = 3;
// Name of the update server definition file
private static final String UPDATE_FILENAME = "update.xml";
// A hardcoded list of default update servers that will be used if no update servers have been specified.
private static final String[] DEFAULT_SERVERS = {
"https://nearinfinitybrowser.github.io/NearInfinity/update/update.xml",
"https://argent77.github.io/NearInfinity/update/update.xml"
};
// Number of supported update servers
private static final int PREFS_SERVER_COUNT = 4;
// The preferences key format string for server URLs
private static final String PREFS_SERVER_FMT = "UpdateServer%1$d";
// preferences key for determining whether to check for stable NI releases only
private static final String PREFS_STABLEONLY = "UpdateStableReleasesOnly";
// preferences key for determining whether automatic update checks are enabled
private static final String PREFS_AUTOCHECK_UPDATES = "UpdateAutoCheckEnabled";
// preferences key for auto-check interval (as specified by the interval constants above)
private static final String PREFS_AUTOCHECK_INTERAVAL = "UpdateAutoCheckInterval";
// preferences key for the date/time of the last auto-check attempt
private static final String PREFS_AUTOCHECK_TIMESTAMP = "UpdateAutoCheckTimeStamp";
// preferences key for determining whether to use a proxy
private static final String PREFS_PROXYENABLED = "UpdateProxyEnabled";
// preferences key for proxy host address (if any)
private static final String PREFS_PROXYHOST = "UpdateProxyHost";
// preferences key for proxy port (if any)
private static final String PREFS_PROXYPORT = "UpdateProxyPort";
// preferences key for storing the hash found on the update server
// (needed to trigger notifications only once for each new release)
private static final String PREFS_UPDATE_HASH = "UpdateReleaseHash";
// preferences key for storing the NI version found on the update server
// (needed to trigger notifications only once for each new release)
private static final String PREFS_UPDATE_VERSION = "UpdateReleaseVersion";
// preferences key for storing the timestamp of the file found on the update server
// (needed to trigger notifications only once for each new release)
private static final String PREFS_UPDATE_TIMESTAMP = "UpdateReleaseTimestamp";
private static Updater instance = null;
private final List<String> serverList = new ArrayList<String>();
private Preferences prefs;
private String hash, version, timestamp;
private Calendar autoCheckDate;
private Proxy proxy;
private int autoCheckInterval;
private boolean stableOnly, autoCheckEnabled, proxyEnabled;
/** Returns a list of predefined server URLs. */
public static String[] getDefaultServerList()
{
return DEFAULT_SERVERS;
}
/** Returns the maximum supported number of updater servers. */
public static int getMaxServerCount()
{
return PREFS_SERVER_COUNT;
}
public static Updater getInstance()
{
if (instance == null) {
instance = new Updater();
}
return instance;
}
/**
* Returns whether the specified release can be considered a new release.
* @param release The release to check.
* @param onlyOnce If {@code true}, each new release will be checked only once.
* @return {@code true} if the specified release is considered newer, {@code false} otherwise.
*/
public static boolean isNewRelease(UpdateInfo.Release release, boolean onlyOnce)
{
boolean isNewer = false;
if (release != null && release.isValid()) {
String curHash = null;
String curVersion = null;
Calendar curCal = null;
if (onlyOnce && !getInstance().getCurrentHash().isEmpty() &&
!(getInstance().getCurrentTimeStamp().isEmpty() || getInstance().getCurrentVersion().isEmpty())) {
curHash = getInstance().getCurrentHash();
curVersion = getInstance().getCurrentVersion();
curCal = Utils.toCalendar(getInstance().getCurrentTimeStamp());
} else {
curHash = getJarFileHash();
curVersion = NearInfinity.getVersion();
curCal = getJarFileDate();
}
if (curHash != null && !curHash.isEmpty()) {
String newHash = release.getHash();
String newVersion = release.getVersion();
Calendar newCal = release.getTimeStamp();
isNewer = !curHash.equalsIgnoreCase(newHash);
if (curCal != null && newCal != null) {
isNewer &= (curCal.compareTo(newCal) < 0);
} else if (curVersion != null && newVersion != null) {
isNewer &= !curVersion.equalsIgnoreCase(newVersion);
}
getInstance().setCurrentHash(newHash);
getInstance().setCurrentVersion(newVersion);
getInstance().setCurrentTimeStamp(Utils.toTimeStamp(newCal));
}
}
return isNewer;
}
/** Returns the modification time of the current JAR's MANIFEST.MF. */
static Calendar getJarFileDate()
{
String jarPath = Utils.getJarFileName(NearInfinity.class);
if (jarPath != null && !jarPath.isEmpty()) {
try {
JarFile jf = new JarFile(jarPath);
try {
ZipEntry manifest = jf.getEntry("META-INF/MANIFEST.MF");
if (manifest != null) {
Calendar cal = Calendar.getInstance();
if (manifest.getTime() >= 0L) {
cal.setTimeInMillis(manifest.getTime());
return cal;
}
}
} finally {
jf.close();
}
} catch (Exception e) {
e.printStackTrace();
}
}
return null;
}
/**
* Calculates the checksum of the current JAR file using the specified hash algorithm.
* @return The MD5 checksum of the current JAR file or empty string on error.
*/
static String getJarFileHash()
{
String path = Utils.getJarFileName(NearInfinity.class);
if (path != null && !path.isEmpty()) {
Path jarPath = FileManager.resolve(path);
if (Files.isRegularFile(jarPath)) {
try {
return Utils.generateMD5Hash(new FileInputStream(path));
} catch (IOException e) {
}
}
}
return "";
}
/**
* Checks whether server1 and server2 are the same.
* Servers are considered the same if one server is part of or equal to the other.
* Empty server strings always return true.
*/
static boolean isSameServer(String server1, String server2)
{
server1 = (server1 != null) ? server1.toLowerCase(Locale.ENGLISH) : "";
server2 = (server2 != null) ? server2.toLowerCase(Locale.ENGLISH) : "";
if (server1.isEmpty() || server2.isEmpty()) {
return true;
} else {
return (server1.startsWith(server2) || server2.startsWith(server1));
}
}
private Updater()
{
try {
prefs = Preferences.userNodeForPackage(getClass());
} catch (SecurityException se) {
prefs = null;
se.printStackTrace();
}
loadUpdateSettings();
}
/** Provides access to the server list. */
public List<String> getServerList()
{
return serverList;
}
/**
* Adds a new update server link to the server list. Optionally checks online if the link points
* to a valid update.xml. Does nothing if the server URL already exists.
* @param link The update server URL.
* @param validate Only checks link format if {@code false}. Additionally checks if
* link points to a valid update.xml if {@code true}.
* @throws IOException
* @throws MalformedURLException
*/
public void addServer(String link, boolean validate) throws MalformedURLException, IOException
{
if (link != null && !link.isEmpty() && serverList.size() < getMaxServerCount()) {
if (Utils.isUrlValid(link)) {
boolean isValid = (validate == false);
if (!isValid) {
// check availability of update.xml
isValid = (getValidatedUpdateUrl(link) != null);
}
if (isValid) {
// checking if server is already in list
for (Iterator<String> iter = serverList.iterator(); iter.hasNext();) {
if (isSameServer(link, iter.next())) {
// consider both links as equal
return;
}
}
// adding server link
serverList.add(link);
}
}
}
}
/** Returns whether to look for stable releases only. */
public boolean isStableOnly()
{
return stableOnly;
}
/** Returns whether to consider only stable releases when checking for updates. */
public void setStableOnly(boolean set)
{
stableOnly = set;
}
/** Returns whether to automatically check for updates. */
public boolean isAutoUpdateCheckEnabled()
{
return autoCheckEnabled;
}
/** Updates whether to automatically check for updates. */
public void setAutoUpdateCheckEnabled(boolean set)
{
autoCheckEnabled = set;
}
/** Returns the current check interval value (as specified by the UPDATE_INTERVAL_xxx constants). */
public int getAutoUpdateCheckInterval()
{
return autoCheckInterval;
}
/** Updates the check interval value (as specified by the UPDATE_INTERVAL_xxx constants). */
public void setAutoUpdateCheckInterval(int value)
{
if (value < 0) value = 0;
if (value > UPDATE_INTERVAL_PER_MONTH) value = UPDATE_INTERVAL_PER_MONTH;
autoCheckInterval = value;
}
/** Returns the last update check date. */
public Calendar getAutoUpdateCheckDate()
{
return autoCheckDate;
}
/** Updates the last update check date. Specifying {@code null} will add the current date. */
public void setAutoUpdateCheckDate(Calendar cal)
{
if (cal != null) {
autoCheckDate = cal;
} else {
autoCheckDate = Calendar.getInstance();
}
}
/** Returns true if the last auto update check is older than the currently defined update interval. */
public boolean hasAutoUpdateCheckDateExpired()
{
return hasAutoUpdateCheckDateExpired(getAutoUpdateCheckInterval());
}
/** Returns true if the last auto update check is older than specified by the UPDATE_INTERVAL_xxx constant. */
public boolean hasAutoUpdateCheckDateExpired(int value)
{
switch (value) {
case UPDATE_INTERVAL_SESSION:
{
return true;
}
case UPDATE_INTERVAL_DAILY:
{
Calendar cal = Calendar.getInstance();
cal.add(Calendar.DAY_OF_MONTH, -1);
return (getAutoUpdateCheckDate().compareTo(cal) < 0);
}
case UPDATE_INTERVAL_PER_WEEK:
{
Calendar cal = Calendar.getInstance();
cal.add(Calendar.WEEK_OF_MONTH, -1);
return (getAutoUpdateCheckDate().compareTo(cal) < 0);
}
case UPDATE_INTERVAL_PER_MONTH:
{
Calendar cal = Calendar.getInstance();
cal.add(Calendar.MONDAY, -1);
return (getAutoUpdateCheckDate().compareTo(cal) < 0);
}
}
return false;
}
/** Returns whether to use a proxy for accessing remote servers. */
public boolean isProxyEnabled()
{
return proxyEnabled;
}
public void setProxyEnabled(boolean set)
{
proxyEnabled = set;
}
/**
* Returns the current Proxy settings if available and enabled.
* More specifically, calls {@link #getProxy(boolean)} with force = {@code false}.
*/
public Proxy getProxy()
{
return getProxy(false);
}
/**
* Returns the current Proxy settings if available and enabled.
* @param force Force to return proxy information even if it has been disabled.
* @return A proxy object or {@code null} depending on availability.
*/
public Proxy getProxy(boolean force)
{
if (proxyEnabled || force) {
return proxy;
} else {
return null;
}
}
/**
* Sets up a new HTTP proxy. Specifying {@code null} or 0 for one or both parameters will
* remove the current proxy settings.
* @param hostName The host name of the proxy address.
* @param port The port of the proxy address.
*/
public void setProxy(String hostName, int port)
{
if (hostName != null && !hostName.isEmpty() && port >= 0 && port < 65536) {
proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(hostName, port));
} else {
proxy = null;
}
}
/** Updates hash and timestamp from given Release info object. */
public void updateReleaseInfo(UpdateInfo.Release release)
{
if (release != null && release.isValid()) {
setCurrentHash(release.getHash());
setCurrentTimeStamp(release.getTimeStampString());
}
}
/** Returns the cached hash string from the latest update check. */
public String getCurrentHash()
{
return hash;
}
/** Updates the cached hash string. */
public void setCurrentHash(String hash)
{
if (hash != null) {
this.hash = hash;
} else {
this.hash = "";
}
}
/** Returns the cached NI version. */
public String getCurrentVersion()
{
return version;
}
/** Updtes the cached NI version. */
public void setCurrentVersion(String version)
{
if (version != null) {
this.version = version;
} else {
this.version = "";
}
}
/** Returns the cached timestamp value from the latest update check. */
public String getCurrentTimeStamp()
{
return timestamp;
}
/** Updates the cached timestamp string. */
public void setCurrentTimeStamp(String timestamp)
{
if (timestamp != null) {
this.timestamp = timestamp;
} else {
this.timestamp = "";
}
}
/** Loads server and update settings from stored preferences. */
public void loadUpdateSettings()
{
// resetting values
serverList.clear();
stableOnly = false;
autoCheckEnabled = false;
autoCheckInterval = UPDATE_INTERVAL_PER_WEEK;
autoCheckDate = Calendar.getInstance();
proxyEnabled = false;
proxy = null;
hash = "";
version = "";
timestamp = "";
if (prefs != null) {
// loading server list (skipping identical server entries)
stableOnly = prefs.getBoolean(PREFS_STABLEONLY, false);
for (int i = 0; i < getMaxServerCount(); i++) {
String server = prefs.get(String.format(PREFS_SERVER_FMT, i), "");
if (!server.isEmpty()) {
// skip duplicate server URLs
boolean isSame = false;
for (Iterator<String> iter = serverList.iterator(); iter.hasNext();) {
if (isSameServer(server, iter.next())) {
isSame = true;
break;
}
}
if (!isSame) {
serverList.add(server);
}
}
}
// loading auto update settings
autoCheckEnabled = prefs.getBoolean(PREFS_AUTOCHECK_UPDATES, false);
autoCheckInterval = prefs.getInt(PREFS_AUTOCHECK_INTERAVAL, UPDATE_INTERVAL_PER_WEEK);
Calendar cal = Utils.toCalendar(prefs.get(PREFS_AUTOCHECK_TIMESTAMP, null));
if (cal != null) {
autoCheckDate = cal;
}
// loading proxy settings
proxyEnabled = prefs.getBoolean(PREFS_PROXYENABLED, false);
String host = prefs.get(PREFS_PROXYHOST, "");
int port = prefs.getInt(PREFS_PROXYPORT, -1);
if (port >= 0) {
try {
if (host.isEmpty()) {
proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(port));
} else {
proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(host, port));
}
} catch (Exception e) {
e.printStackTrace();
}
}
// loading autocheck-related information
hash = prefs.get(PREFS_UPDATE_HASH, "");
version = prefs.get(PREFS_UPDATE_VERSION, "");
timestamp = prefs.get(PREFS_UPDATE_TIMESTAMP, "");
}
// Fallback: add static servers to list
if (serverList.isEmpty()) {
for (int i = 0; i < DEFAULT_SERVERS.length; i++) {
serverList.add(DEFAULT_SERVERS[i]);
}
}
}
/** Saves server and update settings to disk. */
public boolean saveUpdateSettings()
{
if (prefs != null) {
// saving server list
prefs.putBoolean(PREFS_STABLEONLY, stableOnly);
for (int i = 0, size = getMaxServerCount(); i < size; i++) {
String server = null;
if (i < serverList.size()) {
server = serverList.get(i).trim();
}
if (server != null && !server.isEmpty()) {
prefs.put(String.format(PREFS_SERVER_FMT, i), server);
} else {
prefs.remove(String.format(PREFS_SERVER_FMT, i));
}
}
// saving auto update settings
prefs.putBoolean(PREFS_AUTOCHECK_UPDATES, autoCheckEnabled);
prefs.putInt(PREFS_AUTOCHECK_INTERAVAL, autoCheckInterval);
prefs.put(PREFS_AUTOCHECK_TIMESTAMP, Utils.toTimeStamp(autoCheckDate));
// saving proxy settings
if (proxy != null && proxy.type() == Proxy.Type.HTTP &&
proxy.address() instanceof InetSocketAddress) {
InetSocketAddress addr = (InetSocketAddress)proxy.address();
prefs.putBoolean(PREFS_PROXYENABLED, proxyEnabled);
prefs.put(PREFS_PROXYHOST, addr.getHostName());
prefs.putInt(PREFS_PROXYPORT, addr.getPort());
} else {
prefs.putBoolean(PREFS_PROXYENABLED, false);
prefs.remove(PREFS_PROXYHOST);
prefs.remove(PREFS_PROXYPORT);
}
// saving autocheck-related information
prefs.put(PREFS_UPDATE_HASH, hash);
prefs.put(PREFS_UPDATE_VERSION, version);
prefs.put(PREFS_UPDATE_TIMESTAMP, timestamp);
return true;
}
return false;
}
/**
* Checks the specified URL if it points to a valid update.xml and returns the
* (possibly modified) URL on success or {@code null} on error.
* @param link A URL pointing to the update.xml.
* @return A URL that is guaranteed to point to a valid update.xml or {@code null} on error.
* @throws IOException
* @throws MalformedURLException
*/
public String getValidatedUpdateUrl(String link) throws MalformedURLException, IOException
{
if (Utils.isUrlValid(link)) {
try {
// try the specified link first
URL url = new URL(link);
String xml = null;
try {
xml = Utils.downloadText(url, getProxy(), "utf-8");
} catch (IOException e) {
}
if (xml != null && UpdateInfo.isValidXml(xml, url.toExternalForm())) {
return url.toExternalForm();
}
// try the specified link appended by "update.xml" second
url = Utils.getUrl(url, UPDATE_FILENAME);
xml = Utils.downloadText(url, getProxy(), "utf-8");
if (xml != null && UpdateInfo.isValidXml(xml, url.toExternalForm())) {
return url.toExternalForm();
}
} catch (MalformedURLException e) {
}
}
return null;
}
/**
* Attempts to download update information and return them as UpdateInfo object.
* @return The UpdateInfo object containing update information, or {@code null} if not available.
*/
public UpdateInfo loadUpdateInfo()
{
for (Iterator<String> iter = getServerList().iterator(); iter.hasNext();) {
try {
URL url = new URL(iter.next());
String xml = Utils.downloadText(url, getProxy(), "UTF-8");
UpdateInfo info = new UpdateInfo(xml, url.toExternalForm());
if (info.isValid()) {
// adding alternate servers to list (if available)
for (int i = 0, count = info.getGeneral().getServerCount(); i < count; i++) {
try {
addServer(info.getGeneral().getServer(i), true);
} catch (Exception e) {
// skip adding server on error
}
}
return info;
}
} catch (Exception e) {
// skip update server on error and try next
}
}
return null;
}
}