Java tutorial
/* * Copyright (C) 2012 University of Washington * * Licensed under the Apache License, Version 2.0 (the "License"); you may not * use this file except in compliance with the License. You may obtain a copy of * the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations under * the License. */ package org.opendatakit.sync.aggregate; import java.io.BufferedOutputStream; import java.io.File; import java.io.FileFilter; import java.io.FileOutputStream; import java.io.InputStream; import java.net.CookieHandler; import java.net.CookieManager; import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; import java.net.URLEncoder; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.GregorianCalendar; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.TimeZone; import javax.ws.rs.core.HttpHeaders; import javax.ws.rs.core.MediaType; import javax.ws.rs.ext.RuntimeDelegate; import org.apache.commons.lang3.CharEncoding; import org.apache.http.HttpStatus; import org.apache.wink.client.ClientConfig; import org.apache.wink.client.ClientResponse; import org.apache.wink.client.ClientWebException; import org.apache.wink.client.EntityType; import org.apache.wink.client.Resource; import org.apache.wink.client.RestClient; import org.apache.wink.client.internal.handlers.GzipHandler; import org.opendatakit.aggregate.odktables.rest.ApiConstants; import org.opendatakit.aggregate.odktables.rest.entity.ChangeSetList; import org.opendatakit.aggregate.odktables.rest.entity.Column; import org.opendatakit.aggregate.odktables.rest.entity.OdkTablesFileManifest; import org.opendatakit.aggregate.odktables.rest.entity.OdkTablesFileManifestEntry; import org.opendatakit.aggregate.odktables.rest.entity.Row; import org.opendatakit.aggregate.odktables.rest.entity.RowList; import org.opendatakit.aggregate.odktables.rest.entity.RowOutcomeList; import org.opendatakit.aggregate.odktables.rest.entity.RowResourceList; import org.opendatakit.aggregate.odktables.rest.entity.TableDefinition; import org.opendatakit.aggregate.odktables.rest.entity.TableDefinitionResource; import org.opendatakit.aggregate.odktables.rest.entity.TableResource; import org.opendatakit.aggregate.odktables.rest.entity.TableResourceList; import org.opendatakit.common.android.utilities.ODKFileUtils; import org.opendatakit.common.android.utilities.SyncETagsUtils; import org.opendatakit.common.android.utilities.WebLogger; import org.opendatakit.common.android.utilities.WebUtils; import org.opendatakit.sync.R; import org.opendatakit.sync.SyncPreferences; import org.opendatakit.sync.SyncRow; import org.opendatakit.sync.SyncRowPending; import org.opendatakit.sync.Synchronizer; import org.opendatakit.sync.application.Sync; import org.opendatakit.sync.exceptions.InvalidAuthTokenException; import org.opendatakit.sync.service.SyncProgressState; import android.accounts.Account; import android.accounts.AccountManager; import android.content.Context; /** * Implementation of {@link Synchronizer} for ODK Aggregate. * * @author the.dylan.price@gmail.com * @author sudar.sam@gmail.com * */ public class AggregateSynchronizer implements Synchronizer { private static final String LOGTAG = AggregateSynchronizer.class.getSimpleName(); private static final String TOKEN_INFO = "https://www.googleapis.com/oauth2/v1/tokeninfo?access_token="; // parameters for queries that could return a lot of data... public static final String CURSOR_PARAMETER = "cursor"; public static final String FETCH_LIMIT = "fetchLimit"; // parameter for file downloads -- if we want to have it come down as an // attachment. public static final String PARAM_AS_ATTACHMENT = "as_attachment"; // parameters for data/diff/query APIs. public static final String QUERY_DATA_ETAG = "data_etag"; public static final String QUERY_SEQUENCE_VALUE = "sequence_value"; public static final String QUERY_ACTIVE_ONLY = "active_only"; // parameters for query API public static final String QUERY_START_TIME = "startTime"; public static final String QUERY_END_TIME = "endTime"; /** Timeout (in ms) we specify for each http request */ public static final int HTTP_REQUEST_TIMEOUT_MS = 30 * 1000; /** Path to the file servlet on the Aggregate server. */ private static final String FORWARD_SLASH = "/"; static Map<String, String> mimeMapping; static { Map<String, String> m = new HashMap<String, String>(); m.put("jpeg", "image/jpeg"); m.put("jpg", "image/jpeg"); m.put("png", "image/png"); m.put("gif", "image/gif"); m.put("pbm", "image/x-portable-bitmap"); m.put("ico", "image/x-icon"); m.put("bmp", "image/bmp"); m.put("tiff", "image/tiff"); m.put("mp2", "audio/mpeg"); m.put("mp3", "audio/mpeg"); m.put("wav", "audio/x-wav"); m.put("asf", "video/x-ms-asf"); m.put("avi", "video/x-msvideo"); m.put("mov", "video/quicktime"); m.put("mpa", "video/mpeg"); m.put("mpeg", "video/mpeg"); m.put("mpg", "video/mpeg"); m.put("mp4", "video/mp4"); m.put("qt", "video/quicktime"); m.put("css", "text/css"); m.put("htm", "text/html"); m.put("html", "text/html"); m.put("csv", "text/csv"); m.put("txt", "text/plain"); m.put("log", "text/plain"); m.put("rtf", "application/rtf"); m.put("pdf", "application/pdf"); m.put("zip", "application/zip"); m.put("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); m.put("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"); m.put("pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"); m.put("xml", "application/xml"); m.put("js", "application/x-javascript"); m.put("json", "application/x-javascript"); mimeMapping = m; // Android does not support Runtime delegation. Set it up manually... // force this once... org.apache.wink.common.internal.runtime.RuntimeDelegateImpl rd = new org.apache.wink.common.internal.runtime.RuntimeDelegateImpl(); RuntimeDelegate.setInstance(rd); } private final Context context; private final String appName; private final String odkClientApiVersion; private final String aggregateUri; private String accessToken; private final RestClient tokenRt; private final RestClient rt; private final Map<String, TableResource> resources; /** normalized aggregateUri */ private final URI baseUri; private final WebLogger log; // cookie manager private final CookieManager cm; private final URI normalizeUri(String aggregateUri, String additionalPathPortion) { URI uriBase = URI.create(aggregateUri).normalize(); String term = uriBase.getPath(); if (term.endsWith(FORWARD_SLASH)) { if (additionalPathPortion.startsWith(FORWARD_SLASH)) { term = term.substring(0, term.length() - 1); } } else if (!additionalPathPortion.startsWith(FORWARD_SLASH)) { term = term + FORWARD_SLASH; } term = term + additionalPathPortion; URI uri = uriBase.resolve(term).normalize(); log.d(LOGTAG, "normalizeUri: " + uri.toString()); return uri; } private static final String escapeSegment(String segment) { return segment; // String encoding = CharEncoding.UTF_8; // String encodedSegment; // try { // encodedSegment = URLEncoder.encode(segment, encoding) // .replaceAll("\\+", "%20") // .replaceAll("\\%21", "!") // .replaceAll("\\%27", "'") // .replaceAll("\\%28", "(") // .replaceAll("\\%29", ")") // .replaceAll("\\%7E", "~"); // // } catch (UnsupportedEncodingException e) { // log.printStackTrace(e); // throw new IllegalStateException("Should be able to encode with " + // encoding); // } // return encodedSegment; } /** * Format a file path to be pushed up to aggregate. Essentially escapes the * string as for an html url, but leaves forward slashes. The path must * begin with a forward slash, as if starting at the root directory. * * @return a properly escaped url, with forward slashes remaining. */ private String uriEncodeSegments(String path) { String[] parts = path.split("/"); StringBuilder b = new StringBuilder(); for (int i = 0; i < parts.length; ++i) { if (i != 0) { b.append("/"); } b.append(escapeSegment(parts[i])); } String escaped = b.toString(); return escaped; } private String getTablesUriFragment() { /** * Path to the tables servlet (the one that manages table definitions) * on the Aggregate server. */ return "/odktables/" + escapeSegment(appName) + "/tables/"; } private String getManifestUriFragment() { /** * Path to the tables servlet (the one that manages table definitions) * on the Aggregate server. */ return "/odktables/" + escapeSegment(appName) + "/manifest/" + escapeSegment(odkClientApiVersion) + "/"; } /** * Get the URI for the file servlet on the Aggregate server located at * aggregateUri. * * @param aggregateUri * @return */ private String getFilePathURI() { return "/odktables/" + escapeSegment(appName) + "/files/" + escapeSegment(odkClientApiVersion) + "/"; } /** * Simple Resource for all server interactions. * * @param uri * @return * @throws InvalidAuthTokenException */ private Resource buildBasicResource(URI uri) throws InvalidAuthTokenException { Resource rsc = rt.resource(uri); // report our locale... (not currently used by server) rsc.acceptLanguage(Locale.getDefault()); // set the access token... rsc.header(ApiConstants.ACCEPT_CONTENT_ENCODING_HEADER, ApiConstants.GZIP_CONTENT_ENCODING); rsc.header(ApiConstants.OPEN_DATA_KIT_VERSION_HEADER, ApiConstants.OPEN_DATA_KIT_VERSION); rsc.header(HttpHeaders.USER_AGENT, "Sync " + Sync.getInstance().getVersionCodeString() + " (gzip)"); GregorianCalendar g = new GregorianCalendar(TimeZone.getTimeZone("GMT")); Date now = new Date(); g.setTime(now); SimpleDateFormat formatter = new SimpleDateFormat("E, dd MMM yyyy HH:mm:ss zz", Locale.US); formatter.setCalendar(g); rsc.header(ApiConstants.DATE_HEADER, formatter.format(now)); if (accessToken != null && baseUri != null) { if (uri.getHost().equals(baseUri.getHost()) && uri.getPort() == baseUri.getPort()) { rsc.header("Authorization", "Bearer " + accessToken); } } return rsc; } private Resource buildResource(URI uri, MediaType contentType) throws InvalidAuthTokenException { Resource rsc = buildBasicResource(uri); rsc.contentType(contentType); // set our preferred response media type to json using quality // parameters Map<String, String> mediaTypeParams; // we really like JSON mediaTypeParams = new HashMap<String, String>(); mediaTypeParams.put("q", "1.0"); MediaType json = new MediaType(MediaType.APPLICATION_JSON_TYPE.getType(), MediaType.APPLICATION_JSON_TYPE.getSubtype(), mediaTypeParams); // don't really want plaintext... mediaTypeParams = new HashMap<String, String>(); mediaTypeParams.put("charset", CharEncoding.UTF_8.toLowerCase(Locale.ENGLISH)); mediaTypeParams.put("q", "0.4"); MediaType tplainUtf8 = new MediaType(MediaType.TEXT_PLAIN_TYPE.getType(), MediaType.TEXT_PLAIN_TYPE.getSubtype(), mediaTypeParams); // accept either json or plain text (no XML to device) rsc.accept(json, tplainUtf8); // set the response entity character set to CharEncoding.UTF_8 rsc.header("Accept-Charset", CharEncoding.UTF_8); return rsc; } /** * Simple Resource for file download. * * @param uri * @return * @throws InvalidAuthTokenException */ private Resource buildFileDownloadResource(URI uri) throws InvalidAuthTokenException { Resource rsc = buildBasicResource(uri); return rsc; } private Resource buildResource(URI uri) throws InvalidAuthTokenException { // select our preferred protocol... MediaType protocolType = MediaType.APPLICATION_JSON_TYPE; Resource rsc = buildResource(uri, protocolType); return rsc; } public AggregateSynchronizer(Context context, String appName, String odkApiVersion, String aggregateUri, String accessToken) throws InvalidAuthTokenException { this.context = context; this.appName = appName; this.log = WebLogger.getLogger(appName); this.odkClientApiVersion = odkApiVersion; this.aggregateUri = aggregateUri; log.e(LOGTAG, "AggregateUri:" + aggregateUri); this.baseUri = normalizeUri(aggregateUri, "/"); log.e(LOGTAG, "baseUri:" + baseUri); // This is technically not correct, as we should really have a global // that we manage for this... If there are two or more service threads // running, we could forget other session cookies. But, by creating a // new cookie manager here, we ensure that we don't have any stale // session cookies at the start of each sync. cm = new CookieManager(); CookieHandler.setDefault(cm); // now everything should work... ClientConfig cc; cc = new ClientConfig(); cc.setLoadWinkApplications(false); cc.applications(new ODKClientApplication()); cc.handlers(new GzipHandler(), new ReAuthSecurityHandler(this)); cc.connectTimeout(WebUtils.CONNECTION_TIMEOUT); cc.readTimeout(2 * WebUtils.CONNECTION_TIMEOUT); cc.followRedirects(true); this.rt = new RestClient(cc); cc = new ClientConfig(); cc.setLoadWinkApplications(false); cc.applications(new ODKClientApplication()); cc.connectTimeout(WebUtils.CONNECTION_TIMEOUT); cc.readTimeout(2 * WebUtils.CONNECTION_TIMEOUT); cc.followRedirects(true); this.tokenRt = new RestClient(cc); this.resources = new HashMap<String, TableResource>(); checkAccessToken(accessToken); this.accessToken = accessToken; } private static final String ACCOUNT_TYPE_G = "com.google"; private final static String authString = "oauth2:https://www.googleapis.com/auth/userinfo.email"; public String updateAccessToken() throws InvalidAuthTokenException { AccountManager accountManager = AccountManager.get(context); try { SyncPreferences prefs = new SyncPreferences(context, appName); Account account = new Account(prefs.getAccount(), ACCOUNT_TYPE_G); this.accessToken = accountManager.blockingGetAuthToken(account, authString, true); return accessToken; } catch (Exception e) { e.printStackTrace(); throw new InvalidAuthTokenException("unable to update access token -- please re-authorize"); } } private void checkAccessToken(String accessToken) throws InvalidAuthTokenException { try { @SuppressWarnings("unused") Object responseEntity = tokenRt .resource(TOKEN_INFO + URLEncoder.encode(accessToken, ApiConstants.UTF8_ENCODE)) .get(Object.class); } catch (ClientWebException e) { log.e(LOGTAG, "HttpClientErrorException in checkAccessToken"); Object o = null; try { String entityBody = e.getResponse().getEntity(String.class); o = ODKFileUtils.mapper.readValue(entityBody, Object.class); } catch (Exception e1) { log.printStackTrace(e1); throw new InvalidAuthTokenException( "Unable to parse response from auth token verification (" + e.toString() + ")", e); } if (o != null && o instanceof Map) { @SuppressWarnings("rawtypes") Map m = (Map) o; if (m.containsKey("error")) { throw new InvalidAuthTokenException( "Invalid auth token (" + m.get("error").toString() + "): " + accessToken, e); } else { throw new InvalidAuthTokenException( "Unknown response from auth token verification (" + e.toString() + ")", e); } } } catch (Exception e) { log.e(LOGTAG, "HttpClientErrorException in checkAccessToken"); log.printStackTrace(e); throw new InvalidAuthTokenException("Invalid auth token (): " + accessToken, e); } } @Override public TableResourceList getTables(String webSafeResumeCursor) throws ClientWebException, InvalidAuthTokenException { TableResourceList tableResources; try { String tableFrag = getTablesUriFragment(); tableFrag = tableFrag.substring(0, tableFrag.length() - 1); URI uri = normalizeUri(aggregateUri, tableFrag); if (webSafeResumeCursor == null) { tableResources = buildResource(uri).get(TableResourceList.class); } else { tableResources = buildResource(uri).queryParam(CURSOR_PARAMETER, webSafeResumeCursor) .get(TableResourceList.class); } } catch (ClientWebException e) { log.e(LOGTAG, "Exception while requesting list of tables from server: " + e.toString()); throw e; } return tableResources; } @Override public TableDefinitionResource getTableDefinition(String tableDefinitionUri) throws ClientWebException, InvalidAuthTokenException { URI uri = URI.create(tableDefinitionUri).normalize(); TableDefinitionResource definitionRes = buildResource(uri).get(TableDefinitionResource.class); return definitionRes; } @Override public URI constructTableInstanceFileUri(String tableId, String schemaETag) { // e.g., // https://msundt-test.appspot.com:443/odktables/tables/tables/Milk_bank/ref/uuid:bb26cdaf-9ccf-4a4f-8e28-c114fe30358a/attachments URI instanceFileUri = normalizeUri(aggregateUri, getTablesUriFragment() + tableId + "/ref/" + schemaETag + "/attachments"); return instanceFileUri; } @Override public TableResource createTable(String tableId, String schemaETag, ArrayList<Column> columns) throws ClientWebException, InvalidAuthTokenException { android.os.Debug.waitForDebugger(); // build request URI uri = normalizeUri(aggregateUri, getTablesUriFragment() + tableId); TableDefinition definition = new TableDefinition(tableId, schemaETag, columns); // create table TableResource resource; try { // TODO: we also need to put up the key value store/properties. resource = buildResource(uri).put(new EntityType<TableResource>() { }, definition); } catch (ClientWebException e) { log.e(LOGTAG, "ResourceAccessException in createTable: " + tableId + " exception: " + e.toString()); throw e; } // save resource this.resources.put(resource.getTableId(), resource); return resource; } @Override public void deleteTable(TableResource table) throws ClientWebException, InvalidAuthTokenException { URI uri = URI.create(table.getDefinitionUri()).normalize(); buildResource(uri).delete(); this.resources.remove(table.getTableId()); } @Override public ChangeSetList getChangeSets(TableResource table, String dataETag) throws ClientWebException, InvalidAuthTokenException { String tableId = table.getTableId(); URI uri; Resource resource; uri = normalizeUri(table.getDiffUri(), "/changeSets"); resource = buildResource(uri); if ((table.getDataETag() != null) && dataETag != null) { resource = buildResource(uri).queryParam(QUERY_DATA_ETAG, dataETag); } ChangeSetList changeSets; try { changeSets = resource.get(ChangeSetList.class); return changeSets; } catch (ClientWebException e) { log.e(LOGTAG, "Exception while requesting list of changeSets from server: " + tableId + " exception: " + e.toString()); throw e; } } @Override public RowResourceList getChangeSet(TableResource table, String dataETag, boolean activeOnly, String websafeResumeCursor) throws ClientWebException, InvalidAuthTokenException { String tableId = table.getTableId(); URI uri; Resource resource; if ((table.getDataETag() == null) || dataETag == null) { throw new IllegalArgumentException("dataETag cannot be null!"); } uri = normalizeUri(table.getDiffUri(), "/changeSets/" + dataETag); resource = buildResource(uri); if (activeOnly) { resource = resource.queryParam(QUERY_ACTIVE_ONLY, "true"); } // and apply the cursor... if (websafeResumeCursor != null) { resource = resource.queryParam(CURSOR_PARAMETER, websafeResumeCursor); } RowResourceList rows; try { rows = resource.get(RowResourceList.class); return rows; } catch (ClientWebException e) { log.e(LOGTAG, "Exception while requesting changeSet rows from server: " + tableId + " exception: " + e.toString()); throw e; } } @Override public RowResourceList getUpdates(TableResource table, String dataETag, String websafeResumeCursor) throws ClientWebException, InvalidAuthTokenException { String tableId = table.getTableId(); URI uri; Resource resource; if ((table.getDataETag() == null) || dataETag == null) { uri = URI.create(table.getDataUri()); resource = buildResource(uri); } else { uri = URI.create(table.getDiffUri()); resource = buildResource(uri).queryParam(QUERY_DATA_ETAG, dataETag); } // and apply the cursor... if (websafeResumeCursor != null) { resource = resource.queryParam(CURSOR_PARAMETER, websafeResumeCursor); } RowResourceList rows; try { rows = resource.get(RowResourceList.class); return rows; } catch (ClientWebException e) { log.e(LOGTAG, "Exception while requesting list of rows from server: " + tableId + " exception: " + e.toString()); throw e; } } @Override public RowOutcomeList alterRows(TableResource resource, List<SyncRow> rowsToInsertUpdateOrDelete) throws ClientWebException, InvalidAuthTokenException { ArrayList<Row> rows = new ArrayList<Row>(); for (SyncRow rowToAlter : rowsToInsertUpdateOrDelete) { Row row = Row.forUpdate(rowToAlter.getRowId(), rowToAlter.getRowETag(), rowToAlter.getFormId(), rowToAlter.getLocale(), rowToAlter.getSavepointType(), rowToAlter.getSavepointTimestamp(), rowToAlter.getSavepointCreator(), rowToAlter.getFilterScope(), rowToAlter.getValues()); row.setDeleted(rowToAlter.isDeleted()); rows.add(row); } RowList rlist = new RowList(rows, resource.getDataETag()); URI uri = URI.create(resource.getDataUri()); RowOutcomeList outcomes; try { outcomes = buildResource(uri).put(new EntityType<RowOutcomeList>() { }, rlist); } catch (ClientWebException e) { log.e(LOGTAG, "Exception while updating rows on server: " + resource.getTableId() + " exception: " + e.toString()); throw e; } return outcomes; } private static List<String> filterOutTableIdAssetFiles(List<String> relativePaths) { List<String> newList = new ArrayList<String>(); for (String relativePath : relativePaths) { if (relativePath.startsWith("assets/csv/")) { // by convention, the files here begin with their identifying // tableId continue; } else { newList.add(relativePath); } } return newList; } /** * Remove all assets/*.init files (e.g., tables.init) that only alter the * data tables of the application. These are not needed on the server * because their actions have already caused changes in the data tables that * will be shared across all devices. I.e., they only need to be executed on * the one starter device, and then everything gets replicated everywhere * else. * * @param relativePaths * @return */ private static List<String> filterOutAssetInitFiles(List<String> relativePaths) { List<String> newList = new ArrayList<String>(); for (String relativePath : relativePaths) { if (relativePath.equals("assets/tables.init")) { continue; } else { newList.add(relativePath); } } return newList; } private static List<String> filterInTableIdFiles(List<String> relativePaths, String tableId) { List<String> newList = new ArrayList<String>(); for (String relativePath : relativePaths) { if (relativePath.startsWith("assets/csv/")) { // by convention, the files here begin with their identifying // tableId String[] parts = relativePath.split("/"); if (parts.length >= 3) { String[] nameElements = parts[2].split("\\."); if (nameElements[0].equals(tableId)) { newList.add(relativePath); } } } } return newList; } /** * Get all the files under the given folder, excluding those directories * that are the concatenation of folder and a member of excluding. If the * member of excluding is a directory, none of its children will be synched * either. * <p> * If the folder doesn't exist it returns an empty list. * <p> * If the file exists but is not a directory, logs an error and returns an * empty list. * * @param folder * @param excluding * can be null--nothing will be excluded. Should be relative to * the given folder. * @param relativeTo * the path to which the returned paths will be relative. A null * value makes them relative to the folder parameter. If it is * non null, folder must start with relativeTo, or else the files * in folder could not possibly be relative to relativeTo. In * this case will throw an IllegalArgumentException. * @return the relative paths of the files under the folder--i.e. the paths * after the folder parameter, not including the first separator * @throws IllegalArgumentException * if relativeTo is not a substring of folder. */ private List<String> getAllFilesUnderFolder(File baseFolder, final Set<String> excludingNamedItemsUnderFolder) { String appName = ODKFileUtils.extractAppNameFromPath(baseFolder); // Return an empty list of the folder doesn't exist or is not a // directory if (!baseFolder.exists()) { return new ArrayList<String>(); } else if (!baseFolder.isDirectory()) { log.e(LOGTAG, "[getAllFilesUnderFolder] folder is not a directory: " + baseFolder.getAbsolutePath()); return new ArrayList<String>(); } // construct the set of starting directories and files to process File[] partials = baseFolder.listFiles(new FileFilter() { @Override public boolean accept(File pathname) { if (excludingNamedItemsUnderFolder == null) { return true; } else { return !excludingNamedItemsUnderFolder.contains(pathname.getName()); } } }); if (partials == null) { return Collections.emptyList(); } LinkedList<File> unexploredDirs = new LinkedList<File>(); List<File> nondirFiles = new ArrayList<File>(); // copy the starting set into a queue of unexploredDirs // and a list of files to be sync'd for (int i = 0; i < partials.length; ++i) { if (partials[i].isDirectory()) { unexploredDirs.add(partials[i]); } else { nondirFiles.add(partials[i]); } } while (!unexploredDirs.isEmpty()) { File exploring = unexploredDirs.removeFirst(); File[] files = exploring.listFiles(); for (File f : files) { if (f.isDirectory()) { // we'll need to explore it unexploredDirs.add(f); } else { // we'll add it to our list of files. nondirFiles.add(f); } } } List<String> relativePaths = new ArrayList<String>(); // we want the relative path, so drop the necessary bets. for (File f : nondirFiles) { // +1 to exclude the separator. relativePaths.add(ODKFileUtils.asRelativePath(appName, f)); } return relativePaths; } @Override public boolean syncAppLevelFiles(boolean pushLocalFiles, String serverReportedAppLevelETag, SynchronizerStatus syncStatus) throws ClientWebException, InvalidAuthTokenException { // Get the app-level files on the server. syncStatus.updateNotification(SyncProgressState.APP_FILES, R.string.getting_app_level_manifest, null, 1.0, false); List<OdkTablesFileManifestEntry> manifest = getAppLevelFileManifest(pushLocalFiles, serverReportedAppLevelETag); if (manifest == null) { log.i(LOGTAG, "no change in app-leve manifest -- skipping!"); // short-circuited -- no change in manifest syncStatus.updateNotification(SyncProgressState.APP_FILES, R.string.getting_app_level_manifest, null, 100.0, false); return true; } // Get the app-level files on our device. Set<String> dirsToExclude = ODKFileUtils.getDirectoriesToExcludeFromSync(true); File appFolder = new File(ODKFileUtils.getAppFolder(appName)); List<String> relativePathsOnDevice = getAllFilesUnderFolder(appFolder, dirsToExclude); relativePathsOnDevice = filterOutTableIdAssetFiles(relativePathsOnDevice); relativePathsOnDevice = filterOutAssetInitFiles(relativePathsOnDevice); boolean success = true; double stepSize = 100.0 / (1 + relativePathsOnDevice.size() + manifest.size()); int stepCount = 1; if (pushLocalFiles) { // if we are pushing, we want to push the local files that are // different // up to the server, then remove the files on the server that are // not // in the local set. List<String> serverFilesToDelete = new ArrayList<String>(); for (OdkTablesFileManifestEntry entry : manifest) { File localFile = ODKFileUtils.asAppFile(appName, entry.filename); if (!localFile.exists() || !localFile.isFile()) { // we need to delete this file from the server. serverFilesToDelete.add(entry.filename); } else if (ODKFileUtils.getMd5Hash(appName, localFile).equals(entry.md5hash)) { // we are ok -- no need to upload or delete relativePathsOnDevice.remove(entry.filename); } } // this is the actual step size when we are pushing... stepSize = 100.0 / (1 + relativePathsOnDevice.size() + serverFilesToDelete.size()); for (String relativePath : relativePathsOnDevice) { syncStatus.updateNotification(SyncProgressState.APP_FILES, R.string.uploading_local_file, new Object[] { relativePath }, stepCount * stepSize, false); File localFile = ODKFileUtils.asAppFile(appName, relativePath); String wholePathToFile = localFile.getAbsolutePath(); if (!uploadFile(wholePathToFile, relativePath)) { success = false; log.e(LOGTAG, "Unable to upload file to server: " + relativePath); } ++stepCount; } for (String relativePath : serverFilesToDelete) { syncStatus.updateNotification(SyncProgressState.APP_FILES, R.string.deleting_file_on_server, new Object[] { relativePath }, stepCount * stepSize, false); if (!deleteFile(relativePath)) { success = false; log.e(LOGTAG, "Unable to delete file on server: " + relativePath); } ++stepCount; } } else { // if we are pulling, we want to pull the server files that are // different // down from the server, then remove the local files that are not // present // on the server. for (OdkTablesFileManifestEntry entry : manifest) { syncStatus.updateNotification(SyncProgressState.APP_FILES, R.string.verifying_local_file, new Object[] { entry.filename }, stepCount * stepSize, false); // make sure our copy is current compareAndDownloadFile(null, entry); // remove it from the set of app-level files we found before the // sync relativePathsOnDevice.remove(entry.filename); // this is the corrected step size based upon matching files stepSize = 100.0 / (1 + relativePathsOnDevice.size() + manifest.size()); ++stepCount; } for (String relativePath : relativePathsOnDevice) { syncStatus.updateNotification(SyncProgressState.APP_FILES, R.string.deleting_local_file, new Object[] { relativePath }, stepCount * stepSize, false); // and remove any remaining files, as these do not match // anything on // the server. File f = ODKFileUtils.asAppFile(appName, relativePath); if (!f.delete()) { success = false; log.e(LOGTAG, "Unable to delete " + f.getAbsolutePath()); } ++stepCount; } } return success; } private String determineContentType(String fileName) { int ext = fileName.lastIndexOf('.'); if (ext == -1) { return "application/octet-stream"; } String type = fileName.substring(ext + 1); String mimeType = mimeMapping.get(type); if (mimeType == null) { return "application/octet-stream"; } return mimeType; } @Override public void syncTableLevelFiles(String tableId, String serverReportedTableLevelETag, OnTablePropertiesChanged onChange, boolean pushLocalFiles, SynchronizerStatus syncStatus) throws ClientWebException, InvalidAuthTokenException { android.os.Debug.waitForDebugger(); syncStatus.updateNotification(SyncProgressState.TABLE_FILES, R.string.getting_table_manifest, new Object[] { tableId }, 1.0, false); // get the table files on the server List<OdkTablesFileManifestEntry> manifest = getTableLevelFileManifest(tableId, serverReportedTableLevelETag, pushLocalFiles); if (manifest == null) { log.i(LOGTAG, "no change in table manifest -- skipping!"); // short-circuit because our files should match those on the server syncStatus.updateNotification(SyncProgressState.TABLE_FILES, R.string.getting_table_manifest, new Object[] { tableId }, 100.0, false); return; } String tableIdPropertiesFile = "tables" + File.separator + tableId + File.separator + "properties.csv"; boolean tablePropertiesChanged = false; // Get any assets/csv files that begin with tableId Set<String> dirsToExclude = new HashSet<String>(); File assetsCsvFolder = new File(ODKFileUtils.getAssetsFolder(appName) + "/csv"); List<String> relativePathsToTableIdAssetsCsvOnDevice = getAllFilesUnderFolder(assetsCsvFolder, dirsToExclude); relativePathsToTableIdAssetsCsvOnDevice = filterInTableIdFiles(relativePathsToTableIdAssetsCsvOnDevice, tableId); // We don't want to sync anything in the instances directory, because // this // contains things like media attachments. File tableFolder = new File(ODKFileUtils.getTablesFolder(appName, tableId)); dirsToExclude.add(ODKFileUtils.INSTANCES_FOLDER_NAME); List<String> relativePathsOnDevice = getAllFilesUnderFolder(tableFolder, dirsToExclude); // mix in the assets files for this tableId, if any... relativePathsOnDevice.addAll(relativePathsToTableIdAssetsCsvOnDevice); double stepSize = 100.0 / (1 + relativePathsOnDevice.size() + manifest.size()); int stepCount = 1; if (pushLocalFiles) { // if we are pushing, we want to push the local files that are // different // up to the server, then remove the files on the server that are // not // in the local set. List<String> serverFilesToDelete = new ArrayList<String>(); for (OdkTablesFileManifestEntry entry : manifest) { File localFile = ODKFileUtils.asAppFile(appName, entry.filename); if (!localFile.exists() || !localFile.isFile()) { // we need to delete this file from the server. serverFilesToDelete.add(entry.filename); } else if (ODKFileUtils.getMd5Hash(appName, localFile).equals(entry.md5hash)) { // we are ok -- no need to upload or delete relativePathsOnDevice.remove(entry.filename); } } // this is the actual step size when we are pushing... stepSize = 100.0 / (1 + relativePathsOnDevice.size() + serverFilesToDelete.size()); boolean success = true; for (String relativePath : relativePathsOnDevice) { syncStatus.updateNotification(SyncProgressState.TABLE_FILES, R.string.uploading_local_file, new Object[] { relativePath }, stepCount * stepSize, false); File localFile = ODKFileUtils.asAppFile(appName, relativePath); String wholePathToFile = localFile.getAbsolutePath(); if (!uploadFile(wholePathToFile, relativePath)) { success = false; log.e(LOGTAG, "Unable to upload file to server: " + relativePath); } ++stepCount; } for (String relativePath : serverFilesToDelete) { syncStatus.updateNotification(SyncProgressState.TABLE_FILES, R.string.deleting_file_on_server, new Object[] { relativePath }, stepCount * stepSize, false); if (!deleteFile(relativePath)) { success = false; log.e(LOGTAG, "Unable to delete file on server: " + relativePath); } ++stepCount; } if (!success) { log.i(LOGTAG, "unable to delete one or more files!"); } } else { // if we are pulling, we want to pull the server files that are // different // down from the server, then remove the local files that are not // present // on the server. for (OdkTablesFileManifestEntry entry : manifest) { syncStatus.updateNotification(SyncProgressState.TABLE_FILES, R.string.verifying_local_file, new Object[] { entry.filename }, stepCount * stepSize, false); // make sure our copy is current boolean outcome = compareAndDownloadFile(tableId, entry); // and if it was the table properties file, remember whether it // changed. if (entry.filename.equals(tableIdPropertiesFile)) { tablePropertiesChanged = outcome; } // remove it from the set of app-level files we found before the // sync relativePathsOnDevice.remove(entry.filename); // this is the corrected step size based upon matching files stepSize = 100.0 / (1 + relativePathsOnDevice.size() + manifest.size()); ++stepCount; } boolean success = true; for (String relativePath : relativePathsOnDevice) { syncStatus.updateNotification(SyncProgressState.TABLE_FILES, R.string.deleting_local_file, new Object[] { relativePath }, stepCount * stepSize, false); // and remove any remaining files, as these do not match // anything on // the server. File f = ODKFileUtils.asAppFile(appName, relativePath); if (!f.delete()) { success = false; log.e(LOGTAG, "Unable to delete " + f.getAbsolutePath()); } ++stepCount; } if (tablePropertiesChanged && (onChange != null)) { // update this table's KVS values... onChange.onTablePropertiesChanged(tableId); } if (!success) { log.i(LOGTAG, "unable to delete one or more files!"); } // should we return our status? } } public List<OdkTablesFileManifestEntry> getAppLevelFileManifest(boolean pushLocalFiles, String serverReportedAppLevelETag) throws ClientWebException, InvalidAuthTokenException { SyncETagsUtils seu = new SyncETagsUtils(); URI fileManifestUri = normalizeUri(aggregateUri, getManifestUriFragment()); String eTag = seu.getManifestSyncETag(context, appName, fileManifestUri, null); // android.os.Debug.waitForDebugger(); Resource rsc = buildResource(fileManifestUri); // don't short-circuit manifest if we are pushing local files, // as we need to know exactly what is on the server to minimize // transmissions of files being pushed up to the server. if (!pushLocalFiles && eTag != null) { rsc.header(HttpHeaders.IF_NONE_MATCH, eTag); if (serverReportedAppLevelETag != null && serverReportedAppLevelETag.equals(eTag)) { // no change -- we can skip the request to the server return null; } } ClientResponse rsp = rsc.get(); if (rsp.getStatusCode() == HttpStatus.SC_NOT_MODIFIED) { // signal this by returning null; return null; } if (rsp.getStatusCode() < 200 || rsp.getStatusCode() >= 300) { throw new ClientWebException(null, rsp); } if (!rsp.getHeaders().containsKey(ApiConstants.OPEN_DATA_KIT_VERSION_HEADER)) { throw new ClientWebException(null, rsp); } // retrieve the manifest... OdkTablesFileManifest manifest; manifest = rsp.getEntity(OdkTablesFileManifest.class); List<OdkTablesFileManifestEntry> theList = null; if (manifest != null) { theList = manifest.getFiles(); } if (theList == null) { theList = Collections.emptyList(); } // update the manifest ETag record... eTag = rsp.getHeaders().getFirst(HttpHeaders.ETAG); seu.updateManifestSyncETag(context, appName, fileManifestUri, null, eTag); // and return the list of values... return theList; } public List<OdkTablesFileManifestEntry> getTableLevelFileManifest(String tableId, String serverReportedTableLevelETag, boolean pushLocalFiles) throws ClientWebException, InvalidAuthTokenException { SyncETagsUtils seu = new SyncETagsUtils(); URI fileManifestUri = normalizeUri(aggregateUri, getManifestUriFragment() + tableId); String eTag = seu.getManifestSyncETag(context, appName, fileManifestUri, tableId); Resource rsc = buildResource(fileManifestUri); // don't short-circuit manifest if we are pushing local files, // as we need to know exactly what is on the server to minimize // transmissions of files being pushed up to the server. if (!pushLocalFiles && eTag != null) { rsc.header(HttpHeaders.IF_NONE_MATCH, eTag); if (serverReportedTableLevelETag != null && serverReportedTableLevelETag.equals(eTag)) { // no change -- we can skip the request to the server return null; } } ClientResponse rsp = rsc.get(); if (rsp.getStatusCode() == HttpStatus.SC_NOT_MODIFIED) { // signal this by returning null; return null; } if (rsp.getStatusCode() < 200 || rsp.getStatusCode() >= 300) { throw new ClientWebException(null, rsp); } if (!rsp.getHeaders().containsKey(ApiConstants.OPEN_DATA_KIT_VERSION_HEADER)) { throw new ClientWebException(null, rsp); } // retrieve the manifest... OdkTablesFileManifest manifest; manifest = rsp.getEntity(OdkTablesFileManifest.class); List<OdkTablesFileManifestEntry> theList = null; if (manifest != null) { theList = manifest.getFiles(); } if (theList == null) { theList = Collections.emptyList(); } // update the manifest ETag record... eTag = rsp.getHeaders().getFirst(HttpHeaders.ETAG); seu.updateManifestSyncETag(context, appName, fileManifestUri, tableId, eTag); // and return the list of values... return theList; } private boolean deleteFile(String pathRelativeToAppFolder) throws ClientWebException, InvalidAuthTokenException { String escapedPath = uriEncodeSegments(pathRelativeToAppFolder); URI filesUri = normalizeUri(aggregateUri, getFilePathURI() + escapedPath); log.i(LOGTAG, "[deleteFile] fileDeleteUri: " + filesUri.toString()); buildResource(filesUri).delete(); // TODO: verify whether or not this worked. return true; } private boolean uploadFile(String wholePathToFile, String pathRelativeToAppFolder) throws InvalidAuthTokenException { File file = new File(wholePathToFile); String escapedPath = uriEncodeSegments(pathRelativeToAppFolder); URI filesUri = normalizeUri(aggregateUri, getFilePathURI() + escapedPath); log.i(LOGTAG, "[uploadFile] filePostUri: " + filesUri.toString()); String ct = determineContentType(file.getName()); MediaType contentType = MediaType.valueOf(ct); // android.os.Debug.waitForDebugger(); ClientResponse response = buildResource(filesUri, contentType).post(file); if (response.getStatusCode() < 200 || response.getStatusCode() >= 300) { return false; } if (!response.getHeaders().containsKey(ApiConstants.OPEN_DATA_KIT_VERSION_HEADER)) { return false; } return true; } private boolean uploadInstanceFile(File file, URI instanceFileUri) throws InvalidAuthTokenException { log.i(LOGTAG, "[uploadFile] filePostUri: " + instanceFileUri.toString()); String ct = determineContentType(file.getName()); MediaType contentType = MediaType.valueOf(ct); // android.os.Debug.waitForDebugger(); ClientResponse response = buildResource(instanceFileUri, contentType).post(file); if (response.getStatusCode() < 200 || response.getStatusCode() >= 300) { return false; } if (!response.getHeaders().containsKey(ApiConstants.OPEN_DATA_KIT_VERSION_HEADER)) { return false; } return true; } /** * Get the URI to which to post in order to upload the file. * * @param pathRelativeToAppFolder * @return */ public URI getFilePostUri(String appName, String pathRelativeToAppFolder) { String escapedPath = uriEncodeSegments(pathRelativeToAppFolder); URI filesUri = normalizeUri(aggregateUri, getFilePathURI() + escapedPath); return filesUri; } /** * * @param entry * @return */ private boolean compareAndDownloadFile(String tableId, OdkTablesFileManifestEntry entry) { String basePath = ODKFileUtils.getAppFolder(appName); SyncETagsUtils seu = new SyncETagsUtils(); // if the file is a placeholder on the server, then don't do anything... if (entry.contentLength == 0) { return false; } // now we need to look through the manifest and see where the files are // supposed to be stored. Make sure you don't return a bad string. if (entry.filename == null || entry.filename.equals("")) { log.i(LOGTAG, "returned a null or empty filename"); return false; } else { URI uri = null; URL urlFile = null; try { log.i(LOGTAG, "[downloadFile] downloading at url: " + entry.downloadUrl); urlFile = new URL(entry.downloadUrl); uri = urlFile.toURI(); } catch (MalformedURLException e) { log.e(LOGTAG, e.toString()); log.printStackTrace(e); return false; } catch (URISyntaxException e) { log.e(LOGTAG, e.toString()); log.printStackTrace(e); return false; } // filename is the unrooted path of the file, so prepend the // basepath. String path = basePath + File.separator + entry.filename; // Before we try dl'ing the file, we have to make the folder, // b/c otherwise if the folders down to the path have too many non- // existent folders, we'll get a FileNotFoundException when we open // the FileOutputStream. File newFile = new File(path); String folderPath = newFile.getParent(); ODKFileUtils.createFolder(folderPath); if (!newFile.exists()) { // the file doesn't exist on the system // filesToDL.add(newFile); try { int statusCode = downloadFile(newFile, uri); if (statusCode == HttpStatus.SC_OK) { seu.updateFileSyncETag(context, appName, uri, tableId, newFile.lastModified(), entry.md5hash); return true; } else { return false; } } catch (Exception e) { log.printStackTrace(e); log.e(LOGTAG, "trouble downloading file for first time"); return false; } } else { boolean hasUpToDateEntry = true; String md5hash = seu.getFileSyncETag(context, appName, uri, tableId, newFile.lastModified()); if (md5hash == null) { // file exists, but no record of what is on the server // compute local value hasUpToDateEntry = false; md5hash = ODKFileUtils.getMd5Hash(appName, newFile); } // so as it comes down from the manifest, the md5 hash includes // a // "md5:" prefix. Add that and then check. if (!md5hash.equals(entry.md5hash)) { hasUpToDateEntry = false; // it's not up to date, we need to download it. try { int statusCode = downloadFile(newFile, uri); if (statusCode == HttpStatus.SC_OK || statusCode == HttpStatus.SC_NOT_MODIFIED) { seu.updateFileSyncETag(context, appName, uri, tableId, newFile.lastModified(), md5hash); return true; } else { return false; } } catch (Exception e) { log.printStackTrace(e); // TODO throw correct exception log.e(LOGTAG, "trouble downloading new version of existing file"); return false; } } else { if (!hasUpToDateEntry) { seu.updateFileSyncETag(context, appName, uri, tableId, newFile.lastModified(), md5hash); } // no change return false; } } } } /** * * @param destFile * @param downloadUrl * @return true if the download was successful * @throws Exception */ private int downloadFile(File destFile, URI downloadUrl) throws Exception { // WiFi network connections can be renegotiated during a large form // download // sequence. // This will cause intermittent download failures. Silently retry once // after // each // failure. Only if there are two consecutive failures, do we abort. boolean success = false; int attemptCount = 0; while (!success && attemptCount++ <= 2) { Resource resource = buildFileDownloadResource(downloadUrl); if (destFile.exists()) { String md5Hash = ODKFileUtils.getMd5Hash(appName, destFile); resource.header(HttpHeaders.IF_NONE_MATCH, md5Hash); } ClientResponse response = null; try { response = resource.get(); int statusCode = response.getStatusCode(); if (statusCode != HttpStatus.SC_OK) { response.consumeContent(); if (statusCode == HttpStatus.SC_UNAUTHORIZED) { // clear the cookies -- should not be necessary? // ss: might just be a collect thing? } log.w(LOGTAG, "downloading " + downloadUrl.toString() + " returns " + statusCode); return statusCode; } if (!response.getHeaders().containsKey(ApiConstants.OPEN_DATA_KIT_VERSION_HEADER)) { response.consumeContent(); log.w(LOGTAG, "downloading " + downloadUrl.toString() + " appears to have been redirected."); return 302; } File tmp = new File(destFile.getParentFile(), destFile.getName() + ".tmp"); int totalLen = 0; InputStream is = null; BufferedOutputStream os = null; try { // open the InputStream of the (uncompressed) entity body... is = response.getEntity(InputStream.class); os = new BufferedOutputStream(new FileOutputStream(tmp)); // write connection to temporary file byte buf[] = new byte[8192]; int len; while ((len = is.read(buf, 0, buf.length)) >= 0) { if (len != 0) { totalLen += len; os.write(buf, 0, len); } } is.close(); is = null; os.flush(); os.close(); os = null; success = tmp.renameTo(destFile); } catch (Exception e) { // most likely a socket timeout e.printStackTrace(); log.e(LOGTAG, "downloading " + downloadUrl.toString() + " failed after " + totalLen + " bytes: " + e.toString()); try { // signal to the framework that this socket is hosed. // with the various nested streams, this may not work... is.reset(); } catch (Exception ex) { // ignore } throw e; } finally { if (os != null) { try { os.close(); } catch (Exception e) { // no-op } } if (is != null) { try { // ensure stream is consumed... byte buf[] = new byte[8192]; while (is.read(buf) >= 0) ; } catch (Exception e) { // no-op } try { is.close(); } catch (Exception e) { // no-op } } if (tmp.exists()) { tmp.delete(); } response.consumeContent(); } } catch (ClientWebException e) { log.printStackTrace(e); if (response != null) { response.consumeContent(); } if (attemptCount != 1) { throw e; } } } return HttpStatus.SC_OK; } private static final class CommonFileAttachmentTerms { File localFile; URI instanceFileDownloadUri; } private CommonFileAttachmentTerms computeCommonFileAttachmentTerms(String instanceFileUri, String instanceId, File instanceFolder, String relativePath) { // clean up the value... if (relativePath.startsWith("/")) { relativePath = relativePath.substring(1); } String relativeInstanceFolderPath = ODKFileUtils.asRelativePath(appName, instanceFolder); File localFile; if (relativePath.startsWith(relativeInstanceFolderPath)) { log.w(LOGTAG, "rowpath contains full app-relative path!"); localFile = ODKFileUtils.getAsFile(appName, relativePath); } else { localFile = new File(instanceFolder, relativePath); } String baseInstanceFolder = instanceFolder.getAbsolutePath(); String baseLocalAttachment = localFile.getAbsolutePath(); if (!baseLocalAttachment.startsWith(baseInstanceFolder)) { throw new IllegalStateException("instance data file is not within the instances tree!"); } String partialValue = baseLocalAttachment.substring(baseInstanceFolder.length()); if (partialValue.startsWith("/")) { partialValue = partialValue.substring(1); } URI instanceFileDownloadUri = normalizeUri(instanceFileUri, instanceId + "/file/" + partialValue); CommonFileAttachmentTerms cat = new CommonFileAttachmentTerms(); cat.localFile = localFile; cat.instanceFileDownloadUri = instanceFileDownloadUri; return cat; } @Override public boolean getFileAttachments(String instanceFileUri, String tableId, SyncRowPending serverRow, boolean deferInstanceAttachments) throws ClientWebException { if (serverRow.getUriFragments().isEmpty()) { this.log.w("getFileAttachments", "logic error - probably from conflict mgmt - should never get here!"); return true; } SyncETagsUtils seu = new SyncETagsUtils(); /********************************************** * * * * * With the new mechanism, we can directly fetch any file from the * server in the fileAttachmentColumns. And we can include any md5Hash * we might have of the local file. This would enable the server to say * "yes, it is identical". */ boolean success = true; try { // 1) Get this row's instanceId (rowId) String instanceId = serverRow.getRowId(); // 2) Get the folder holding the instance attachments File instanceFolder = new File(ODKFileUtils.getInstanceFolder(appName, tableId, instanceId)); // 3) get all the files in that folder... List<String> relativePathsToAppFolderOnDevice = getAllFilesUnderFolder(instanceFolder, null); // 4) Iterate over all non-null file attachments in the data row for (String relativePath : serverRow.getUriFragments()) { // clean up the value... if (relativePath.startsWith("/")) { relativePath = relativePath.substring(1); } CommonFileAttachmentTerms cat = computeCommonFileAttachmentTerms(instanceFileUri, instanceId, instanceFolder, relativePath); String appFolderRelativePath = ODKFileUtils.asRelativePath(appName, cat.localFile); // remove it from the local files list relativePathsToAppFolderOnDevice.remove(appFolderRelativePath); if (!cat.localFile.exists()) { if (deferInstanceAttachments) { return false; } int statusCode = downloadFile(cat.localFile, cat.instanceFileDownloadUri); if (statusCode == HttpStatus.SC_OK || statusCode == HttpStatus.SC_NOT_MODIFIED) { String md5Hash = ODKFileUtils.getMd5Hash(appName, cat.localFile); seu.updateFileSyncETag(context, appName, cat.instanceFileDownloadUri, tableId, cat.localFile.lastModified(), md5Hash); } else { success = false; } } else { // assume that if the local file exists, it matches exactly // the // content on the server. // this could be inaccurate if there are buggy programs on // the // device! String md5hash = seu.getFileSyncETag(context, appName, cat.instanceFileDownloadUri, tableId, cat.localFile.lastModified()); if (md5hash == null) { md5hash = ODKFileUtils.getMd5Hash(appName, cat.localFile); seu.updateFileSyncETag(context, appName, cat.instanceFileDownloadUri, tableId, cat.localFile.lastModified(), md5hash); } } } // we usually do this, but, when we have a conflict row, we pull the // server files down, and leave the local files. Upon the next sync, // we will resolve what to do and clean up. if (serverRow.shouldDeleteExtraneousLocalFiles()) { for (String relativePath : relativePathsToAppFolderOnDevice) { // remove local files that are not on server... File localFile = ODKFileUtils.asAppFile(appName, relativePath); if (!localFile.delete()) { success = false; } } } return success; } catch (ClientWebException e) { log.e(LOGTAG, "Exception while getting attachment: " + e.toString()); throw e; } catch (Exception e) { log.printStackTrace(e); return false; } /****************************************************** * End of the new file attachment mechanism... * * * * * */ } @Override public boolean putFileAttachments(String instanceFileUri, String tableId, SyncRowPending localRow, boolean deferInstanceAttachments) throws ClientWebException { if (localRow.getUriFragments().isEmpty()) { this.log.w("putFileAttachments", "logic error - probably from conflict mgmt - should never get here!"); return true; } /********************************************** * * * * * With the new mechanism, we can directly fetch any file from the * server in the fileAttachmentColumns. For PUT, we don't know if the * local file exists on the server, so we need to PUT every attachment. * This can be done by retrieving the manifest, and comparing that * against the local directory, or we can issue an if-none-match GET * request for each file we need. If we do not get a NOT_MODIFIED * return, then we upload it. */ boolean success = true; try { // 1) Get this row's instanceId (rowId) String instanceId = localRow.getRowId(); // 2) Get the folder holding the instance attachments File instanceFolder = new File(ODKFileUtils.getInstanceFolder(appName, tableId, instanceId)); // 3) Iterate over all non-null file attachments in the data row for (String relativePath : localRow.getUriFragments()) { // clean up the value... if (relativePath.startsWith("/")) { relativePath = relativePath.substring(1); } CommonFileAttachmentTerms cat = computeCommonFileAttachmentTerms(instanceFileUri, instanceId, instanceFolder, relativePath); if (cat.localFile.exists()) { // issue a GET. If the return is NOT_MODIFIED, then we don't // need to // POST it. int statusCode = downloadFile(cat.localFile, cat.instanceFileDownloadUri); if (statusCode == HttpStatus.SC_NOT_MODIFIED) { // no-op... what is on server matches local. } else if (statusCode == HttpStatus.SC_OK) { // The test for ODK header ensures we detect wifi login // overlays // this should not happen -- indicates something is // corrupted. log.e(LOGTAG, "Unexpectedly overwriting attachment: " + cat.instanceFileDownloadUri.toString()); } else if (statusCode == HttpStatus.SC_NOT_FOUND || statusCode == HttpStatus.SC_NO_CONTENT) { if (deferInstanceAttachments) { return false; } // upload it... boolean outcome = uploadInstanceFile(cat.localFile, cat.instanceFileDownloadUri); if (!outcome) { success = false; } } else { success = false; } } else { // we will pull these files later during a // getFileAttachments() call, // if needed... } } return success; } catch (ClientWebException e) { log.e(LOGTAG, "Exception while putting attachment: " + e.toString()); throw e; } catch (Exception e) { log.e(LOGTAG, "Exception during sync: " + e.toString()); return false; } /****************************************************** * End of the new file attachment mechanism... * * * * * */ } }