diff --git a/app/src/main/java/it/niedermann/owncloud/notes/android/activity/SettingsActivity.java b/app/src/main/java/it/niedermann/owncloud/notes/android/activity/SettingsActivity.java index 9b5eb2c3b..122d7a0f6 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/android/activity/SettingsActivity.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/android/activity/SettingsActivity.java @@ -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 @@ protected void onPostExecute(LoginStatus status) { 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(); diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NoteSQLiteOpenHelper.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NoteSQLiteOpenHelper.java index dec4d5d3c..7198a58b1 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NoteSQLiteOpenHelper.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NoteSQLiteOpenHelper.java @@ -251,9 +251,9 @@ private DBNote getNoteFromCursor(Cursor cursor) { public void debugPrintFullDB() { List 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); } } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NoteServerSyncHelper.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NoteServerSyncHelper.java index f7a05e0ad..0b771ad41 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NoteServerSyncHelper.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NoteServerSyncHelper.java @@ -35,6 +35,7 @@ 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 @@ protected void onPreExecute() { @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 @@ protected LoginStatus doInBackground(Void... voids) { status = pullRemoteChanges(); } //dbHelper.debugPrintFullDB(); - Log.d(getClass().getSimpleName(), "SYNCHRONIZATION FINISHED"); + Log.i(getClass().getSimpleName(), "SYNCHRONIZATION FINISHED"); return status; } @@ -242,12 +243,12 @@ private void pushLocalChanges() { 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 @@ private void pushLocalChanges() { // 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 @@ private void pushLocalChanges() { */ 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 idMap = dbHelper.getIdMap(); - List remoteNotes = client.getNotes(customCertManager); + ServerResponse.NotesResponse response = client.getNotes(customCertManager, lastModified, lastETag); + List remoteNotes = response.getNotes(); Set 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 @@ private LoginStatus pullRemoteChanges() { // remove remotely deleted notes (only those without local changes) for (Map.Entry 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); diff --git a/app/src/main/java/it/niedermann/owncloud/notes/util/NotesClient.java b/app/src/main/java/it/niedermann/owncloud/notes/util/NotesClient.java index 2d5f55fd7..dd56f0730 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/util/NotesClient.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/util/NotesClient.java @@ -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.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 NotesClient(String url, String username, String password) { 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); - } - if (!json.isNull(key_title)) { - title = json.getString(key_title); - } - if (!json.isNull(key_content)) { - content = json.getString(key_content); + 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_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 getNotes(CustomCertManager ccm) throws JSONException, IOException { - List 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 List getNotes(CustomCertManager ccm) throws JSONException, IOE * @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 @@ private CloudNote putNote(CustomCertManager ccm, CloudNote note, String path, St * @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 void deleteNote(CustomCertManager ccm, long noteId) throws * @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 @@ private String requestServer(CustomCertManager ccm, String target, String method 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); } -} \ No newline at end of file +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/util/ServerResponse.java b/app/src/main/java/it/niedermann/owncloud/notes/util/ServerResponse.java new file mode 100644 index 000000000..04f4b1f14 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/util/ServerResponse.java @@ -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 getNotes() throws JSONException { + List 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); + } +}