/* * Copyright (C) 2010-2017 Stichting Akvo (Akvo Foundation) * * This file is part of Akvo Flow. * * Akvo Flow 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. * * Akvo Flow 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 Akvo Flow. If not, see <http://www.gnu.org/licenses/>. */ package org.akvo.flow.service; import android.app.IntentService; import android.content.Context; import android.content.Intent; import android.content.res.Resources; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import org.akvo.flow.R; import org.akvo.flow.api.FlowApi; import org.akvo.flow.api.S3Api; import org.akvo.flow.data.dao.SurveyDao; import org.akvo.flow.data.database.SurveyDbAdapter; import org.akvo.flow.data.preference.Prefs; import org.akvo.flow.domain.Question; import org.akvo.flow.domain.QuestionGroup; import org.akvo.flow.domain.QuestionHelp; import org.akvo.flow.domain.Survey; import org.akvo.flow.domain.SurveyGroup; import org.akvo.flow.util.ConnectivityStateManager; import org.akvo.flow.util.ConstantUtil; import org.akvo.flow.util.FileUtil; import org.akvo.flow.util.FileUtil.FileType; import org.akvo.flow.util.HttpUtil; import org.akvo.flow.util.NotificationHelper; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.zip.ZipInputStream; import timber.log.Timber; /** * This activity will check for new surveys on the device and install as needed * * @author Christopher Fagiani */ public class SurveyDownloadService extends IntentService { private static final String TAG = "SURVEY_DOWNLOAD_SERVICE"; /** * Intent parameter to specify which survey needs to be downloaded */ public static final String EXTRA_SURVEY_ID = "survey"; public static final String EXTRA_DELETE_SURVEYS = "delete_surveys"; private static final String DEFAULT_TYPE = "Survey"; public static final String TEST_SURVEY_ID = "0"; private SurveyDbAdapter databaseAdaptor; private Prefs prefs; private ConnectivityStateManager connectivityStateManager; public SurveyDownloadService() { super(TAG); } public void onHandleIntent(@Nullable Intent intent) { try { databaseAdaptor = new SurveyDbAdapter(this); databaseAdaptor.open(); prefs = new Prefs(getApplicationContext()); connectivityStateManager = new ConnectivityStateManager(getApplicationContext()); if (intent != null && intent.hasExtra(EXTRA_SURVEY_ID)) { downloadSurvey(intent); } else if (intent != null && intent.getBooleanExtra(EXTRA_DELETE_SURVEYS, false)) { reDownloadAllSurveys(intent); } else { checkAndDownload(null); } } catch (Exception e) { Timber.e(e, e.getMessage()); } finally { databaseAdaptor.close(); sendBroadcastNotification(this); } } private void reDownloadAllSurveys(@NonNull Intent intent) { intent.removeExtra(EXTRA_DELETE_SURVEYS); String[] surveyIds = databaseAdaptor.getSurveyIds(); databaseAdaptor.deleteAllSurveys(); checkAndDownload(surveyIds); } private void downloadSurvey(@NonNull Intent intent) { String surveyId = intent.getStringExtra(EXTRA_SURVEY_ID); intent.removeExtra(EXTRA_SURVEY_ID); if (TEST_SURVEY_ID.equals(surveyId)) { databaseAdaptor.reinstallTestSurvey(); } else { checkAndDownload(new String[] { surveyId }); } } /** * if no surveyIds are passed in, this will check for new surveys and, if * there are some new ones, downloads them to the DATA_DIR. If surveyIds are * passed in, then those specific surveys will be downloaded. If they're already * on the device, the surveys will be replaced with the new ones. */ private void checkAndDownload(@Nullable String[] surveyIds) { if (!connectivityStateManager.isConnectionAvailable( prefs.getBoolean(Prefs.KEY_CELL_UPLOAD, Prefs.DEFAULT_VALUE_CELL_UPLOAD))) { //No internet or not allowed to sync return; } List<Survey> surveys; if (surveyIds != null && surveyIds.length > 0) { surveys = getSurveyHeaders(surveyIds); } else { surveys = checkForSurveys(); } // Update all survey groups syncSurveyGroups(surveys); // Check synced versions, and omit up-to-date surveys surveys = databaseAdaptor.checkSurveyVersions(surveys); if (!surveys.isEmpty()) { int synced = 0, failed = 0; displayNotification(synced, failed, surveys.size()); for (Survey survey : surveys) { try { downloadSurvey(survey); databaseAdaptor.saveSurvey(survey); downloadResources(survey); synced++; } catch (IOException e) { failed++; Timber.e(e, "Error downloading survey: " + survey.getId()); displayErrorNotification(ConstantUtil.NOTIFICATION_FORM_ERROR, getString(R.string.error_form_download)); } displayNotification(synced, failed, surveys.size()); } } // now check if any previously downloaded surveys still need // don't have their help media pre-cached surveys = databaseAdaptor.getSurveyList(SurveyGroup.ID_NONE); for (Survey survey : surveys) { if (!survey.isHelpDownloaded()) { downloadResources(survey); } } } private void syncSurveyGroups(@NonNull List<Survey> surveys) { for (Survey s : surveys) { // Assign registration form id, if missing. SurveyGroup sg = s.getSurveyGroup(); if (sg != null && sg.getRegisterSurveyId() == null) { sg.setRegisterSurveyId(s.getId()); } databaseAdaptor.addSurveyGroup(sg); } } /** * Downloads the survey based on the ID and then updates the survey object * with the filename and location */ private void downloadSurvey(@NonNull Survey survey) throws IOException { final String filename = survey.getId() + ConstantUtil.ARCHIVE_SUFFIX; final String objectKey = ConstantUtil.S3_SURVEYS_DIR + filename; final File file = new File(FileUtil.getFilesDir(FileType.FORMS), filename); S3Api s3Api = new S3Api(this); s3Api.get(objectKey, file); // Download zip file FileUtil.extract(new ZipInputStream(new FileInputStream(file)), FileUtil.getFilesDir(FileType.FORMS)); // Compressed file is not needed any more if (!file.delete()) { Timber.e("Could not delete survey zip file: " + filename); } survey.setFileName(survey.getId() + ConstantUtil.XML_SUFFIX); survey.setType(DEFAULT_TYPE); survey.setLocation(ConstantUtil.FILE_LOCATION); } @Nullable private Survey loadSurvey(@NonNull Survey survey) { InputStream in = null; Survey hydratedDurvey = null; try { if (ConstantUtil.RESOURCE_LOCATION.equalsIgnoreCase(survey.getLocation())) { // load from resource Resources res = getResources(); in = res.openRawResource(res.getIdentifier(survey.getFileName(), ConstantUtil.RAW_RESOURCE, ConstantUtil.RESOURCE_PACKAGE)); } else { // load from file File f = new File(FileUtil.getFilesDir(FileType.FORMS), survey.getFileName()); in = new FileInputStream(f); } hydratedDurvey = SurveyDao.loadSurvey(survey, in); } catch (FileNotFoundException e) { Timber.e(e, "Could not parse survey survey file"); } finally { FileUtil.close(in); } return hydratedDurvey; } /** * checks to see if we should pre-cache help media files (based on the * property in the settings db) and, if we should, downloads the files * * @param survey */ private void downloadResources(@NonNull Survey survey) { Survey hydratedSurvey = loadSurvey(survey); if (hydratedSurvey != null) { // collect files in a set just in case the same binary is // used in multiple questions we only need to download once Set<String> resources = new HashSet<>(); for (QuestionGroup group : hydratedSurvey.getQuestionGroups()) { for (Question question : group.getQuestions()) { if (!question.getHelpByType(ConstantUtil.VIDEO_HELP_TYPE).isEmpty()) { resources.add(question.getHelpByType(ConstantUtil.VIDEO_HELP_TYPE) .get(0).getValue()); } for (QuestionHelp help : question.getHelpByType(ConstantUtil.IMAGE_HELP_TYPE)) { resources.add(help.getValue()); } // Question src data (i.e. cascading question resources) if (question.getSrc() != null) { resources.add(question.getSrc()); } } } // Download help media files (images & videos) and common resources downloadResources(survey.getId(), resources); } } private void downloadResources(@NonNull final String sid, @NonNull final Set<String> resources) { databaseAdaptor.markSurveyHelpDownloaded(sid, false); boolean ok = true; for (String resource : resources) { Timber.i("Downloading resource: " + resource); try { // Handle both absolute URL (media help files) and S3 object IDs (survey resources) // Naive check to determine whether or not this is an absolute filename if (resource.startsWith("http")) { downloadGaeResource(sid, resource); } else { downloadS3Resource(resource); } } catch (Exception e) { ok = false; // Display cascade-specific error message. If at any point we include support for // more resource types, this message should be accordingly customized. displayErrorNotification(ConstantUtil.NOTIFICATION_RESOURCE_ERROR, getString(R.string.error_missing_cascade)); Timber.e(e, "Could not download resource " + resource + " for survey " + sid); } } // Mark help (survey resources) as downloaded if ALL files succeeded. if (ok) { databaseAdaptor.markSurveyHelpDownloaded(sid, true); } } private void downloadS3Resource(String resource) throws IOException { // resource is just a filename final String filename = resource + ConstantUtil.ARCHIVE_SUFFIX; final String objectKey = ConstantUtil.S3_SURVEYS_DIR + filename; final File resDir = FileUtil.getFilesDir(FileType.RES); final File file = new File(resDir, filename); S3Api s3 = new S3Api(SurveyDownloadService.this); s3.syncFile(objectKey, file); FileUtil.extract(new ZipInputStream(new FileInputStream(file)), resDir); if (!file.delete()) { Timber.e("Error deleting resource zip file"); } } private void downloadGaeResource(@NonNull String sid, @NonNull String url) throws IOException { final String filename = new File(url).getName(); final File surveyDir = new File(FileUtil.getFilesDir(FileType.FORMS), sid); if (!surveyDir.exists()) { surveyDir.mkdir(); } HttpUtil.httpGet(url, new File(surveyDir, filename)); } /** * invokes a service call to get the header information for multiple surveys */ @NonNull private List<Survey> getSurveyHeaders(@NonNull String[] surveyIds) { List<Survey> surveys = new ArrayList<>(); FlowApi flowApi = new FlowApi(getApplicationContext()); for (String id : surveyIds) { try { surveys.addAll(flowApi.getSurveyHeader(id)); } catch (IllegalArgumentException | IOException e) { if (e instanceof IllegalArgumentException) { Timber.e(e); } displayErrorNotification(ConstantUtil.NOTIFICATION_HEADER_ERROR, getString(R.string.error_form_header, id)); } } return surveys; } /** * invokes a service call to list all surveys that have been designated for * this device (based on phone number). * * @return - an arrayList of Survey objects with the id and version populated * TODO: Move this feature to FLOWApi */ private List<Survey> checkForSurveys() { List<Survey> surveys = new ArrayList<>(); FlowApi api = new FlowApi(getApplicationContext()); try { surveys = api.getSurveys(); } catch (@NonNull IllegalArgumentException | IOException e) { if (e instanceof IllegalArgumentException) { Timber.e(e, e.getMessage()); } displayErrorNotification(ConstantUtil.NOTIFICATION_ASSIGNMENT_ERROR, getString(R.string.error_assignment_read)); } return surveys; } private void displayErrorNotification(int id, String msg) { NotificationHelper .displayErrorNotification(getString(R.string.error_form_sync_title), msg, this, id); } private void displayNotification(int synced, int failed, int total) { boolean finished = synced + failed >= total; String title = getString(R.string.downloading_forms); // Do not show failed if there is none String text = failed > 0 ? String.format(getString(R.string.data_sync_all), synced, failed) : String.format(getString(R.string.data_sync_synced), synced); NotificationHelper.displayNotification(this, total, title, text, ConstantUtil.NOTIFICATION_FORMS_SYNCED, !finished, synced + failed); } /** * Dispatch a Broadcast notification to notify of surveys synchronization. * This notification will be received in SurveyHomeActivity, in order to * refresh its data */ private void sendBroadcastNotification(@NonNull Context context) { Intent intentBroadcast = new Intent(context.getString(R.string.action_surveys_sync)); context.sendBroadcast(intentBroadcast); } }