/*
* 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.Intent;
import android.os.Environment;
import android.os.Handler;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.text.TextUtils;
import org.akvo.flow.R;
import org.akvo.flow.data.database.SurveyDbAdapter;
import org.akvo.flow.domain.Survey;
import org.akvo.flow.domain.SurveyMetadata;
import org.akvo.flow.serialization.form.SurveyMetadataParser;
import org.akvo.flow.util.ConstantUtil;
import org.akvo.flow.util.FileUtil;
import org.akvo.flow.util.FileUtil.FileType;
import org.akvo.flow.util.NotificationHelper;
import org.akvo.flow.util.StatusUtil;
import org.akvo.flow.util.SurveyFileNameGenerator;
import org.akvo.flow.util.SurveyIdGenerator;
import org.akvo.flow.util.ViewUtil;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipInputStream;
import timber.log.Timber;
/**
* Service that will check a well-known location on the device's SD card for a
* zip file that contains data that should be loaded on the device. The root of
* the zip file can contain a file called dbinstructions.sql. If it does, the
* sql statements contained therein will be executed in the order they appear.
* The zip file can also contain any number of directories which can each
* contain ONE survey (the survey xml and any help media). The name of the
* directory must be the surveyID and the name of the survey file will be used
* for the survey name. The system will iterate through each directory and
* install the survey and help media contained therein. If the survey is already
* present on the device, the survey in the ZIP file will overwrite the data
* already on the device. If there are multiple zip files in the directory, this
* utility will process them in lexicographical order by file name; Any files
* with a name starting with . will be skipped (to prevent inadvertent
* processing of MAC OSX metadata files).
*
* @author Christopher Fagiani
*/
public class BootstrapService extends IntentService {
private static final String TAG = "BOOTSTRAP_SERVICE";
public volatile static boolean isProcessing = false;
private final SurveyIdGenerator surveyIdGenerator = new SurveyIdGenerator();
private final SurveyFileNameGenerator surveyFileNameGenerator = new SurveyFileNameGenerator();
private SurveyDbAdapter databaseAdapter;
private Handler mHandler;
public BootstrapService() {
super(TAG);
}
public void onHandleIntent(Intent intent) {
isProcessing = true;
checkAndInstall();
isProcessing = false;
sendBroadcastNotification();
}
/**
* Checks the bootstrap directory for unprocessed zip files. If they are
* found, they're processed one at a time. If an error occurs, all
* processing stops (subsequent zips won't be processed if there are
* multiple zips in the directory) just in case data in a later zip depends
* on the previous one being there.
*/
private void checkAndInstall() {
try {
ArrayList<File> zipFiles = getZipFiles();
if (zipFiles.isEmpty()) {
return;
}
String startMessage = getString(R.string.bootstrapstart);
displayNotification(startMessage);
databaseAdapter = new SurveyDbAdapter(this);
databaseAdapter.open();
try {
for (File file : zipFiles) {
try {
processFile(file);
} catch (Exception e) {
// try to roll back any database changes (if the zip has a rollback file)
rollback(file);
String newFilename = file.getAbsolutePath();
file.renameTo(new File(newFilename + ConstantUtil.PROCESSED_ERROR_SUFFIX));
throw (e);
}
}
String endMessage = getString(R.string.bootstrapcomplete);
displayNotification(endMessage);
} finally {
if (databaseAdapter != null) {
databaseAdapter.close();
}
}
} catch (Exception e) {
String errorMessage = getString(R.string.bootstraperror);
displayErrorNotification(errorMessage);
Timber.e(e,"Bootstrap error");
}
}
private void displayErrorNotification(String errorMessage) {
//FIXME: why are we repeating the message in title and text?
NotificationHelper.displayErrorNotification(errorMessage, errorMessage, this, ConstantUtil.NOTIFICATION_BOOTSTRAP);
}
private void displayNotification(String message) {
//FIXME: why are we repeating the message in title and text?
NotificationHelper.displayNotification(message, message, this, ConstantUtil.NOTIFICATION_BOOTSTRAP);
}
/**
* looks for the rollback file in the zip and, if it exists, attempts to
* execute the statements contained therein
*/
private void rollback(File zipFile) throws Exception {
ZipInputStream zis = new ZipInputStream(new FileInputStream(zipFile));
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
String parts[] = entry.getName().split("/");
String fileName = parts[parts.length - 1];
// make sure we're not processing a hidden file
if (!fileName.startsWith(".")) {
if (entry.getName().toLowerCase()
.endsWith(ConstantUtil.BOOTSTRAP_ROLLBACK_FILE.toLowerCase())) {
processDbInstructions(FileUtil.readText(zis), false);
}
}
}
}
/**
* processes a bootstrap zip file
*/
private void processFile(File file) throws Exception {
final ZipFile zipFile = new ZipFile(file);
Enumeration<? extends ZipEntry> entries = zipFile.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
String entryName = entry.getName();
// Skip directories and hidden/unwanted files
if (entry.isDirectory() || entryName.startsWith(".") ||
entryName.endsWith(ConstantUtil.BOOTSTRAP_ROLLBACK_FILE) || TextUtils
.isEmpty(entryName)) {
continue;
}
if (entryName.endsWith(ConstantUtil.BOOTSTRAP_DB_FILE)) {
// DB instructions
processDbInstructions(FileUtil.readText(zipFile.getInputStream(entry)), true);
} else if (entryName.endsWith(ConstantUtil.CASCADE_RES_SUFFIX)) {
// Cascade resource
FileUtil.extract(new ZipInputStream(zipFile.getInputStream(entry)),
FileUtil.getFilesDir(FileType.RES));
} else if (entryName.endsWith(ConstantUtil.XML_SUFFIX)) {
String filename = surveyFileNameGenerator.generateFileName(entryName);
String id = surveyIdGenerator.getSurveyIdFromFilePath(entryName);
processSurveyFile(zipFile, entry, filename, id);
} else {
String filename = surveyFileNameGenerator.generateFileName(entryName);
String id = surveyIdGenerator.getSurveyIdFromFilePath(entryName);
// Help media file
File helpDir = new File(FileUtil.getFilesDir(FileType.FORMS), id);
if (!helpDir.exists()) {
helpDir.mkdir();
}
FileUtil.copy(zipFile.getInputStream(entry),
new FileOutputStream(new File(helpDir, filename)));
}
}
// now rename the zip file so we don't process it again
file.renameTo(new File(file.getAbsolutePath() + ConstantUtil.PROCESSED_OK_SUFFIX));
}
private void processSurveyFile(@NonNull ZipFile zipFile, @NonNull ZipEntry entry,
@NonNull String filename, @NonNull String idFromFolderName) throws IOException {
Survey survey = databaseAdapter.getSurvey(idFromFolderName);
String surveyFolderName = generateSurveyFolder(entry);
// in both cases (new survey and existing), we need to update the xml
File surveyFile = generateNewSurveyFile(filename, surveyFolderName);
FileUtil.copy(zipFile.getInputStream(entry), new FileOutputStream(surveyFile));
// now read the survey XML back into memory to see if there is a version
SurveyMetadata surveyMetadata = readBasicSurveyData(surveyFile);
if (surveyMetadata == null) {
// Something went wrong, we cannot continue with this survey
return;
}
verifyAppId(surveyMetadata);
survey = updateSurvey(filename, idFromFolderName, survey, surveyFolderName, surveyMetadata);
// Save the Survey, SurveyGroup, and languages.
updateSurveyStorage(survey);
}
@Nullable
private SurveyMetadata readBasicSurveyData(File surveyFile) {
SurveyMetadata surveyMetadata = null;
try {
InputStream in = new FileInputStream(surveyFile);
SurveyMetadataParser parser = new SurveyMetadataParser();
surveyMetadata = parser.parse(in);
} catch (FileNotFoundException e) {
Timber.e("Could not load survey xml file");
}
return surveyMetadata;
}
@NonNull
private Survey updateSurvey(@NonNull String filename, @NonNull String idFromFolderName,
@Nullable Survey survey, @NonNull String surveyFolderName,
@NonNull SurveyMetadata surveyMetadata) {
String surveyName = filename;
if (surveyName.contains(ConstantUtil.DOT_SEPARATOR)) {
surveyName = surveyName.substring(0, surveyName.indexOf(ConstantUtil.DOT_SEPARATOR));
}
if (survey == null) {
survey = createSurvey(idFromFolderName, surveyMetadata, surveyName);
}
survey.setLocation(ConstantUtil.FILE_LOCATION);
String surveyFileName = generateSurveyFileName(filename, surveyFolderName);
survey.setFileName(surveyFileName);
if (!TextUtils.isEmpty(surveyMetadata.getName())) {
survey.setName(surveyMetadata.getName());
}
survey.setSurveyGroup(surveyMetadata.getSurveyGroup());
if (surveyMetadata.getVersion() > 0) {
survey.setVersion(surveyMetadata.getVersion());
} else {
survey.setVersion(1d);
}
return survey;
}
@NonNull
private Survey createSurvey(@NonNull String id, @NonNull SurveyMetadata surveyMetadata,
String surveyName) {
Survey survey = new Survey();
if (!TextUtils.isEmpty(surveyMetadata.getId())) {
survey.setId(surveyMetadata.getId());
} else {
survey.setId(id);
}
survey.setName(surveyName);
/**
* Resources are always attached to the zip file
*/
survey.setHelpDownloaded(true);
survey.setType(ConstantUtil.SURVEY_TYPE);
return survey;
}
/**
* Check form app id. Reject the form if it does not belong to the one set up
* @param loadedSurvey survey to verify
*/
private void verifyAppId(@NonNull SurveyMetadata loadedSurvey) {
final String app = StatusUtil.getApplicationId(this);
final String formApp = loadedSurvey.getApp();
if (!TextUtils.isEmpty(app) && !TextUtils.isEmpty(formApp) && !app.equals(formApp)) {
ViewUtil.displayToastFromService(getString(R.string.bootstrap_invalid_app), mHandler,
getApplicationContext());
throw new IllegalArgumentException("Form belongs to a different instance." +
" Expected: " + app + ". Got: " + formApp);
}
}
private void updateSurveyStorage(@NonNull Survey survey) {
databaseAdapter.addSurveyGroup(survey.getSurveyGroup());
databaseAdapter.saveSurvey(survey);
}
@NonNull
private File generateNewSurveyFile(@NonNull String filename,
@Nullable String surveyFolderName) {
File filesDir = FileUtil.getFilesDir(FileType.FORMS);
if (TextUtils.isEmpty(surveyFolderName)) {
return new File(filesDir, filename);
} else {
File surveyFolder = new File(filesDir, surveyFolderName);
if (!surveyFolder.exists()) {
surveyFolder.mkdir();
}
return new File(surveyFolder, filename);
}
}
@NonNull
private String generateSurveyFileName(@NonNull String filename,
@Nullable String surveyFolderName) {
StringBuilder sb = new StringBuilder(20);
if (!TextUtils.isEmpty(surveyFolderName)) {
sb.append(surveyFolderName);
sb.append(File.separator);
}
sb.append(filename);
return sb.toString();
}
@NonNull
private String generateSurveyFolder(@NonNull ZipEntry entry) {
String entryName = entry.getName();
String entryPaths[] = entryName == null ? new String[0] : entryName.split(File.separator);
return entryPaths.length < 2 ? "" : entryPaths[entryPaths.length - 2];
}
/**
* tokenizes instructions using the newline character as a delimiter and
* executes each line as a separate SQL command;
*/
private void processDbInstructions(String instructions, boolean failOnError)
throws Exception {
if (instructions != null && instructions.trim().length() > 0) {
String[] instructionList = instructions.split("\n");
for (String instruction : instructionList) {
String command = instruction.trim();
if (!command.endsWith(";")) {
command = command + ";";
}
try {
databaseAdapter.executeSql(command);
} catch (Exception e) {
if (failOnError) {
throw e;
}
}
}
}
}
/**
* returns an ordered list of zip files that exist in the device's bootstrap
* directory
*/
private ArrayList<File> getZipFiles() {
ArrayList<File> zipFiles = new ArrayList<>();
// zip files can only be loaded on the SD card (not internal storage) so
// we only need to look there
if (Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) {
File dir = FileUtil.getFilesDir(FileType.INBOX);
File[] fileList = dir.listFiles();
if (fileList != null) {
for (File file : fileList) {
if (file.isFile() && file.getName().toLowerCase()
.endsWith(ConstantUtil.ARCHIVE_SUFFIX)) {
zipFiles.add(file);
}
}
}
Collections.sort(zipFiles);
}
return zipFiles;
}
/**
* sets up the uncaught exception handler for this thread so we can report
* errors to the server.
*/
public void onCreate() {
super.onCreate();
mHandler = new Handler();
}
/**
* 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() {
Intent intentBroadcast = new Intent(getString(R.string.action_surveys_sync));
sendBroadcast(intentBroadcast);
}
}