/* * Copyright (C) 2010-12 Ciaran Gultnieks, ciaran@ciarang.com * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 3 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ package org.fdroid.fdroid; import android.app.AlarmManager; import android.app.IntentService; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.SharedPreferences; import android.database.Cursor; import android.net.ConnectivityManager; import android.net.NetworkInfo; import android.os.Build; import android.os.Handler; import android.os.Looper; import android.os.Process; import android.os.SystemClock; import android.preference.PreferenceManager; import android.support.v4.app.NotificationCompat; import android.support.v4.app.TaskStackBuilder; import android.support.v4.content.LocalBroadcastManager; import android.text.TextUtils; import android.util.Log; import android.widget.Toast; import org.fdroid.fdroid.data.Apk; import org.fdroid.fdroid.data.ApkProvider; import org.fdroid.fdroid.data.App; import org.fdroid.fdroid.data.AppProvider; import org.fdroid.fdroid.data.Repo; import org.fdroid.fdroid.data.RepoProvider; import org.fdroid.fdroid.data.Schema; import org.fdroid.fdroid.installer.InstallManagerService; import java.net.URL; import java.util.ArrayList; import java.util.List; public class UpdateService extends IntentService { private static final String TAG = "UpdateService"; public static final String LOCAL_ACTION_STATUS = "status"; public static final String EXTRA_MESSAGE = "msg"; public static final String EXTRA_REPO_ERRORS = "repoErrors"; public static final String EXTRA_STATUS_CODE = "status"; public static final String EXTRA_ADDRESS = "address"; public static final String EXTRA_MANUAL_UPDATE = "manualUpdate"; public static final String EXTRA_PROGRESS = "progress"; public static final int STATUS_COMPLETE_WITH_CHANGES = 0; public static final int STATUS_COMPLETE_AND_SAME = 1; public static final int STATUS_ERROR_GLOBAL = 2; public static final int STATUS_ERROR_LOCAL = 3; public static final int STATUS_ERROR_LOCAL_SMALL = 4; public static final int STATUS_INFO = 5; private static final String STATE_LAST_UPDATED = "lastUpdateCheck"; private static final int NOTIFY_ID_UPDATING = 0; private static final int NOTIFY_ID_UPDATES_AVAILABLE = 1; private static final int FLAG_NET_UNAVAILABLE = 0; private static final int FLAG_NET_METERED = 1; private static final int FLAG_NET_NO_LIMIT = 2; private static Handler toastHandler; private NotificationManager notificationManager; private NotificationCompat.Builder notificationBuilder; public UpdateService() { super("UpdateService"); } public static void updateNow(Context context) { updateRepoNow(null, context); } public static void updateRepoNow(String address, Context context) { Intent intent = new Intent(context, UpdateService.class); intent.putExtra(EXTRA_MANUAL_UPDATE, true); if (!TextUtils.isEmpty(address)) { intent.putExtra(EXTRA_ADDRESS, address); } context.startService(intent); } /** * Schedule or cancel this service to update the app index, according to the * current preferences. Should be called a) at boot, b) if the preference * is changed, or c) on startup, in case we get upgraded. */ public static void schedule(Context ctx) { SharedPreferences prefs = PreferenceManager .getDefaultSharedPreferences(ctx); String sint = prefs.getString(Preferences.PREF_UPD_INTERVAL, "0"); int interval = Integer.parseInt(sint); Intent intent = new Intent(ctx, UpdateService.class); PendingIntent pending = PendingIntent.getService(ctx, 0, intent, 0); AlarmManager alarm = (AlarmManager) ctx .getSystemService(Context.ALARM_SERVICE); alarm.cancel(pending); if (interval > 0) { alarm.setInexactRepeating(AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() + 5000, AlarmManager.INTERVAL_HOUR, pending); Utils.debugLog(TAG, "Update scheduler alarm set"); } else { Utils.debugLog(TAG, "Update scheduler alarm not set"); } } @Override public void onCreate() { super.onCreate(); notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); notificationBuilder = new NotificationCompat.Builder(this) .setSmallIcon(R.drawable.ic_refresh_white) .setOngoing(true) .setCategory(NotificationCompat.CATEGORY_SERVICE) .setContentTitle(getString(R.string.update_notification_title)); // Android docs are a little sketchy, however it seems that Gingerbread is the last // sdk that made a content intent mandatory: // // http://stackoverflow.com/a/20032920 // if (Build.VERSION.SDK_INT <= 10) { Intent pendingIntent = new Intent(this, FDroid.class); pendingIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); notificationBuilder.setContentIntent(PendingIntent.getActivity(this, 0, pendingIntent, PendingIntent.FLAG_UPDATE_CURRENT)); } } @Override public void onDestroy() { super.onDestroy(); notificationManager.cancel(NOTIFY_ID_UPDATING); LocalBroadcastManager.getInstance(this).unregisterReceiver(updateStatusReceiver); } private static void sendStatus(Context context, int statusCode) { sendStatus(context, statusCode, null, -1); } private static void sendStatus(Context context, int statusCode, String message) { sendStatus(context, statusCode, message, -1); } private static void sendStatus(Context context, int statusCode, String message, int progress) { Intent intent = new Intent(LOCAL_ACTION_STATUS); intent.putExtra(EXTRA_STATUS_CODE, statusCode); if (!TextUtils.isEmpty(message)) { intent.putExtra(EXTRA_MESSAGE, message); } intent.putExtra(EXTRA_PROGRESS, progress); LocalBroadcastManager.getInstance(context).sendBroadcast(intent); } private void sendRepoErrorStatus(int statusCode, ArrayList<CharSequence> repoErrors) { Intent intent = new Intent(LOCAL_ACTION_STATUS); intent.putExtra(EXTRA_STATUS_CODE, statusCode); intent.putExtra(EXTRA_REPO_ERRORS, repoErrors.toArray(new CharSequence[repoErrors.size()])); LocalBroadcastManager.getInstance(this).sendBroadcast(intent); } // For receiving results from the UpdateService when we've told it to // update in response to a user request. private final BroadcastReceiver updateStatusReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); if (TextUtils.isEmpty(action)) { return; } if (!action.equals(LOCAL_ACTION_STATUS)) { return; } final String message = intent.getStringExtra(EXTRA_MESSAGE); int resultCode = intent.getIntExtra(EXTRA_STATUS_CODE, -1); int progress = intent.getIntExtra(EXTRA_PROGRESS, -1); String text; switch (resultCode) { case STATUS_INFO: notificationBuilder.setContentText(message) .setCategory(NotificationCompat.CATEGORY_SERVICE); if (progress != -1) { notificationBuilder.setProgress(100, progress, false); } else { notificationBuilder.setProgress(100, 0, true); } notificationManager.notify(NOTIFY_ID_UPDATING, notificationBuilder.build()); break; case STATUS_ERROR_GLOBAL: text = context.getString(R.string.global_error_updating_repos, message); notificationBuilder.setContentText(text) .setCategory(NotificationCompat.CATEGORY_ERROR) .setSmallIcon(android.R.drawable.ic_dialog_alert); notificationManager.notify(NOTIFY_ID_UPDATING, notificationBuilder.build()); Toast.makeText(context, text, Toast.LENGTH_LONG).show(); break; case STATUS_ERROR_LOCAL: case STATUS_ERROR_LOCAL_SMALL: StringBuilder msgBuilder = new StringBuilder(); CharSequence[] repoErrors = intent.getCharSequenceArrayExtra(EXTRA_REPO_ERRORS); for (CharSequence error : repoErrors) { if (msgBuilder.length() > 0) msgBuilder.append('\n'); msgBuilder.append(error); } if (resultCode == STATUS_ERROR_LOCAL_SMALL) { msgBuilder.append('\n').append(context.getString(R.string.all_other_repos_fine)); } text = msgBuilder.toString(); notificationBuilder.setContentText(text) .setCategory(NotificationCompat.CATEGORY_ERROR) .setSmallIcon(android.R.drawable.ic_dialog_info); notificationManager.notify(NOTIFY_ID_UPDATING, notificationBuilder.build()); Toast.makeText(context, text, Toast.LENGTH_LONG).show(); break; case STATUS_COMPLETE_WITH_CHANGES: break; case STATUS_COMPLETE_AND_SAME: text = context.getString(R.string.repos_unchanged); notificationBuilder.setContentText(text) .setCategory(NotificationCompat.CATEGORY_SERVICE); notificationManager.notify(NOTIFY_ID_UPDATING, notificationBuilder.build()); break; } } }; /** * Check whether it is time to run the scheduled update. * We don't want to run if: * - The time between scheduled runs is set to zero (though don't know * when that would occur) * - Last update was too recent * - Not on wifi, but the property for "Only auto update on wifi" is set. * * @return True if we are due for a scheduled update. */ private boolean verifyIsTimeForScheduledRun() { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getBaseContext()); String sint = prefs.getString(Preferences.PREF_UPD_INTERVAL, "0"); int interval = Integer.parseInt(sint); if (interval == 0) { Log.i(TAG, "Skipping update - disabled"); return false; } long lastUpdate = prefs.getLong(STATE_LAST_UPDATED, 0); long elapsed = System.currentTimeMillis() - lastUpdate; if (elapsed < interval * 60 * 60 * 1000) { Log.i(TAG, "Skipping update - done " + elapsed + "ms ago, interval is " + interval + " hours"); return false; } return true; } /** * Gets the state of internet availability, whether there is no connection at all, * whether the connection has no usage limit (like most WiFi), or whether this is * a metered connection like most cellular plans or hotspot WiFi connections. */ private static int getNetworkState(Context context) { ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); NetworkInfo activeNetwork = cm.getActiveNetworkInfo(); if (activeNetwork == null || !activeNetwork.isConnected()) { return FLAG_NET_UNAVAILABLE; } int networkType = activeNetwork.getType(); switch (networkType) { case ConnectivityManager.TYPE_ETHERNET: case ConnectivityManager.TYPE_WIFI: if (Build.VERSION.SDK_INT >= 16 && cm.isActiveNetworkMetered()) { return FLAG_NET_METERED; } else { return FLAG_NET_NO_LIMIT; } default: return FLAG_NET_METERED; } } /** * In order to send a {@link Toast} from a {@link IntentService}, we have to do these tricks. */ private void sendNoInternetToast() { if (toastHandler == null) { toastHandler = new Handler(Looper.getMainLooper()); } toastHandler.post(new Runnable() { @Override public void run() { Toast.makeText(getApplicationContext(), R.string.warning_no_internet, Toast.LENGTH_SHORT).show(); } }); } @Override protected void onHandleIntent(Intent intent) { Process.setThreadPriority(Process.THREAD_PRIORITY_LOWEST); final long startTime = System.currentTimeMillis(); boolean manualUpdate = false; String address = null; if (intent != null) { address = intent.getStringExtra(EXTRA_ADDRESS); manualUpdate = intent.getBooleanExtra(EXTRA_MANUAL_UPDATE, false); } try { // See if it's time to actually do anything yet... int netState = getNetworkState(this); if (netState == FLAG_NET_UNAVAILABLE) { Utils.debugLog(TAG, "No internet, cannot update"); if (manualUpdate) { sendNoInternetToast(); } return; } if (manualUpdate) { Utils.debugLog(TAG, "manually requested update"); } else if (!verifyIsTimeForScheduledRun() || (netState == FLAG_NET_METERED && Preferences.get().isUpdateOnlyOnUnmeteredNetworks())) { Utils.debugLog(TAG, "don't run update"); return; } notificationManager.notify(NOTIFY_ID_UPDATING, notificationBuilder.build()); LocalBroadcastManager.getInstance(this).registerReceiver(updateStatusReceiver, new IntentFilter(LOCAL_ACTION_STATUS)); // Grab some preliminary information, then we can release the // database while we do all the downloading, etc... List<Repo> repos = RepoProvider.Helper.all(this); int unchangedRepos = 0; int updatedRepos = 0; int errorRepos = 0; ArrayList<CharSequence> repoErrors = new ArrayList<>(); boolean changes = false; boolean singleRepoUpdate = !TextUtils.isEmpty(address); final Preferences fdroidPrefs = Preferences.get(); for (final Repo repo : repos) { if (!repo.inuse) { continue; } if (singleRepoUpdate && !repo.address.equals(address)) { unchangedRepos++; continue; } if (!singleRepoUpdate && repo.isSwap) { continue; } sendStatus(this, STATUS_INFO, getString(R.string.status_connecting_to_repo, repo.address)); RepoUpdater updater = new RepoUpdater(getBaseContext(), repo); setProgressListeners(updater); try { updater.update(); if (updater.hasChanged()) { updatedRepos++; changes = true; } else { unchangedRepos++; } } catch (RepoUpdater.UpdateException e) { errorRepos++; repoErrors.add(e.getMessage()); Log.e(TAG, "Error updating repository " + repo.address, e); } // now that downloading the index is done, start downloading updates if (changes && fdroidPrefs.isAutoDownloadEnabled()) { autoDownloadUpdates(); } } if (!changes) { Utils.debugLog(TAG, "Not checking app details or compatibility, because all repos were up to date."); } else { notifyContentProviders(); if (fdroidPrefs.isUpdateNotificationEnabled() && !fdroidPrefs.isAutoDownloadEnabled()) { performUpdateNotification(); } } SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getBaseContext()); SharedPreferences.Editor e = prefs.edit(); e.putLong(STATE_LAST_UPDATED, System.currentTimeMillis()); e.apply(); if (errorRepos == 0) { if (changes) { sendStatus(this, STATUS_COMPLETE_WITH_CHANGES); } else { sendStatus(this, STATUS_COMPLETE_AND_SAME); } } else { if (updatedRepos + unchangedRepos == 0) { sendRepoErrorStatus(STATUS_ERROR_LOCAL, repoErrors); } else { sendRepoErrorStatus(STATUS_ERROR_LOCAL_SMALL, repoErrors); } } } catch (Exception e) { Log.e(TAG, "Exception during update processing", e); sendStatus(this, STATUS_ERROR_GLOBAL, e.getMessage()); } long time = System.currentTimeMillis() - startTime; Log.i(TAG, "Updating repo(s) complete, took " + time / 1000 + " seconds to complete."); } private void notifyContentProviders() { getContentResolver().notifyChange(AppProvider.getContentUri(), null); getContentResolver().notifyChange(ApkProvider.getContentUri(), null); } private void performUpdateNotification() { Cursor cursor = getContentResolver().query( AppProvider.getCanUpdateUri(), Schema.AppMetadataTable.Cols.ALL, null, null, null); if (cursor != null) { if (cursor.getCount() > 0) { showAppUpdatesNotification(cursor); } cursor.close(); } } private PendingIntent createNotificationIntent() { Intent notifyIntent = new Intent(this, FDroid.class).putExtra(FDroid.EXTRA_TAB_UPDATE, true); TaskStackBuilder stackBuilder = TaskStackBuilder .create(this).addParentStack(FDroid.class) .addNextIntent(notifyIntent); return stackBuilder.getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT); } private static final int MAX_UPDATES_TO_SHOW = 5; private NotificationCompat.Style createNotificationBigStyle(Cursor hasUpdates) { final String contentText = hasUpdates.getCount() > 1 ? getString(R.string.many_updates_available, hasUpdates.getCount()) : getString(R.string.one_update_available); NotificationCompat.InboxStyle inboxStyle = new NotificationCompat.InboxStyle(); inboxStyle.setBigContentTitle(contentText); hasUpdates.moveToFirst(); for (int i = 0; i < Math.min(hasUpdates.getCount(), MAX_UPDATES_TO_SHOW); i++) { App app = new App(hasUpdates); hasUpdates.moveToNext(); inboxStyle.addLine(app.name + " (" + app.installedVersionName + " → " + app.getSuggestedVersionName() + ")"); } if (hasUpdates.getCount() > MAX_UPDATES_TO_SHOW) { int diff = hasUpdates.getCount() - MAX_UPDATES_TO_SHOW; inboxStyle.setSummaryText(getString(R.string.update_notification_more, diff)); } return inboxStyle; } private void autoDownloadUpdates() { Cursor cursor = getContentResolver().query( AppProvider.getCanUpdateUri(), Schema.AppMetadataTable.Cols.ALL, null, null, null); if (cursor != null) { cursor.moveToFirst(); for (int i = 0; i < cursor.getCount(); i++) { App app = new App(cursor); Apk apk = ApkProvider.Helper.findApkFromAnyRepo(this, app.packageName, app.suggestedVersionCode); InstallManagerService.queue(this, app, apk); cursor.moveToNext(); } cursor.close(); } } private void showAppUpdatesNotification(Cursor hasUpdates) { Utils.debugLog(TAG, "Notifying " + hasUpdates.getCount() + " updates."); final int icon = Build.VERSION.SDK_INT >= 11 ? R.drawable.ic_stat_notify_updates : R.drawable.ic_launcher; final String contentText = hasUpdates.getCount() > 1 ? getString(R.string.many_updates_available, hasUpdates.getCount()) : getString(R.string.one_update_available); NotificationCompat.Builder builder = new NotificationCompat.Builder(this) .setAutoCancel(true) .setContentTitle(getString(R.string.fdroid_updates_available)) .setSmallIcon(icon) .setContentIntent(createNotificationIntent()) .setContentText(contentText) .setStyle(createNotificationBigStyle(hasUpdates)); notificationManager.notify(NOTIFY_ID_UPDATES_AVAILABLE, builder.build()); } /** * Set up the various {@link ProgressListener}s needed to get feedback to the UI. * Note: {@code ProgressListener}s do not need to be unregistered, they can just * be set again for each download. */ private void setProgressListeners(RepoUpdater updater) { updater.setDownloadProgressListener(new ProgressListener() { @Override public void onProgress(URL sourceUrl, int bytesRead, int totalBytes) { Log.i(TAG, "downloadProgressReceiver " + sourceUrl); String downloadedSizeFriendly = Utils.getFriendlySize(bytesRead); int percent = -1; if (totalBytes > 0) { percent = (int) ((double) bytesRead / totalBytes * 100); } String message; if (totalBytes == -1) { message = getString(R.string.status_download_unknown_size, sourceUrl, downloadedSizeFriendly); percent = -1; } else { String totalSizeFriendly = Utils.getFriendlySize(totalBytes); message = getString(R.string.status_download, sourceUrl, downloadedSizeFriendly, totalSizeFriendly, percent); } sendStatus(getApplicationContext(), STATUS_INFO, message, percent); } }); updater.setProcessXmlProgressListener(new ProgressListener() { @Override public void onProgress(URL sourceUrl, int bytesRead, int totalBytes) { String downloadedSize = Utils.getFriendlySize(bytesRead); String totalSize = Utils.getFriendlySize(totalBytes); int percent = -1; if (totalBytes > 0) { percent = (int) ((double) bytesRead / totalBytes * 100); } String message = getString(R.string.status_processing_xml_percent, sourceUrl, downloadedSize, totalSize, percent); sendStatus(getApplicationContext(), STATUS_INFO, message, percent); } }); updater.setCommittingProgressListener(new ProgressListener() { @Override public void onProgress(URL sourceUrl, int bytesRead, int totalBytes) { String message = getString(R.string.status_inserting_apps); sendStatus(getApplicationContext(), STATUS_INFO, message); } }); } }