package org.fdroid.fdroid.installer;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.BroadcastReceiver;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.IBinder;
import android.support.v4.app.NotificationCompat;
import android.support.v4.app.TaskStackBuilder;
import android.support.v4.content.IntentCompat;
import android.support.v4.content.LocalBroadcastManager;
import android.text.TextUtils;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.filefilter.WildcardFileFilter;
import org.fdroid.fdroid.AppDetails;
import org.fdroid.fdroid.Hasher;
import org.fdroid.fdroid.R;
import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.compat.PackageManagerCompat;
import org.fdroid.fdroid.data.Apk;
import org.fdroid.fdroid.data.App;
import org.fdroid.fdroid.data.AppProvider;
import org.fdroid.fdroid.data.Schema;
import org.fdroid.fdroid.net.Downloader;
import org.fdroid.fdroid.net.DownloaderService;
import java.io.File;
import java.io.FileFilter;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
/**
* Manages the whole process when a background update triggers an install or the user
* requests an APK to be installed. It handles checking whether the APK is cached,
* downloading it, putting up and maintaining a {@link Notification}, and more.
* <p>
* The {@link App} and {@link Apk} instances are sent via
* {@link Intent#putExtra(String, android.os.Bundle)}
* so that Android handles the message queuing and {@link Service} lifecycle for us.
* For example, if this {@code InstallManagerService} gets killed, Android will cache
* and then redeliver the {@link Intent} for us, which includes all of the data needed
* for {@code InstallManagerService} to do its job for the whole lifecycle of an install.
* <p>
* The full URL for the APK file to download is also used as the unique ID to
* represent the download itself throughout F-Droid. This follows the model
* of {@link Intent#setData(Uri)}, where the core data of an {@code Intent} is
* a {@code Uri}. The full download URL is guaranteed to be unique since it
* points to files on a filesystem, where there cannot be multiple files with
* the same name. This provides a unique ID beyond just {@code packageName}
* and {@code versionCode} since there could be different copies of the same
* APK on different servers, signed by different keys, or even different builds.
* <p><ul>
* <li>for a {@link Uri} ID, use {@code Uri}, {@link Intent#getData()}
* <li>for a {@code String} ID, use {@code urlString}, {@link Uri#toString()}, or
* {@link Intent#getDataString()}
* <li>for an {@code int} ID, use {@link String#hashCode()} or {@link Uri#hashCode()}
* </ul></p>
* The implementations of {@link Uri#toString()} and {@link Intent#getDataString()} both
* include caching of the generated {@code String}, so it should be plenty fast.
* <p>
* This also handles downloading OBB "APK Extension" files for any APK that has one
* assigned to it. OBB files are queued up for download before the APK so that they
* are hopefully in place before the APK starts. That is not guaranteed though.
*
* @see <a href="https://developer.android.com/google/play/expansion-files.html">APK Expansion Files</a>
*/
public class InstallManagerService extends Service {
private static final String TAG = "InstallManagerService";
private static final String ACTION_INSTALL = "org.fdroid.fdroid.installer.action.INSTALL";
private static final String ACTION_CANCEL = "org.fdroid.fdroid.installer.action.CANCEL";
private static final String EXTRA_APP = "org.fdroid.fdroid.installer.extra.APP";
private static final String EXTRA_APK = "org.fdroid.fdroid.installer.extra.APK";
/**
* The collection of {@link Apk}s that are actively going through this whole process,
* matching the {@link App}s in {@code ACTIVE_APPS}. The key is the download URL, as
* in {@link Apk#getUrl()} or {@code urlString}.
*/
private static final HashMap<String, Apk> ACTIVE_APKS = new HashMap<>(3);
/**
* The collection of {@link App}s that are actively going through this whole process,
* matching the {@link Apk}s in {@code ACTIVE_APKS}. The key is the
* {@code packageName} of the app.
*/
private static final HashMap<String, App> ACTIVE_APPS = new HashMap<>(3);
private LocalBroadcastManager localBroadcastManager;
private NotificationManager notificationManager;
/**
* This service does not use binding, so no need to implement this method
*/
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public void onCreate() {
super.onCreate();
Utils.debugLog(TAG, "creating Service");
localBroadcastManager = LocalBroadcastManager.getInstance(this);
notificationManager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
BroadcastReceiver br = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
String packageName = intent.getData().getSchemeSpecificPart();
for (Map.Entry<String, Apk> entry : ACTIVE_APKS.entrySet()) {
if (TextUtils.equals(packageName, entry.getValue().packageName)) {
String urlString = entry.getKey();
cancelNotification(urlString);
break;
}
}
}
};
IntentFilter intentFilter = new IntentFilter();
intentFilter.addAction(Intent.ACTION_PACKAGE_ADDED);
intentFilter.addDataScheme("package");
registerReceiver(br, intentFilter);
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
Utils.debugLog(TAG, "onStartCommand " + intent);
String urlString = intent.getDataString();
if (TextUtils.isEmpty(urlString)) {
Utils.debugLog(TAG, "empty urlString, nothing to do");
return START_NOT_STICKY;
}
String action = intent.getAction();
if (ACTION_CANCEL.equals(action)) {
DownloaderService.cancel(this, urlString);
Apk apk = getApkFromActive(urlString);
DownloaderService.cancel(this, apk.getPatchObbUrl());
DownloaderService.cancel(this, apk.getMainObbUrl());
cancelNotification(urlString);
return START_NOT_STICKY;
} else if (!ACTION_INSTALL.equals(action)) {
Utils.debugLog(TAG, "Ignoring " + intent + " as it is not an " + ACTION_INSTALL + " intent");
return START_NOT_STICKY;
}
if (!intent.hasExtra(EXTRA_APP) || !intent.hasExtra(EXTRA_APK)) {
Utils.debugLog(TAG, urlString + " did not include both an App and Apk instance, ignoring");
return START_NOT_STICKY;
}
if ((flags & START_FLAG_REDELIVERY) == START_FLAG_REDELIVERY
&& !DownloaderService.isQueuedOrActive(urlString)) {
// TODO is there a case where we should allow an active urlString to pass through?
Utils.debugLog(TAG, urlString + " finished downloading while InstallManagerService was killed.");
cancelNotification(urlString);
return START_NOT_STICKY;
}
App app = intent.getParcelableExtra(EXTRA_APP);
Apk apk = intent.getParcelableExtra(EXTRA_APK);
if (app == null || apk == null) {
Utils.debugLog(TAG, "Intent had null EXTRA_APP and/or EXTRA_APK: " + intent);
return START_NOT_STICKY;
}
addToActive(urlString, app, apk);
NotificationCompat.Builder builder = createNotificationBuilder(urlString, apk);
notificationManager.notify(urlString.hashCode(), builder.build());
registerApkDownloaderReceivers(urlString, builder);
getObb(urlString, apk.getMainObbUrl(), apk.getMainObbFile(), apk.obbMainFileSha256, builder);
getObb(urlString, apk.getPatchObbUrl(), apk.getPatchObbFile(), apk.obbPatchFileSha256, builder);
File apkFilePath = ApkCache.getApkDownloadPath(this, intent.getData());
long apkFileSize = apkFilePath.length();
if (!apkFilePath.exists() || apkFileSize < apk.size) {
Utils.debugLog(TAG, "download " + urlString + " " + apkFilePath);
DownloaderService.queue(this, urlString);
} else if (ApkCache.apkIsCached(apkFilePath, apk)) {
Utils.debugLog(TAG, "skip download, we have it, straight to install " + urlString + " " + apkFilePath);
sendBroadcast(intent.getData(), Downloader.ACTION_STARTED, apkFilePath);
sendBroadcast(intent.getData(), Downloader.ACTION_COMPLETE, apkFilePath);
} else {
Utils.debugLog(TAG, "delete and download again " + urlString + " " + apkFilePath);
apkFilePath.delete();
DownloaderService.queue(this, urlString);
}
return START_REDELIVER_INTENT; // if killed before completion, retry Intent
}
private void sendBroadcast(Uri uri, String action, File file) {
Intent intent = new Intent(action);
intent.setData(uri);
intent.putExtra(Downloader.EXTRA_DOWNLOAD_PATH, file.getAbsolutePath());
localBroadcastManager.sendBroadcast(intent);
}
/**
* Check if any OBB files are available, and if so, download and install them. This
* also deletes any obsolete OBB files, per the spec, since there can be only one
* "main" and one "patch" OBB installed at a time.
*
* @see <a href="https://developer.android.com/google/play/expansion-files.html">APK Expansion Files</a>
*/
private void getObb(final String urlString, String obbUrlString,
final File obbDestFile, final String sha256,
final NotificationCompat.Builder builder) {
if (obbDestFile == null || obbDestFile.exists() || TextUtils.isEmpty(obbUrlString)) {
return;
}
final BroadcastReceiver downloadReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
if (Downloader.ACTION_STARTED.equals(action)) {
Utils.debugLog(TAG, action + " " + intent);
} else if (Downloader.ACTION_PROGRESS.equals(action)) {
int bytesRead = intent.getIntExtra(Downloader.EXTRA_BYTES_READ, 0);
int totalBytes = intent.getIntExtra(Downloader.EXTRA_TOTAL_BYTES, 0);
builder.setProgress(totalBytes, bytesRead, false);
notificationManager.notify(urlString.hashCode(), builder.build());
} else if (Downloader.ACTION_COMPLETE.equals(action)) {
localBroadcastManager.unregisterReceiver(this);
File localFile = new File(intent.getStringExtra(Downloader.EXTRA_DOWNLOAD_PATH));
Uri localApkUri = Uri.fromFile(localFile);
Utils.debugLog(TAG, "OBB download completed " + intent.getDataString()
+ " to " + localApkUri);
try {
if (Hasher.isFileMatchingHash(localFile, sha256, "SHA-256")) {
Utils.debugLog(TAG, "Installing OBB " + localFile + " to " + obbDestFile);
FileUtils.forceMkdirParent(obbDestFile);
FileUtils.copyFile(localFile, obbDestFile);
FileFilter filter = new WildcardFileFilter(
obbDestFile.getName().substring(0, 4) + "*.obb");
for (File f : obbDestFile.getParentFile().listFiles(filter)) {
if (!f.equals(obbDestFile)) {
Utils.debugLog(TAG, "Deleting obsolete OBB " + f);
FileUtils.deleteQuietly(f);
}
}
} else {
Utils.debugLog(TAG, localFile + " deleted, did not match hash: " + sha256);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
FileUtils.deleteQuietly(localFile);
}
} else if (Downloader.ACTION_INTERRUPTED.equals(action)) {
localBroadcastManager.unregisterReceiver(this);
} else {
throw new RuntimeException("intent action not handled!");
}
}
};
DownloaderService.queue(this, obbUrlString);
localBroadcastManager.registerReceiver(downloadReceiver,
DownloaderService.getIntentFilter(obbUrlString));
}
private void registerApkDownloaderReceivers(String urlString, final NotificationCompat.Builder builder) {
BroadcastReceiver downloadReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
Uri downloadUri = intent.getData();
String urlString = downloadUri.toString();
switch (intent.getAction()) {
case Downloader.ACTION_STARTED:
// nothing to do
break;
case Downloader.ACTION_PROGRESS:
int bytesRead = intent.getIntExtra(Downloader.EXTRA_BYTES_READ, 0);
int totalBytes = intent.getIntExtra(Downloader.EXTRA_TOTAL_BYTES, 0);
builder.setProgress(totalBytes, bytesRead, false);
notificationManager.notify(urlString.hashCode(), builder.build());
break;
case Downloader.ACTION_COMPLETE:
File localFile = new File(intent.getStringExtra(Downloader.EXTRA_DOWNLOAD_PATH));
Uri localApkUri = Uri.fromFile(localFile);
Utils.debugLog(TAG, "download completed of " + urlString + " to " + localApkUri);
localBroadcastManager.unregisterReceiver(this);
registerInstallerReceivers(downloadUri);
Apk apk = ACTIVE_APKS.get(urlString);
InstallerService.install(context, localApkUri, downloadUri, apk);
break;
case Downloader.ACTION_INTERRUPTED:
removeFromActive(urlString);
localBroadcastManager.unregisterReceiver(this);
cancelNotification(urlString);
break;
default:
throw new RuntimeException("intent action not handled!");
}
}
};
localBroadcastManager.registerReceiver(downloadReceiver,
DownloaderService.getIntentFilter(urlString));
}
private void registerInstallerReceivers(Uri downloadUri) {
BroadcastReceiver installReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
String downloadUrl = intent.getDataString();
Apk apk;
switch (intent.getAction()) {
case Installer.ACTION_INSTALL_STARTED:
// nothing to do
break;
case Installer.ACTION_INSTALL_COMPLETE:
Apk apkComplete = removeFromActive(downloadUrl);
PackageManagerCompat.setInstaller(getPackageManager(), apkComplete.packageName);
localBroadcastManager.unregisterReceiver(this);
break;
case Installer.ACTION_INSTALL_INTERRUPTED:
apk = intent.getParcelableExtra(Installer.EXTRA_APK);
String errorMessage =
intent.getStringExtra(Installer.EXTRA_ERROR_MESSAGE);
// show notification if app details is not visible
if (!TextUtils.isEmpty(errorMessage)) {
App app = getAppFromActive(downloadUrl);
if (app == null) {
ContentResolver resolver = context.getContentResolver();
app = AppProvider.Helper.findSpecificApp(resolver, apk.packageName, apk.repo);
}
// show notification if app details is not visible
if (app != null && AppDetails.isAppVisible(app.packageName)) {
cancelNotification(downloadUrl);
} else {
notifyError(downloadUrl, app, errorMessage);
}
}
removeFromActive(downloadUrl);
localBroadcastManager.unregisterReceiver(this);
break;
case Installer.ACTION_INSTALL_USER_INTERACTION:
apk = intent.getParcelableExtra(Installer.EXTRA_APK);
PendingIntent installPendingIntent =
intent.getParcelableExtra(Installer.EXTRA_USER_INTERACTION_PI);
// show notification if app details is not visible
if (AppDetails.isAppVisible(apk.packageName)) {
cancelNotification(downloadUrl);
} else {
notifyDownloadComplete(apk, downloadUrl, installPendingIntent);
}
break;
default:
throw new RuntimeException("intent action not handled!");
}
}
};
localBroadcastManager.registerReceiver(installReceiver,
Installer.getInstallIntentFilter(downloadUri));
}
private NotificationCompat.Builder createNotificationBuilder(String urlString, Apk apk) {
int downloadUrlId = urlString.hashCode();
return new NotificationCompat.Builder(this)
.setAutoCancel(false)
.setOngoing(true)
.setContentIntent(getAppDetailsIntent(downloadUrlId, apk))
.setContentTitle(getString(R.string.downloading_apk, getAppName(apk)))
.addAction(R.drawable.ic_cancel_black_24dp, getString(R.string.cancel),
getCancelPendingIntent(urlString))
.setSmallIcon(android.R.drawable.stat_sys_download)
.setContentText(urlString)
.setProgress(100, 0, true);
}
private String getAppName(Apk apk) {
return ACTIVE_APPS.get(apk.packageName).name;
}
/**
* Get a {@link PendingIntent} for a {@link Notification} to send when it
* is clicked. {@link AppDetails} handles {@code Intent}s that are missing
* or bad {@link AppDetails#EXTRA_APPID}, so it does not need to be checked
* here.
*/
private PendingIntent getAppDetailsIntent(int requestCode, Apk apk) {
Intent notifyIntent = new Intent(getApplicationContext(), AppDetails.class)
.putExtra(AppDetails.EXTRA_APPID, apk.packageName);
return TaskStackBuilder.create(getApplicationContext())
.addParentStack(AppDetails.class)
.addNextIntent(notifyIntent)
.getPendingIntent(requestCode, PendingIntent.FLAG_UPDATE_CURRENT);
}
/**
* Post a notification about a completed download. {@code packageName} must be a valid
* and currently in the app index database. This must create a new {@code Builder}
* instance otherwise the progress/cancel stuff does not go away.
*
* @see <a href=https://code.google.com/p/android/issues/detail?id=47809> Issue 47809:
* Removing the progress bar from a notification should cause the notification's content
* text to return to normal size</a>
*/
private void notifyDownloadComplete(Apk apk, String urlString, PendingIntent installPendingIntent) {
String title;
try {
PackageManager pm = getPackageManager();
title = String.format(getString(R.string.tap_to_update_format),
pm.getApplicationLabel(pm.getApplicationInfo(apk.packageName, 0)));
} catch (PackageManager.NameNotFoundException e) {
String name = getAppName(apk);
if (TextUtils.isEmpty(name) || name.equals(new App().name)) {
ContentResolver resolver = getContentResolver();
App app = AppProvider.Helper.findSpecificApp(resolver, apk.packageName, apk.repo,
new String[]{Schema.AppMetadataTable.Cols.NAME});
if (app == null || TextUtils.isEmpty(app.name)) {
return; // do not have a name to display, so leave notification as is
}
name = app.name;
}
title = String.format(getString(R.string.tap_to_install_format), name);
}
int downloadUrlId = urlString.hashCode();
notificationManager.cancel(downloadUrlId);
Notification notification = new NotificationCompat.Builder(this)
.setAutoCancel(true)
.setOngoing(false)
.setContentTitle(title)
.setContentIntent(installPendingIntent)
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setContentText(getString(R.string.tap_to_install))
.build();
notificationManager.notify(downloadUrlId, notification);
}
private void notifyError(String urlString, App app, String text) {
int downloadUrlId = urlString.hashCode();
String name;
if (app == null) {
// if we have nothing else, show the APK filename
String path = Uri.parse(urlString).getPath();
name = path.substring(path.lastIndexOf('/'), path.length());
} else {
name = app.name;
}
String title = String.format(getString(R.string.install_error_notify_title), name);
Intent errorDialogIntent = new Intent(this, ErrorDialogActivity.class);
errorDialogIntent.putExtra(
ErrorDialogActivity.EXTRA_TITLE, title);
errorDialogIntent.putExtra(
ErrorDialogActivity.EXTRA_MESSAGE, text);
PendingIntent errorDialogPendingIntent = PendingIntent.getActivity(
getApplicationContext(),
downloadUrlId,
errorDialogIntent,
PendingIntent.FLAG_UPDATE_CURRENT);
NotificationCompat.Builder builder =
new NotificationCompat.Builder(this)
.setAutoCancel(true)
.setContentTitle(title)
.setContentIntent(errorDialogPendingIntent)
.setSmallIcon(R.drawable.ic_issues)
.setContentText(text);
NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
nm.notify(downloadUrlId, builder.build());
}
/**
* Cancel the {@link Notification} tied to {@code urlString}, which is the
* unique ID used to represent a given APK file. {@link String#hashCode()}
* converts {@code urlString} to the required {@code int}.
*/
private void cancelNotification(String urlString) {
notificationManager.cancel(urlString.hashCode());
}
private static void addToActive(String urlString, App app, Apk apk) {
ACTIVE_APKS.put(urlString, apk);
ACTIVE_APPS.put(app.packageName, app);
}
/**
* Always returns an {@link Apk} instance to avoid annoying null guards.
*/
private static Apk getApkFromActive(String urlString) {
Apk apk = ACTIVE_APKS.get(urlString);
if (apk == null) {
return new Apk();
} else {
return apk;
}
}
/**
* Remove the {@link App} and {@Apk} instances that are associated with
* {@code urlString} from the {@link Map} of active apps. This can be
* called after this service has been destroyed and recreated based on the
* {@link BroadcastReceiver}s, in which case {@code urlString} would not
* find anything in the active maps.
*/
private static App getAppFromActive(String urlString) {
return ACTIVE_APPS.get(getApkFromActive(urlString).packageName);
}
/**
* Remove the URL from this service, and return the {@link Apk}. This returns
* an empty {@code Apk} instance if we get a null one so the code doesn't need
* lots of null guards.
*/
private static Apk removeFromActive(String urlString) {
Apk apk = ACTIVE_APKS.remove(urlString);
if (apk == null) {
return new Apk();
}
ACTIVE_APPS.remove(apk.packageName);
return apk;
}
private PendingIntent getCancelPendingIntent(String urlString) {
Intent intent = new Intent(this, InstallManagerService.class)
.setData(Uri.parse(urlString))
.setAction(ACTION_CANCEL)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | IntentCompat.FLAG_ACTIVITY_CLEAR_TASK);
return PendingIntent.getService(this,
urlString.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT);
}
/**
* Install an APK, checking the cache and downloading if necessary before starting the process.
* All notifications are sent as an {@link Intent} via local broadcasts to be received by
*
* @param context this app's {@link Context}
*/
public static void queue(Context context, App app, Apk apk) {
String urlString = apk.getUrl();
Uri downloadUri = Uri.parse(urlString);
Installer.sendBroadcastInstall(context, downloadUri, Installer.ACTION_INSTALL_STARTED, apk,
null, null);
Utils.debugLog(TAG, "queue " + app.packageName + " " + apk.versionCode + " from " + urlString);
Intent intent = new Intent(context, InstallManagerService.class);
intent.setAction(ACTION_INSTALL);
intent.setData(downloadUri);
intent.putExtra(EXTRA_APP, app);
intent.putExtra(EXTRA_APK, apk);
context.startService(intent);
}
public static void cancel(Context context, String urlString) {
Intent intent = new Intent(context, InstallManagerService.class);
intent.setAction(ACTION_CANCEL);
intent.setData(Uri.parse(urlString));
context.startService(intent);
}
/**
* Returns a {@link Set} of the {@code urlString}s that are currently active.
* {@code urlString}s are used as unique IDs throughout the
* {@code InstallManagerService} process, either as a {@code String} or as an
* {@code int} from {@link String#hashCode()}.
*/
public static Set<String> getActiveDownloadUrls() {
return ACTIVE_APKS.keySet();
}
/**
* Returns a {@link Set} of the {@code packageName}s that are currently active.
* {@code packageName}s are used as unique IDs for apps throughout all of
* Android, F-Droid, and other apps stores.
*/
public static Set<String> getActivePackageNames() {
return ACTIVE_APPS.keySet();
}
}