speed-up synchronization (ETag and Last-Modified)

This commit is contained in:
korelstar 2017-05-26 09:36:48 +02:00
parent d6ac2cc6e6
commit 2e7ab5a857
5 changed files with 226 additions and 92 deletions

View file

@ -39,6 +39,8 @@ public class SettingsActivity extends AppCompatActivity {
public static final String SETTINGS_URL = "settingsUrl";
public static final String SETTINGS_USERNAME = "settingsUsername";
public static final String SETTINGS_PASSWORD = "settingsPassword";
public static final String SETTINGS_KEY_ETAG = "notes_last_etag";
public static final String SETTINGS_KEY_LAST_MODIFIED = "notes_last_modified";
public static final String DEFAULT_SETTINGS = "";
public static final int CREDENTIALS_CHANGED = 3;
@ -258,6 +260,8 @@ public class SettingsActivity extends AppCompatActivity {
editor.putString(SETTINGS_URL, url);
editor.putString(SETTINGS_USERNAME, username);
editor.putString(SETTINGS_PASSWORD, password);
editor.remove(SETTINGS_KEY_ETAG);
editor.remove(SETTINGS_KEY_LAST_MODIFIED);
editor.apply();
final Intent data = new Intent();

View file

@ -256,9 +256,9 @@ public class NoteSQLiteOpenHelper extends SQLiteOpenHelper {
public void debugPrintFullDB() {
List<DBNote> notes = getNotesCustom("", new String[]{}, default_order);
Log.d(getClass().getSimpleName(), "Full Database ("+notes.size()+" notes):");
Log.v(getClass().getSimpleName(), "Full Database ("+notes.size()+" notes):");
for (DBNote note : notes) {
Log.d(getClass().getSimpleName(), " "+note);
Log.v(getClass().getSimpleName(), " "+note);
}
}

View file

@ -35,6 +35,7 @@ import it.niedermann.owncloud.notes.model.DBStatus;
import it.niedermann.owncloud.notes.util.ICallback;
import it.niedermann.owncloud.notes.util.NotesClient;
import it.niedermann.owncloud.notes.util.NotesClientUtil.LoginStatus;
import it.niedermann.owncloud.notes.util.ServerResponse;
import it.niedermann.owncloud.notes.util.SupportUtil;
/**
@ -218,7 +219,7 @@ public class NoteServerSyncHelper {
@Override
protected LoginStatus doInBackground(Void... voids) {
client = createNotesClient(); // recreate NoteClients on every sync in case the connection settings was changed
Log.d(getClass().getSimpleName(), "STARTING SYNCHRONIZATION");
Log.i(getClass().getSimpleName(), "STARTING SYNCHRONIZATION");
//dbHelper.debugPrintFullDB();
LoginStatus status = LoginStatus.OK;
pushLocalChanges();
@ -226,7 +227,7 @@ public class NoteServerSyncHelper {
status = pullRemoteChanges();
}
//dbHelper.debugPrintFullDB();
Log.d(getClass().getSimpleName(), "SYNCHRONIZATION FINISHED");
Log.i(getClass().getSimpleName(), "SYNCHRONIZATION FINISHED");
return status;
}
@ -242,12 +243,12 @@ public class NoteServerSyncHelper {
CloudNote remoteNote=null;
switch(note.getStatus()) {
case LOCAL_EDITED:
Log.d(getClass().getSimpleName(), " ...create/edit");
Log.v(getClass().getSimpleName(), " ...create/edit");
// if note is not new, try to edit it.
if (note.getRemoteId()>0) {
Log.d(getClass().getSimpleName(), " ...try to edit");
Log.v(getClass().getSimpleName(), " ...try to edit");
try {
remoteNote = client.editNote(customCertManager, note);
remoteNote = client.editNote(customCertManager, note).getNote();
} catch(FileNotFoundException e) {
// Note does not exists anymore
}
@ -255,21 +256,21 @@ public class NoteServerSyncHelper {
// However, the note may be deleted on the server meanwhile; or was never synchronized -> (re)create
// Please note, thas dbHelper.updateNote() realizes an optimistic conflict resolution, which is required for parallel changes of this Note from the UI.
if (remoteNote == null) {
Log.d(getClass().getSimpleName(), " ...Note does not exist on server -> (re)create");
remoteNote = client.createNote(customCertManager, note);
Log.v(getClass().getSimpleName(), " ...Note does not exist on server -> (re)create");
remoteNote = client.createNote(customCertManager, note).getNote();
}
dbHelper.updateNote(note.getId(), remoteNote, note);
break;
case LOCAL_DELETED:
if(note.getRemoteId()>0) {
Log.d(getClass().getSimpleName(), " ...delete (from server and local)");
Log.v(getClass().getSimpleName(), " ...delete (from server and local)");
try {
client.deleteNote(customCertManager, note.getRemoteId());
} catch (FileNotFoundException e) {
Log.d(getClass().getSimpleName(), " ...Note does not exist on server (anymore?) -> delete locally");
Log.v(getClass().getSimpleName(), " ...Note does not exist on server (anymore?) -> delete locally");
}
} else {
Log.d(getClass().getSimpleName(), " ...delete (only local, since it was not synchronized)");
Log.v(getClass().getSimpleName(), " ...delete (only local, since it was not synchronized)");
}
// Please note, thas dbHelper.deleteNote() realizes an optimistic conflict resolution, which is required for parallel changes of this Note from the UI.
dbHelper.deleteNote(note.getId(), DBStatus.LOCAL_DELETED);
@ -289,20 +290,26 @@ public class NoteServerSyncHelper {
*/
private LoginStatus pullRemoteChanges() {
Log.d(getClass().getSimpleName(), "pullRemoteChanges()");
LoginStatus status = null;
SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(appContext);
String lastETag = preferences.getString(SettingsActivity.SETTINGS_KEY_ETAG, null);
long lastModified = preferences.getLong(SettingsActivity.SETTINGS_KEY_LAST_MODIFIED, 0);
LoginStatus status;
try {
Map<Long, Long> idMap = dbHelper.getIdMap();
List<CloudNote> remoteNotes = client.getNotes(customCertManager);
ServerResponse.NotesResponse response = client.getNotes(customCertManager, lastModified, lastETag);
List<CloudNote> remoteNotes = response.getNotes();
Set<Long> remoteIDs = new HashSet<>();
// pull remote changes: update or create each remote note
for (CloudNote remoteNote : remoteNotes) {
Log.d(getClass().getSimpleName(), " Process Remote Note: "+remoteNote);
Log.v(getClass().getSimpleName(), " Process Remote Note: "+remoteNote);
remoteIDs.add(remoteNote.getRemoteId());
if(idMap.containsKey(remoteNote.getRemoteId())) {
Log.d(getClass().getSimpleName(), " ... found -> Update");
if(remoteNote.getModified()==null) {
Log.v(getClass().getSimpleName(), " ... unchanged");
} else if(idMap.containsKey(remoteNote.getRemoteId())) {
Log.v(getClass().getSimpleName(), " ... found -> Update");
dbHelper.updateNote(idMap.get(remoteNote.getRemoteId()), remoteNote, null);
} else {
Log.d(getClass().getSimpleName(), " ... create");
Log.v(getClass().getSimpleName(), " ... create");
dbHelper.addNote(remoteNote);
}
}
@ -310,11 +317,30 @@ public class NoteServerSyncHelper {
// remove remotely deleted notes (only those without local changes)
for (Map.Entry<Long, Long> entry : idMap.entrySet()) {
if(!remoteIDs.contains(entry.getKey())) {
Log.d(getClass().getSimpleName(), " ... remove "+entry.getValue());
Log.v(getClass().getSimpleName(), " ... remove "+entry.getValue());
dbHelper.deleteNote(entry.getValue(), DBStatus.VOID);
}
}
status = LoginStatus.OK;
// update ETag and Last-Modified in order to reduce size of next response
SharedPreferences.Editor editor = preferences.edit();
String etag = response.getETag();
if(etag!=null && !etag.isEmpty()) {
editor.putString(SettingsActivity.SETTINGS_KEY_ETAG, etag);
} else {
editor.remove(SettingsActivity.SETTINGS_KEY_ETAG);
}
long modified = response.getLastModified();
if(modified!=0) {
editor.putLong(SettingsActivity.SETTINGS_KEY_LAST_MODIFIED, modified);
} else {
editor.remove(SettingsActivity.SETTINGS_KEY_LAST_MODIFIED);
}
editor.apply();
} catch (ServerResponse.NotModifiedException e) {
Log.d(getClass().getSimpleName(), "No changes, nothing to do.");
status = LoginStatus.OK;
} catch (IOException e) {
Log.e(getClass().getSimpleName(), "Exception", e);
exceptions.add(e);

View file

@ -1,10 +1,8 @@
package it.niedermann.owncloud.notes.util;
import android.content.Context;
import android.util.Base64;
import android.util.Log;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
@ -14,27 +12,52 @@ import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.GregorianCalendar;
import java.util.List;
import at.bitfire.cert4android.CustomCertManager;
import it.niedermann.owncloud.notes.model.CloudNote;
import it.niedermann.owncloud.notes.util.ServerResponse.NoteResponse;
import it.niedermann.owncloud.notes.util.ServerResponse.NotesResponse;
public class NotesClient {
/**
* This entity class is used to return relevant data of the HTTP reponse.
*/
public static class ResponseData {
private final String content;
private final String etag;
private final long lastModified;
public ResponseData(String content, String etag, long lastModified) {
this.content = content;
this.etag = etag;
this.lastModified = lastModified;
}
public String getContent() {
return content;
}
public String getETag() {
return etag;
}
public long getLastModified() {
return lastModified;
}
}
public static final String METHOD_GET = "GET";
public static final String METHOD_PUT = "PUT";
public static final String METHOD_POST = "POST";
public static final String METHOD_DELETE = "DELETE";
private static final String key_id = "id";
private static final String key_title = "title";
private static final String key_content = "content";
private static final String key_favorite = "favorite";
private static final String key_category = "category";
private static final String key_etag = "etag";
private static final String key_modified = "modified";
public static final String JSON_ID = "id";
public static final String JSON_TITLE = "title";
public static final String JSON_CONTENT = "content";
public static final String JSON_FAVORITE = "favorite";
public static final String JSON_CATEGORY = "category";
public static final String JSON_ETAG = "etag";
public static final String JSON_MODIFIED = "modified";
private static final String application_json = "application/json";
private String url = "";
private String username = "";
@ -46,47 +69,12 @@ public class NotesClient {
this.password = password;
}
private CloudNote getNoteFromJSON(JSONObject json) throws JSONException {
long id = 0;
String title = "";
String content = "";
Calendar modified = null;
boolean favorite = false;
String category = null;
String etag = null;
if (!json.isNull(key_id)) {
id = json.getLong(key_id);
public NotesResponse getNotes(CustomCertManager ccm, long lastModified, String lastETag) throws JSONException, IOException {
String url = "notes";
if(lastModified>0) {
url += "?pruneBefore="+lastModified;
}
if (!json.isNull(key_title)) {
title = json.getString(key_title);
}
if (!json.isNull(key_content)) {
content = json.getString(key_content);
}
if (!json.isNull(key_modified)) {
modified = GregorianCalendar.getInstance();
modified.setTimeInMillis(json.getLong(key_modified) * 1000);
}
if (!json.isNull(key_favorite)) {
favorite = json.getBoolean(key_favorite);
}
if (!json.isNull(key_category)) {
category = json.getString(key_category);
}
if (!json.isNull(key_etag)) {
etag = json.getString(key_etag);
}
return new CloudNote(id, modified, title, content, favorite, category, etag);
}
public List<CloudNote> getNotes(CustomCertManager ccm) throws JSONException, IOException {
List<CloudNote> notesList = new ArrayList<>();
JSONArray notes = new JSONArray(requestServer(ccm, "notes", METHOD_GET, null));
for (int i = 0; i < notes.length(); i++) {
JSONObject json = notes.getJSONObject(i);
notesList.add(getNoteFromJSON(json));
}
return notesList;
return new NotesResponse(requestServer(ccm, url, METHOD_GET, null, lastETag));
}
/**
@ -98,18 +86,16 @@ public class NotesClient {
* @throws IOException
*/
@SuppressWarnings("unused")
public CloudNote getNoteById(CustomCertManager ccm, long id) throws JSONException, IOException {
JSONObject json = new JSONObject(requestServer(ccm, "notes/" + id, METHOD_GET, null));
return getNoteFromJSON(json);
public NoteResponse getNoteById(CustomCertManager ccm, long id) throws JSONException, IOException {
return new NoteResponse(requestServer(ccm, "notes/" + id, METHOD_GET, null, null));
}
private CloudNote putNote(CustomCertManager ccm, CloudNote note, String path, String method) throws JSONException, IOException {
private NoteResponse putNote(CustomCertManager ccm, CloudNote note, String path, String method) throws JSONException, IOException {
JSONObject paramObject = new JSONObject();
paramObject.accumulate(key_content, note.getContent());
paramObject.accumulate(key_modified, note.getModified().getTimeInMillis()/1000);
paramObject.accumulate(key_favorite, note.isFavorite());
JSONObject json = new JSONObject(requestServer(ccm, path, method, paramObject));
return getNoteFromJSON(json);
paramObject.accumulate(JSON_CONTENT, note.getContent());
paramObject.accumulate(JSON_MODIFIED, note.getModified().getTimeInMillis()/1000);
paramObject.accumulate(JSON_FAVORITE, note.isFavorite());
return new NoteResponse(requestServer(ccm, path, method, paramObject, null));
}
/**
@ -120,17 +106,16 @@ public class NotesClient {
* @throws JSONException
* @throws IOException
*/
public CloudNote createNote(CustomCertManager ccm, CloudNote note) throws JSONException, IOException {
public NoteResponse createNote(CustomCertManager ccm, CloudNote note) throws JSONException, IOException {
return putNote(ccm, note, "notes", METHOD_POST);
}
public CloudNote editNote(CustomCertManager ccm, CloudNote note) throws JSONException, IOException {
public NoteResponse editNote(CustomCertManager ccm, CloudNote note) throws JSONException, IOException {
return putNote(ccm, note, "notes/" + note.getRemoteId(), METHOD_PUT);
}
public void deleteNote(CustomCertManager ccm, long noteId) throws
IOException {
this.requestServer(ccm, "notes/" + noteId, METHOD_DELETE, null);
public void deleteNote(CustomCertManager ccm, long noteId) throws IOException {
this.requestServer(ccm, "notes/" + noteId, METHOD_DELETE, null, null);
}
/**
@ -143,19 +128,25 @@ public class NotesClient {
* @throws MalformedURLException
* @throws IOException
*/
private String requestServer(CustomCertManager ccm, String target, String method, JSONObject params)
private ResponseData requestServer(CustomCertManager ccm, String target, String method, JSONObject params, String lastETag)
throws IOException {
StringBuffer result = new StringBuffer();
// setup connection
String targetURL = url + "index.php/apps/notes/api/v0.2/" + target;
HttpURLConnection con = SupportUtil.getHttpURLConnection(ccm, targetURL);
con.setRequestMethod(method);
con.setRequestProperty(
"Authorization",
"Basic " + Base64.encodeToString((username + ":" + password).getBytes(), Base64.NO_WRAP));
if(lastETag!=null && METHOD_GET.equals(method)) {
con.setRequestProperty("If-None-Match", lastETag);
}
con.setConnectTimeout(10 * 1000); // 10 seconds
Log.d(getClass().getSimpleName(), method + " " + targetURL);
// send request data (optional)
byte[] paramData=null;
if (params != null) {
byte[] paramData = params.toString().getBytes();
paramData = params.toString().getBytes();
Log.d(getClass().getSimpleName(), "Params: " + params);
con.setFixedLengthStreamingMode(paramData.length);
con.setRequestProperty("Content-Type", application_json);
@ -165,12 +156,25 @@ public class NotesClient {
os.flush();
os.close();
}
BufferedReader rd = new BufferedReader(new InputStreamReader(con.getInputStream()));
// read response data
int responseCode = con.getResponseCode();
Log.d(getClass().getSimpleName(), "HTTP response code: "+responseCode);
if(responseCode==HttpURLConnection.HTTP_NOT_MODIFIED) {
throw new ServerResponse.NotModifiedException();
}
BufferedReader rd = new BufferedReader(new InputStreamReader(con.getInputStream()));
String line;
while ((line = rd.readLine()) != null) {
result.append(line);
}
return result.toString();
// create response object
String etag = con.getHeaderField("ETag");
long lastModified = con.getHeaderFieldDate("Last-Modified", 0) / 1000;
Log.i(getClass().getSimpleName(), "Result length: " + result.length() + (paramData == null ? "" : "; Request length: " + paramData.length));
Log.d(getClass().getSimpleName(), "ETag: " + etag + "; Last-Modified: " + lastModified + " (" + con.getHeaderField("Last-Modified") + ")");
// return these header fields since they should only be saved after successful processing the result!
return new ResponseData(result.toString(), etag, lastModified);
}
}
}

View file

@ -0,0 +1,100 @@
package it.niedermann.owncloud.notes.util;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.GregorianCalendar;
import java.util.List;
import it.niedermann.owncloud.notes.model.CloudNote;
/**
* Provides entity classes for handling server responses with a single note ({@link NoteResponse}) or a list of notes ({@link NotesResponse}).
*/
public class ServerResponse {
public static class NotModifiedException extends IOException {
}
public static class NoteResponse extends ServerResponse {
public NoteResponse(NotesClient.ResponseData response) {
super(response);
}
public CloudNote getNote() throws JSONException {
return getNoteFromJSON(new JSONObject(getContent()));
}
}
public static class NotesResponse extends ServerResponse {
public NotesResponse(NotesClient.ResponseData response) {
super(response);
}
public List<CloudNote> getNotes() throws JSONException {
List<CloudNote> notesList = new ArrayList<>();
JSONArray notes = new JSONArray(getContent());
for (int i = 0; i < notes.length(); i++) {
JSONObject json = notes.getJSONObject(i);
notesList.add(getNoteFromJSON(json));
}
return notesList;
}
}
private final NotesClient.ResponseData response;
public ServerResponse(NotesClient.ResponseData response) {
this.response = response;
}
protected String getContent() {
return response.getContent();
}
public String getETag() {
return response.getETag();
}
public long getLastModified() {
return response.getLastModified();
}
protected CloudNote getNoteFromJSON(JSONObject json) throws JSONException {
long id = 0;
String title = "";
String content = "";
Calendar modified = null;
boolean favorite = false;
String category = null;
String etag = null;
if (!json.isNull(NotesClient.JSON_ID)) {
id = json.getLong(NotesClient.JSON_ID);
}
if (!json.isNull(NotesClient.JSON_TITLE)) {
title = json.getString(NotesClient.JSON_TITLE);
}
if (!json.isNull(NotesClient.JSON_CONTENT)) {
content = json.getString(NotesClient.JSON_CONTENT);
}
if (!json.isNull(NotesClient.JSON_MODIFIED)) {
modified = GregorianCalendar.getInstance();
modified.setTimeInMillis(json.getLong(NotesClient.JSON_MODIFIED) * 1000);
}
if (!json.isNull(NotesClient.JSON_FAVORITE)) {
favorite = json.getBoolean(NotesClient.JSON_FAVORITE);
}
if (!json.isNull(NotesClient.JSON_CATEGORY)) {
category = json.getString(NotesClient.JSON_CATEGORY);
}
if (!json.isNull(NotesClient.JSON_ETAG)) {
etag = json.getString(NotesClient.JSON_ETAG);
}
return new CloudNote(id, modified, title, content, favorite, category, etag);
}
}