package org.commcare.engine.resource; import android.content.Context; import android.os.Handler; import android.util.Log; import org.commcare.CommCareApp; import org.commcare.CommCareApplication; import org.commcare.engine.resource.installers.LocalStorageUnavailableException; import org.commcare.logging.AndroidLogger; import org.commcare.logging.analytics.UpdateStats; import org.commcare.resources.ResourceManager; import org.commcare.resources.model.InstallCancelled; import org.commcare.resources.model.InstallCancelledException; import org.commcare.resources.model.Resource; import org.commcare.resources.model.ResourceTable; import org.commcare.resources.model.TableStateListener; import org.commcare.resources.model.UnresolvedResourceException; import org.commcare.tasks.UpdateTask; import org.commcare.util.CommCarePlatform; import org.commcare.utils.AndroidCommCarePlatform; import org.commcare.utils.AndroidResourceInstallerFactory; import org.commcare.utils.SessionUnavailableException; import org.javarosa.core.services.Logger; import org.javarosa.xml.util.UnfullfilledRequirementsException; /** * Manages app installations and updates. Extends the ResourceManager with the * ability to stage but not apply updates. * * @author Phillip Mates (pmates@dimagi.com) */ public class AndroidResourceManager extends ResourceManager { private final static String TAG = AndroidResourceManager.class.getSimpleName(); public final static String TEMP_UPGRADE_TABLE_KEY = "TEMP_UPGRADE_RESOURCE_TABLE"; // 60 minutes private final static long MAX_UPDATE_RETRY_DELAY_IN_MS = 1000 * 60 * 60; private final CommCareApp app; private final UpdateStats updateStats; private final ResourceTable tempUpgradeTable; private String profileRef; public AndroidResourceManager(AndroidCommCarePlatform platform) { super(platform, platform.getGlobalResourceTable(), platform.getUpgradeResourceTable(), platform.getRecoveryTable()); app = CommCareApplication.instance().getCurrentApp(); tempUpgradeTable = new AndroidResourceTable(app.getStorage(TEMP_UPGRADE_TABLE_KEY, Resource.class), new AndroidResourceInstallerFactory()); updateStats = UpdateStats.loadUpdateStats(app); upgradeTable.setInstallStatsLogger(updateStats); tempUpgradeTable.setInstallStatsLogger(updateStats); } /** * Download the latest profile; if it is new, download and stage the entire * update. * * @param profileRef Reference that resolves to the profile file used to * seed the update * @param profileAuthority The authority from which the app resources for the update are * coming (local vs. remote) * @return UpdateStaged upon update download, UpToDate if no new update, * otherwise an error status. */ public AppInstallStatus checkAndPrepareUpgradeResources(String profileRef, int profileAuthority) throws UnfullfilledRequirementsException, UnresolvedResourceException { synchronized (updateLock) { this.profileRef = profileRef; try { instantiateLatestUpgradeProfile(profileAuthority); if (isUpgradeTableStaged()) { return AppInstallStatus.UpdateStaged; } if (updateNotNewer(getMasterProfile())) { Logger.log(AndroidLogger.TYPE_RESOURCES, "App Resources up to Date"); upgradeTable.clear(); return AppInstallStatus.UpToDate; } prepareUpgradeResources(); } catch (InstallCancelledException e) { // The user cancelled the upgrade check process. The calling task // should have caught and handled the cancellation return AppInstallStatus.UnknownFailure; } return AppInstallStatus.UpdateStaged; } } /** * Load the latest profile into the upgrade table. Clears the upgrade table * if it's partially populated with an out-of-date version. */ private void instantiateLatestUpgradeProfile(int authority) throws UnfullfilledRequirementsException, UnresolvedResourceException, InstallCancelledException { ensureMasterTableValid(); if (updateStats.isUpgradeStale()) { Log.i(TAG, "Clearing upgrade table because resource downloads " + "failed too many times or started too long ago"); upgradeTable.destroy(); updateStats.resetStats(app); } Resource upgradeProfile = upgradeTable.getResourceWithId(CommCarePlatform.APP_PROFILE_RESOURCE_ID); if (upgradeProfile == null) { loadProfileIntoTable(upgradeTable, profileRef, authority); } else { loadProfileViaTemp(upgradeProfile, authority); } } /** * Download the latest profile into the temporary table and if the version * higher than the upgrade table's profile, copy it into the upgrade table. * * @param upgradeProfile the profile currently in the upgrade table. */ private void loadProfileViaTemp(Resource upgradeProfile, int profileAuthority) throws UnfullfilledRequirementsException, UnresolvedResourceException, InstallCancelledException { tempUpgradeTable.destroy(); loadProfileIntoTable(tempUpgradeTable, profileRef, profileAuthority); Resource tempProfile = tempUpgradeTable.getResourceWithId(CommCarePlatform.APP_PROFILE_RESOURCE_ID); if (tempProfile != null && tempProfile.isNewer(upgradeProfile)) { upgradeTable.destroy(); tempUpgradeTable.copyToTable(upgradeTable); } tempUpgradeTable.destroy(); } /** * Set listeners and checkers that enable communication between low-level * resource installation and top-level app update/installation process. * * @param tableListener allows resource table to report its progress to the * launching process * @param cancelCheckker allows resource installers to check if the * launching process was cancelled */ @Override public void setUpgradeListeners(TableStateListener tableListener, InstallCancelled cancelCheckker) { super.setUpgradeListeners(tableListener, cancelCheckker); tempUpgradeTable.setStateListener(tableListener); tempUpgradeTable.setInstallCancellationChecker(cancelCheckker); } /** * Save upgrade stats if the upgrade was cancelled and wasn't complete at * that time. */ public void upgradeCancelled() { if (!isUpgradeTableStaged()) { UpdateStats.saveStatsPersistently(app, updateStats); } else { Log.i(TAG, "Upgrade cancelled, but already finished with these stats"); Log.i(TAG, updateStats.toString()); } } public void incrementUpdateAttempts() { updateStats.registerStagingAttempt(); } /** * Log update failure that occurs while trying to install the staged update table */ public void recordUpdateInstallFailure(Exception exception) { updateStats.registerUpdateException(exception); } public void recordUpdateInstallFailure(AppInstallStatus result) { updateStats.registerUpdateException(new Exception(result.toString())); } /** * Clear update table, log failure with update stats, * and, if appropriate, schedule a update retry * * @param result update attempt result * @param ctx Used for showing pinned notification of update task retry * @param isAutoUpdate When set keep retrying update with delay and max retry count */ public void processUpdateFailure(AppInstallStatus result, Context ctx, boolean isAutoUpdate) { updateStats.registerUpdateException(new Exception(result.toString())); if (!result.canReusePartialUpdateTable()) { upgradeTable.clear(); } retryUpdateOrGiveUp(ctx, isAutoUpdate); } private void retryUpdateOrGiveUp(Context ctx, boolean isAutoUpdate) { if (updateStats.isUpgradeStale()) { Log.i(TAG, "Stop trying to download update. Here are the update stats:"); // NOTE PLM: this is currently the only place that update stats // are uploaded to HQ via normal log uploads Logger.log("App Update", updateStats.toString()); UpdateStats.clearPersistedStats(app); if (isAutoUpdate) { ResourceInstallUtils.recordAutoUpdateCompletion(app); } upgradeTable.clear(); } else { Log.w(TAG, "Retrying auto-update"); UpdateStats.saveStatsPersistently(app, updateStats); if (isAutoUpdate) { scheduleUpdateTaskRetry(ctx, updateStats.getRestartCount()); } } } private void scheduleUpdateTaskRetry(final Context ctx, int numberOfRestarts) { final Handler handler = new Handler(); handler.postDelayed(new Runnable() { @Override public void run() { launchRetryTask(ctx); } }, exponentionalRetryDelay(numberOfRestarts)); } private void launchRetryTask(Context ctx) { String ref = ResourceInstallUtils.getDefaultProfileRef(); try { if (canUpdateRetryRun()) { UpdateTask updateTask = UpdateTask.getNewInstance(); updateTask.startPinnedNotification(ctx); updateTask.setAsAutoUpdate(); updateTask.executeParallel(ref); } } catch (IllegalStateException e) { // The user may have started the update process in the meantime Log.w(TAG, "Trying trigger an auto-update retry when it is already running"); } } /** * @return Logged into an app that has begun the auto-update process */ private static boolean canUpdateRetryRun() { try { CommCareApp currentApp = CommCareApplication.instance().getCurrentApp(); // NOTE PLM: Doesn't distinguish between two apps currently in the // auto-update process. return CommCareApplication.instance().getSession().isActive() && ResourceInstallUtils.shouldAutoUpdateResume(currentApp); } catch (SessionUnavailableException e) { return false; } } /** * Retry delay that ranges between 30 seconds and 60 minutes. * At 3 retries the delay is 35 seconds, at 5 retries it is at 30 minutes. * * @param numberOfRestarts used as the exponent for the delay calculation * @return delay in MS, which grows exponentially over the number of restarts. */ private long exponentionalRetryDelay(int numberOfRestarts) { final Double base = 10 * (1.78); final long thirtySeconds = 30 * 1000; long exponentialDelay = thirtySeconds + (long)Math.pow(base, numberOfRestarts); return Math.min(exponentialDelay, MAX_UPDATE_RETRY_DELAY_IN_MS); } }