// Copyright 2015 The Project Buendia Authors
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may not
// use this file except in compliance with the License. You may obtain a copy
// of the License at: http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software distrib-
// uted under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES
// OR CONDITIONS OF ANY KIND, either express or implied. See the License for
// specific language governing permissions and limitations under the License.
package org.projectbuendia.client.updater;
import android.app.Application;
import android.app.DownloadManager;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.database.Cursor;
import android.net.Uri;
import android.os.Environment;
import com.android.volley.NoConnectionError;
import com.android.volley.Response;
import com.android.volley.VolleyError;
import org.joda.time.DateTime;
import org.projectbuendia.client.AppSettings;
import org.projectbuendia.client.events.UpdateAvailableEvent;
import org.projectbuendia.client.events.UpdateNotAvailableEvent;
import org.projectbuendia.client.events.UpdateReadyToInstallEvent;
import org.projectbuendia.client.json.JsonUpdateInfo;
import org.projectbuendia.client.utils.LexicographicVersion;
import org.projectbuendia.client.utils.Logger;
import java.io.File;
import java.util.List;
import de.greenrobot.event.EventBus;
/**
* An object that manages auto-updating of the application from a configurable package server.
* <p/>
* <p>This class requires that all methods be called from the main thread.
*/
public class UpdateManager {
/**
* The minimal version number.
* <p/>
* <p>This value is smaller than any other version. If the current application has this version,
* any non-minimal update will be installed over it. If an update has this version, it will
* never be installed over any the current application.
*/
public static final LexicographicVersion MINIMAL_VERSION = LexicographicVersion.parse("0");
private static final Logger LOG = Logger.create();
/**
* The update manager's module name for updates to this app. A name of "foo"
* means the updates are saved as "foo-1.2.apk", "foo-1.3.apk" on disk.
*/
private static final String MODULE_NAME = "buendia-client";
private static final IntentFilter sDownloadCompleteIntentFilter =
new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE);
private final Object mLock = new Object();
private final Application mApplication;
private final PackageServer mServer;
private final PackageManager mPackageManager;
private final LexicographicVersion mCurrentVersion;
private final DownloadManager mDownloadManager;
private final AppSettings mSettings;
private DateTime mLastCheckForUpdateTime = new DateTime(0 /*instant*/);
private AvailableUpdateInfo mLastAvailableUpdateInfo = null;
// TODO: Consider caching this in SharedPreferences OR standardizing the location of it
// so that we can check for it on application launch.
private DownloadedUpdateInfo mLastDownloadedUpdateInfo = null;
private final Object mDownloadLock = new Object();
// ID of the currently running download, or -1 if no download is underway.
private long mDownloadId = -1;
/**
* Ensures that a check for available updates has been initiated within the
* last update interval period {@see AppSettings#getApkUpdateInterval()},
* or initiates one. May post events that update the UI even if no new
* server check is initiated. The check proceeds asynchronously in the
* background and eventually posts the relevant events {@see postEvent()}.
* Clients should call this method and then check for two sticky events:
* UpdateAvailableEvent and UpdateReadyToInstallEvent.
*/
public void checkForUpdate() {
DateTime now = DateTime.now();
if (now.isBefore(mLastCheckForUpdateTime.plusSeconds(mSettings.getApkUpdateInterval()))) {
if (!isDownloadInProgress()) {
// This immediate check just updates the event state to match any current
// knowledge of an available or downloaded update. The more interesting
// calls to postEvents occur below in PackageIndexReceivedListener and
// DownloadReceiver.
postEvents();
}
return;
}
PackageIndexReceivedListener listener = new PackageIndexReceivedListener();
mServer.getPackageIndex(listener, listener);
mLastCheckForUpdateTime = now;
}
/** Returns true if a download is in progress. */
public boolean isDownloadInProgress() {
return mDownloadId >= 0;
}
/**
* Posts events notifying of whether a file is available to be downloaded, or a
* file is downloaded and ready to install. See {@link UpdateReadyToInstallEvent},
* {@link UpdateAvailableEvent}, and {@link UpdateNotAvailableEvent} for details.
*/
protected void postEvents() {
EventBus bus = EventBus.getDefault();
if (mLastDownloadedUpdateInfo.shouldInstall()
&& mLastDownloadedUpdateInfo.downloadedVersion.greaterThanOrEqualTo(
mLastAvailableUpdateInfo.availableVersion)) {
bus.postSticky(new UpdateReadyToInstallEvent(mLastDownloadedUpdateInfo));
} else if (mLastAvailableUpdateInfo.shouldUpdate()) {
bus.removeStickyEvent(UpdateReadyToInstallEvent.class);
bus.postSticky(new UpdateAvailableEvent(mLastAvailableUpdateInfo));
} else {
bus.removeStickyEvent(UpdateReadyToInstallEvent.class);
bus.removeStickyEvent(UpdateAvailableEvent.class);
bus.post(new UpdateNotAvailableEvent());
}
}
/**
* Starts downloading an available update in the background, registering a
* DownloadUpdateReceiver to be invoked when the download is complete.
* @return whether a new download was started; {@code false} if the download failed to start.
*/
public boolean startDownload(AvailableUpdateInfo availableUpdateInfo) {
synchronized (mDownloadLock) {
cancelDownload();
mApplication.registerReceiver(
new DownloadUpdateReceiver(), sDownloadCompleteIntentFilter);
try {
String dir = getDownloadDirectory();
if (dir == null) {
LOG.e("no external storage is available, can't start download");
return false;
}
String filename = MODULE_NAME + "-"
+ availableUpdateInfo.availableVersion + ".apk";
DownloadManager.Request request =
new DownloadManager.Request(availableUpdateInfo.updateUri)
.setDestinationInExternalPublicDir(dir, filename)
.setNotificationVisibility(
DownloadManager.Request.VISIBILITY_VISIBLE);
mDownloadId = mDownloadManager.enqueue(request);
LOG.i("Starting download: " + availableUpdateInfo.updateUri
+ " -> " + filename + " in " + dir);
return true;
} catch (Exception e) {
LOG.e(e, "Failed to download application update from "
+ availableUpdateInfo.updateUri);
return false;
}
}
}
/** Stops any currently running download. */
public boolean cancelDownload() {
if (isDownloadInProgress()) {
mDownloadManager.remove(mDownloadId);
mDownloadId = -1;
return true;
}
return false;
}
/**
* Returns the relative path to the directory in which updates will be downloaded,
* or null if storage is unavailable.
*/
private String getDownloadDirectory() {
String externalStorageDirectory =
Environment.getExternalStorageDirectory().getAbsolutePath();
File externalFilesDir = mApplication.getExternalFilesDir(null);
if (externalFilesDir == null) {
return null;
}
String downloadDirectory = externalFilesDir.getAbsolutePath();
if (downloadDirectory.startsWith(externalStorageDirectory)) {
downloadDirectory = downloadDirectory.substring(externalStorageDirectory.length());
}
return downloadDirectory;
}
/** Installs the last downloaded update. */
public void installUpdate(DownloadedUpdateInfo updateInfo) {
Uri apkUri = Uri.parse(updateInfo.path);
Intent installIntent = new Intent(Intent.ACTION_VIEW)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.setDataAndType(apkUri, "application/vnd.android.package-archive");
mApplication.startActivity(installIntent);
}
UpdateManager(Application application, PackageServer packageServer, AppSettings settings) {
mApplication = application;
mServer = packageServer;
mSettings = settings;
mPackageManager = application.getPackageManager();
mDownloadManager =
(DownloadManager) application.getSystemService(Context.DOWNLOAD_SERVICE);
mCurrentVersion = getCurrentVersion();
mLastAvailableUpdateInfo = AvailableUpdateInfo.getInvalid(mCurrentVersion);
mLastDownloadedUpdateInfo = DownloadedUpdateInfo.getInvalid(mCurrentVersion);
}
/** Returns the version of the application. */
private LexicographicVersion getCurrentVersion() {
PackageInfo packageInfo;
try {
packageInfo =
mPackageManager.getPackageInfo(mApplication.getPackageName(), 0 /*flags*/);
} catch (PackageManager.NameNotFoundException e) {
LOG.e(
e,
"No package found with the name " + mApplication.getPackageName() + ". "
+ "This should never happen.");
return MINIMAL_VERSION;
}
try {
return LexicographicVersion.parse(packageInfo.versionName);
} catch (IllegalArgumentException e) {
LOG.w("App has an invalid version (or is a dev build): " + packageInfo.versionName);
return MINIMAL_VERSION;
}
}
private DownloadedUpdateInfo getLastDownloadedUpdateInfo() {
String dir = getDownloadDirectory();
if (dir == null) {
LOG.e("no external storage is available, no download directory for updates");
return DownloadedUpdateInfo.getInvalid(mCurrentVersion);
}
File downloadDirectoryFile =
new File(Environment.getExternalStorageDirectory(), dir);
if (!downloadDirectoryFile.exists()) {
return DownloadedUpdateInfo.getInvalid(mCurrentVersion);
}
if (!downloadDirectoryFile.isDirectory()) {
LOG.e(
"The path in which updates are downloaded is not a directory: '%1$s'",
downloadDirectoryFile.toString());
return DownloadedUpdateInfo.getInvalid(mCurrentVersion);
}
File[] files = downloadDirectoryFile.listFiles();
File latestApk = null;
for (File file : files) {
if (file.isFile()
&& file.getName().endsWith(".apk")
&& (latestApk == null || file.lastModified() > latestApk.lastModified())) {
latestApk = file;
}
}
if (latestApk == null) {
return DownloadedUpdateInfo.getInvalid(mCurrentVersion);
} else {
return DownloadedUpdateInfo
.fromUri(mCurrentVersion, "file://" + latestApk.getAbsolutePath());
}
}
/** A listener that receives the index of available .apk files from the package server. */
private class PackageIndexReceivedListener
implements Response.Listener<List<JsonUpdateInfo>>, Response.ErrorListener {
@Override public void onResponse(List<JsonUpdateInfo> response) {
synchronized (mLock) {
mLastAvailableUpdateInfo =
AvailableUpdateInfo.fromResponse(mCurrentVersion, response);
mLastDownloadedUpdateInfo = getLastDownloadedUpdateInfo();
LOG.i("received package index; lastAvailableUpdate: " + mLastAvailableUpdateInfo);
postEvents();
}
}
@Override public void onErrorResponse(VolleyError error) {
String message = "Server failed; will retry shortly";
if (error != null && error.networkResponse != null) {
message = "Server failed (" + error.networkResponse.statusCode + "); will retry shortly";
}
if (error instanceof NoConnectionError) {
LOG.w(message + " - " + error);
} else {
LOG.w(error, message);
}
// assume no update is available
EventBus.getDefault().post(new UpdateNotAvailableEvent());
}
}
/**
* A {@link BroadcastReceiver} that listens for
* {@code DownloadManager.ACTION_DOWNLOAD_COMPLETED} intents.
*/
private class DownloadUpdateReceiver extends BroadcastReceiver {
@Override public void onReceive(Context context, Intent intent) {
synchronized (mDownloadLock) {
if (!isDownloadInProgress()) {
LOG.e(
"Received an ACTION_DOWNLOAD_COMPLETED intent when no download was in "
+ "progress. This indicates that this receiver was registered "
+ "incorrectly. Unregistering receiver.");
mApplication.unregisterReceiver(this);
return;
}
long receivedDownloadId =
intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1);
if (mDownloadId != receivedDownloadId) {
LOG.d(
"Received an ACTION_DOWNLOAD_COMPLETED intent with download ID "
+ receivedDownloadId + " when the expected download ID is "
+ mDownloadId + ". Download was probably initiated by another "
+ " application.");
return;
}
// We have received the intent for our download, so we'll call the download finished
// and unregister the receiver.
mDownloadId = -1;
mApplication.unregisterReceiver(this);
Cursor cursor = null;
final String uriString;
try {
cursor = mDownloadManager.query(
new DownloadManager.Query().setFilterById(receivedDownloadId));
if (!cursor.moveToFirst()) {
LOG.w(
"Received download ID " + receivedDownloadId + " does not exist.");
// TODO: Consider firing an event.
return;
}
int status = cursor.getInt(
cursor.getColumnIndex(DownloadManager.COLUMN_STATUS));
if (status != DownloadManager.STATUS_SUCCESSFUL) {
LOG.w("Update download failed with status " + status + ".");
// TODO: Consider firing an event.
return;
}
uriString = cursor.getString(
cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI));
if (uriString == null) {
LOG.w("No path for a downloaded file exists.");
// TODO: Consider firing an event.
return;
}
} finally {
if (cursor != null) {
cursor.close();
}
}
try {
Uri.parse(uriString);
} catch (IllegalArgumentException e) {
LOG.w(e, "Path for downloaded file is invalid: %1$s.", uriString);
// TODO: Consider firing an event.
return;
}
mLastDownloadedUpdateInfo =
DownloadedUpdateInfo.fromUri(mCurrentVersion, uriString);
LOG.i("downloaded update: " + mLastDownloadedUpdateInfo);
if (!mLastDownloadedUpdateInfo.isValid) {
LOG.w(
"The last update downloaded from the server is invalid. Update checks "
+ "will not occur for the next %1$d seconds.",
mSettings.getApkUpdateInterval());
// Set the last available update info to an invalid value so as to prevent
// further download attempts.
mLastAvailableUpdateInfo = AvailableUpdateInfo.getInvalid(mCurrentVersion);
return;
}
if (!mLastAvailableUpdateInfo.availableVersion
.greaterThanOrEqualTo(mLastDownloadedUpdateInfo.downloadedVersion)) {
LOG.w(
"The last update downloaded from the server was reported to have "
+ "version '%1$s' but actually has version '%2$s'. This "
+ "indicates a server configuration problem. Update checks "
+ "will not occur for the next %3$d seconds.",
mLastAvailableUpdateInfo.availableVersion.toString(),
mLastDownloadedUpdateInfo.downloadedVersion.toString(),
mSettings.getApkUpdateInterval());
// Set the last available update info to an invalid value so as to prevent
// further download attempts.
mLastAvailableUpdateInfo = AvailableUpdateInfo.getInvalid(mCurrentVersion);
return;
}
postEvents();
}
}
}
}