#1167 Use retrofit

This commit is contained in:
Stefan Niedermann 2021-04-26 18:23:25 +02:00
parent ef7e2815b0
commit 4676bb8b6b
6 changed files with 219 additions and 67 deletions

View file

@ -98,6 +98,12 @@ dependencies {
implementation "androidx.room:room-runtime:2.3.0" implementation "androidx.room:room-runtime:2.3.0"
annotationProcessor "androidx.room:room-compiler:2.3.0" annotationProcessor "androidx.room:room-compiler:2.3.0"
// Retrofit
implementation 'com.squareup.retrofit2:retrofit:2.6.4'
// Gson
implementation 'com.google.code.gson:gson:2.8.6'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
// Testing // Testing

View file

@ -312,6 +312,9 @@ public class NotesRepository {
db.getNoteDao().deleteByNoteId(id, forceDBStatus); db.getNoteDao().deleteByNoteId(id, forceDBStatus);
} }
/**
* Please note, that db.updateNote() realized an optimistic conflict resolution, which is required for parallel changes of this Note from the UI.
*/
public int updateIfNotModifiedLocallyDuringSync(long noteId, Long targetModified, String targetTitle, boolean targetFavorite, String targetETag, String targetContent, String targetExcerpt, String contentBeforeSyncStart, String categoryBeforeSyncStart, boolean favoriteBeforeSyncStart) { public int updateIfNotModifiedLocallyDuringSync(long noteId, Long targetModified, String targetTitle, boolean targetFavorite, String targetETag, String targetContent, String targetExcerpt, String contentBeforeSyncStart, String categoryBeforeSyncStart, boolean favoriteBeforeSyncStart) {
return db.getNoteDao().updateIfNotModifiedLocallyDuringSync(noteId, targetModified, targetTitle, targetFavorite, targetETag, targetContent, targetExcerpt, contentBeforeSyncStart, categoryBeforeSyncStart, favoriteBeforeSyncStart); return db.getNoteDao().updateIfNotModifiedLocallyDuringSync(noteId, targetModified, targetTitle, targetFavorite, targetETag, targetContent, targetExcerpt, contentBeforeSyncStart, categoryBeforeSyncStart, favoriteBeforeSyncStart);
} }
@ -779,10 +782,8 @@ public class NotesRepository {
Log.d(TAG, "Sync requested (" + (onlyLocalChanges ? "onlyLocalChanges" : "full") + "; " + (Boolean.TRUE.equals(syncActive.get(account.getId())) ? "sync active" : "sync NOT active") + ") ..."); Log.d(TAG, "Sync requested (" + (onlyLocalChanges ? "onlyLocalChanges" : "full") + "; " + (Boolean.TRUE.equals(syncActive.get(account.getId())) ? "sync active" : "sync NOT active") + ") ...");
if (isSyncPossible() && (!Boolean.TRUE.equals(syncActive.get(account.getId())) || onlyLocalChanges)) { if (isSyncPossible() && (!Boolean.TRUE.equals(syncActive.get(account.getId())) || onlyLocalChanges)) {
try { try {
SingleSignOnAccount ssoAccount = AccountImporter.getSingleSignOnAccount(context, account.getAccountName());
Log.d(TAG, "... starting now"); Log.d(TAG, "... starting now");
final NotesClient notesClient = NotesClient.newInstance(account.getPreferredApiVersion(), context); final NotesServerSyncTask syncTask = new NotesServerSyncTask(context, this, account, onlyLocalChanges) {
final NotesServerSyncTask syncTask = new NotesServerSyncTask(notesClient, this, account, ssoAccount, onlyLocalChanges) {
@Override @Override
void onPreExecute() { void onPreExecute() {
syncStatus.postValue(true); syncStatus.postValue(true);

View file

@ -1,26 +1,34 @@
package it.niedermann.owncloud.notes.persistence; package it.niedermann.owncloud.notes.persistence;
import android.content.Context;
import android.util.Log; import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import com.nextcloud.android.sso.AccountImporter;
import com.nextcloud.android.sso.api.NextcloudAPI;
import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountNotFoundException;
import com.nextcloud.android.sso.exceptions.NextcloudHttpRequestFailedException; import com.nextcloud.android.sso.exceptions.NextcloudHttpRequestFailedException;
import com.nextcloud.android.sso.exceptions.TokenMismatchException; import com.nextcloud.android.sso.exceptions.TokenMismatchException;
import com.nextcloud.android.sso.model.SingleSignOnAccount; import com.nextcloud.android.sso.model.SingleSignOnAccount;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Set; import java.util.Set;
import it.niedermann.owncloud.notes.persistence.entity.Account; import it.niedermann.owncloud.notes.persistence.entity.Account;
import it.niedermann.owncloud.notes.persistence.entity.Note; import it.niedermann.owncloud.notes.persistence.entity.Note;
import it.niedermann.owncloud.notes.persistence.sync.ApiProvider;
import it.niedermann.owncloud.notes.shared.model.DBStatus; import it.niedermann.owncloud.notes.shared.model.DBStatus;
import it.niedermann.owncloud.notes.shared.model.ISyncCallback; import it.niedermann.owncloud.notes.shared.model.ISyncCallback;
import it.niedermann.owncloud.notes.shared.model.ServerResponse;
import it.niedermann.owncloud.notes.shared.model.SyncResultStatus; import it.niedermann.owncloud.notes.shared.model.SyncResultStatus;
import retrofit2.Response;
import static it.niedermann.owncloud.notes.shared.model.DBStatus.LOCAL_DELETED; import static it.niedermann.owncloud.notes.shared.model.DBStatus.LOCAL_DELETED;
import static it.niedermann.owncloud.notes.shared.util.NoteUtil.generateNoteExcerpt; import static it.niedermann.owncloud.notes.shared.util.NoteUtil.generateNoteExcerpt;
@ -36,8 +44,13 @@ abstract class NotesServerSyncTask extends Thread {
private static final String TAG = NotesServerSyncTask.class.getSimpleName(); private static final String TAG = NotesServerSyncTask.class.getSimpleName();
private static final String HEADER_KEY_X_NOTES_API_VERSIONS = "X-Notes-API-Versions";
private static final String HEADER_KEY_ETAG = "ETag";
private static final String HEADER_KEY_LAST_MODIFIED = "Last-Modified";
@NonNull @NonNull
private final NotesClient notesClient; private final Context context;
@NonNull
private ApiProvider notesClient;
@NonNull @NonNull
private final NotesRepository repo; private final NotesRepository repo;
@NonNull @NonNull
@ -50,12 +63,12 @@ abstract class NotesServerSyncTask extends Thread {
@NonNull @NonNull
protected final ArrayList<Throwable> exceptions = new ArrayList<>(); protected final ArrayList<Throwable> exceptions = new ArrayList<>();
NotesServerSyncTask(@NonNull NotesClient notesClient, @NonNull NotesRepository repo, @NonNull Account localAccount, @NonNull SingleSignOnAccount ssoAccount, boolean onlyLocalChanges) { NotesServerSyncTask(@NonNull Context context, @NonNull NotesRepository repo, @NonNull Account localAccount, boolean onlyLocalChanges) throws NextcloudFilesAppAccountNotFoundException {
super(TAG); super(TAG);
this.notesClient = notesClient; this.context = context;
this.repo = repo; this.repo = repo;
this.localAccount = localAccount; this.localAccount = localAccount;
this.ssoAccount = ssoAccount; this.ssoAccount = AccountImporter.getSingleSignOnAccount(context, localAccount.getAccountName());
this.onlyLocalChanges = onlyLocalChanges; this.onlyLocalChanges = onlyLocalChanges;
} }
@ -67,15 +80,30 @@ abstract class NotesServerSyncTask extends Thread {
public void run() { public void run() {
onPreExecute(); onPreExecute();
Log.i(TAG, "STARTING SYNCHRONIZATION"); notesClient = new ApiProvider(context, ssoAccount, new NextcloudAPI.ApiConnectedListener() {
final SyncResultStatus status = new SyncResultStatus(); @Override
status.pushSuccessful = pushLocalChanges(); public void onConnected() {
if (!onlyLocalChanges) { new Thread(() -> {
status.pullSuccessful = pullRemoteChanges(); Log.i(TAG, "STARTING SYNCHRONIZATION");
} final SyncResultStatus status = new SyncResultStatus();
Log.i(TAG, "SYNCHRONIZATION FINISHED"); status.pushSuccessful = pushLocalChanges();
if (!onlyLocalChanges) {
status.pullSuccessful = pullRemoteChanges();
}
Log.i(TAG, "SYNCHRONIZATION FINISHED");
onPostExecute(status); onPostExecute(status);
}).start();
}
@Override
public void onError(Exception ex) {
final SyncResultStatus status = new SyncResultStatus();
status.pullSuccessful = false;
status.pushSuccessful = false;
onPostExecute(status);
}
});
} }
abstract void onPreExecute(); abstract void onPreExecute();
@ -99,20 +127,31 @@ abstract class NotesServerSyncTask extends Thread {
Log.v(TAG, " ...create/edit"); Log.v(TAG, " ...create/edit");
if (note.getRemoteId() != null) { if (note.getRemoteId() != null) {
Log.v(TAG, " ...Note has remoteId → try to edit"); Log.v(TAG, " ...Note has remoteId → try to edit");
try { final Response<Note> editResponse = notesClient.getNotesAPI().editNote(note, note.getRemoteId()).execute();
remoteNote = notesClient.editNote(ssoAccount, note).getNote(); if (editResponse.isSuccessful()) {
} catch (NextcloudHttpRequestFailedException e) { remoteNote = editResponse.body();
if (e.getStatusCode() == HTTP_NOT_FOUND) { } else {
if (editResponse.code() == HTTP_NOT_FOUND) {
Log.v(TAG, " ...Note does no longer exist on server → recreate"); Log.v(TAG, " ...Note does no longer exist on server → recreate");
remoteNote = notesClient.createNote(ssoAccount, note).getNote(); final Response<Note> createResponse = notesClient.getNotesAPI().createNote(note).execute();
if (createResponse.isSuccessful()) {
remoteNote = createResponse.body();
} else {
throw new Exception(createResponse.errorBody().string());
}
} else { } else {
throw e; throw new Exception(editResponse.errorBody().string());
} }
} }
} else { } else {
Log.v(TAG, " ...Note does not have a remoteId yet → create"); Log.v(TAG, " ...Note does not have a remoteId yet → create");
remoteNote = notesClient.createNote(ssoAccount, note).getNote(); final Response<Note> createResponse = notesClient.getNotesAPI().createNote(note).execute();
repo.updateRemoteId(note.getId(), remoteNote.getRemoteId()); if (createResponse.isSuccessful()) {
remoteNote = createResponse.body();
repo.updateRemoteId(note.getId(), remoteNote.getRemoteId());
} else {
throw new Exception(createResponse.errorBody().string());
}
} }
// Please note, that db.updateNote() realized an optimistic conflict resolution, which is required for parallel changes of this Note from the UI. // Please note, that db.updateNote() realized an optimistic conflict resolution, which is required for parallel changes of this Note from the UI.
repo.updateIfNotModifiedLocallyDuringSync(note.getId(), remoteNote.getModified().getTimeInMillis(), remoteNote.getTitle(), remoteNote.getFavorite(), remoteNote.getETag(), remoteNote.getContent(), generateNoteExcerpt(remoteNote.getContent(), remoteNote.getTitle()), note.getContent(), note.getCategory(), note.getFavorite()); repo.updateIfNotModifiedLocallyDuringSync(note.getId(), remoteNote.getModified().getTimeInMillis(), remoteNote.getTitle(), remoteNote.getFavorite(), remoteNote.getETag(), remoteNote.getContent(), generateNoteExcerpt(remoteNote.getContent(), remoteNote.getTitle()), note.getContent(), note.getCategory(), note.getFavorite());
@ -122,13 +161,12 @@ abstract class NotesServerSyncTask extends Thread {
Log.v(TAG, " ...delete (only local, since it has never been synchronized)"); Log.v(TAG, " ...delete (only local, since it has never been synchronized)");
} else { } else {
Log.v(TAG, " ...delete (from server and local)"); Log.v(TAG, " ...delete (from server and local)");
try { final Response<Note> deleteResponse = notesClient.getNotesAPI().deleteNote(note.getRemoteId()).execute();
notesClient.deleteNote(ssoAccount, note.getRemoteId()); if (!deleteResponse.isSuccessful()) {
} catch (NextcloudHttpRequestFailedException e) { if (deleteResponse.code() == HTTP_NOT_FOUND) {
if (e.getStatusCode() == HTTP_NOT_FOUND) {
Log.v(TAG, " ...delete (note has already been deleted remotely)"); Log.v(TAG, " ...delete (note has already been deleted remotely)");
} else { } else {
throw e; throw new Exception(deleteResponse.errorBody().string());
} }
} }
} }
@ -173,51 +211,71 @@ abstract class NotesServerSyncTask extends Thread {
localAccount.setModified(accountFromDatabase.getModified()); localAccount.setModified(accountFromDatabase.getModified());
localAccount.setETag(accountFromDatabase.getETag()); localAccount.setETag(accountFromDatabase.getETag());
final ServerResponse.NotesResponse response = notesClient.getNotes(ssoAccount, localAccount.getModified(), localAccount.getETag()); final Response<List<Note>> fetchResponse = notesClient.getNotesAPI().getNotes(localAccount.getModified(), localAccount.getETag()).execute();
final List<Note> remoteNotes = response.getNotes(); if (fetchResponse.isSuccessful()) {
final Set<Long> remoteIDs = new HashSet<>(); final List<Note> remoteNotes = fetchResponse.body();
// pull remote changes: update or create each remote note final Set<Long> remoteIDs = new HashSet<>();
for (Note remoteNote : remoteNotes) { // pull remote changes: update or create each remote note
Log.v(TAG, " Process Remote Note: " + remoteNote); for (Note remoteNote : remoteNotes) {
remoteIDs.add(remoteNote.getRemoteId()); Log.v(TAG, " Process Remote Note: " + remoteNote);
if (remoteNote.getModified() == null) { remoteIDs.add(remoteNote.getRemoteId());
Log.v(TAG, " ... unchanged"); if (remoteNote.getModified() == null) {
} else if (idMap.containsKey(remoteNote.getRemoteId())) { Log.v(TAG, " ... unchanged");
Log.v(TAG, " ... found → Update"); } else if (idMap.containsKey(remoteNote.getRemoteId())) {
Long localId = idMap.get(remoteNote.getRemoteId()); Log.v(TAG, " ... found → Update");
if (localId != null) { Long localId = idMap.get(remoteNote.getRemoteId());
repo.updateIfNotModifiedLocallyAndAnyRemoteColumnHasChanged( if (localId != null) {
localId, remoteNote.getModified().getTimeInMillis(), remoteNote.getTitle(), remoteNote.getFavorite(), remoteNote.getCategory(), remoteNote.getETag(), remoteNote.getContent(), generateNoteExcerpt(remoteNote.getContent(), remoteNote.getTitle())); repo.updateIfNotModifiedLocallyAndAnyRemoteColumnHasChanged(
localId, remoteNote.getModified().getTimeInMillis(), remoteNote.getTitle(), remoteNote.getFavorite(), remoteNote.getCategory(), remoteNote.getETag(), remoteNote.getContent(), generateNoteExcerpt(remoteNote.getContent(), remoteNote.getTitle()));
} else {
Log.e(TAG, "Tried to update note from server, but local id of note is null. " + remoteNote);
}
} else { } else {
Log.e(TAG, "Tried to update note from server, but local id of note is null. " + remoteNote); Log.v(TAG, " ... create");
repo.addNote(localAccount.getId(), remoteNote);
} }
} else {
Log.v(TAG, " ... create");
repo.addNote(localAccount.getId(), remoteNote);
} }
} Log.d(TAG, " Remove remotely deleted Notes (only those without local changes)");
Log.d(TAG, " Remove remotely deleted Notes (only those without local changes)"); // remove remotely deleted notes (only those without local changes)
// remove remotely deleted notes (only those without local changes) for (Map.Entry<Long, Long> entry : idMap.entrySet()) {
for (Map.Entry<Long, Long> entry : idMap.entrySet()) { if (!remoteIDs.contains(entry.getKey())) {
if (!remoteIDs.contains(entry.getKey())) { Log.v(TAG, " ... remove " + entry.getValue());
Log.v(TAG, " ... remove " + entry.getValue()); repo.deleteByNoteId(entry.getValue(), DBStatus.VOID);
repo.deleteByNoteId(entry.getValue(), DBStatus.VOID); }
} }
}
// update ETag and Last-Modified in order to reduce size of next response // update ETag and Last-Modified in order to reduce size of next response
localAccount.setETag(response.getETag()); localAccount.setETag(fetchResponse.headers().get(HEADER_KEY_ETAG));
localAccount.setModified(response.getLastModified());
repo.updateETag(localAccount.getId(), localAccount.getETag()); final Calendar lastModified = Calendar.getInstance();
repo.updateModified(localAccount.getId(), localAccount.getModified().getTimeInMillis()); lastModified.setTimeInMillis(0);
try { final String lastModifiedHeader = fetchResponse.headers().get(HEADER_KEY_LAST_MODIFIED);
if (repo.updateApiVersion(localAccount.getId(), response.getSupportedApiVersions())) { if (lastModifiedHeader != null)
localAccount.setApiVersion(response.getSupportedApiVersions()); lastModified.setTimeInMillis(Date.parse(lastModifiedHeader));
Log.d(TAG, "ETag: " + fetchResponse.headers().get(HEADER_KEY_ETAG) + "; Last-Modified: " + lastModified + " (" + lastModified + ")");
localAccount.setModified(lastModified);
repo.updateETag(localAccount.getId(), localAccount.getETag());
repo.updateModified(localAccount.getId(), localAccount.getModified().getTimeInMillis());
String supportedApiVersions = null;
final String supportedApiVersionsHeader = fetchResponse.headers().get(HEADER_KEY_X_NOTES_API_VERSIONS);
if (supportedApiVersionsHeader != null) {
supportedApiVersions = "[" + Objects.requireNonNull(supportedApiVersionsHeader) + "]";
} }
} catch (Exception e) { try {
exceptions.add(e); if (repo.updateApiVersion(localAccount.getId(), supportedApiVersions)) {
localAccount.setApiVersion(supportedApiVersions);
}
} catch (Exception e) {
exceptions.add(e);
}
return true;
} else {
throw new Exception(fetchResponse.errorBody().string());
} }
return true;
} catch (NextcloudHttpRequestFailedException e) { } catch (NextcloudHttpRequestFailedException e) {
Log.d(TAG, "Server returned HTTP Status Code " + e.getStatusCode() + " - " + e.getMessage()); Log.d(TAG, "Server returned HTTP Status Code " + e.getStatusCode() + " - " + e.getMessage());
if (e.getStatusCode() == HTTP_NOT_MODIFIED) { if (e.getStatusCode() == HTTP_NOT_MODIFIED) {

View file

@ -9,6 +9,9 @@ import androidx.room.Ignore;
import androidx.room.Index; import androidx.room.Index;
import androidx.room.PrimaryKey; import androidx.room.PrimaryKey;
import com.google.gson.annotations.Expose;
import com.google.gson.annotations.SerializedName;
import java.io.Serializable; import java.io.Serializable;
import java.util.Calendar; import java.util.Calendar;
@ -34,31 +37,51 @@ import it.niedermann.owncloud.notes.shared.model.Item;
} }
) )
public class Note implements Serializable, Item { public class Note implements Serializable, Item {
@SerializedName("localId")
@PrimaryKey(autoGenerate = true) @PrimaryKey(autoGenerate = true)
private long id; private long id;
@Nullable @Nullable
@Expose
@SerializedName("id")
private Long remoteId; private Long remoteId;
private long accountId; private long accountId;
@NonNull @NonNull
private DBStatus status = DBStatus.VOID; private DBStatus status = DBStatus.VOID;
@NonNull @NonNull
@ColumnInfo(defaultValue = "") @ColumnInfo(defaultValue = "")
@Expose
private String title = ""; private String title = "";
@NonNull @NonNull
@Expose
@ColumnInfo(defaultValue = "") @ColumnInfo(defaultValue = "")
private String category = ""; private String category = "";
@Expose
@Nullable @Nullable
private Calendar modified; private Calendar modified;
@NonNull @NonNull
@ColumnInfo(defaultValue = "") @ColumnInfo(defaultValue = "")
@Expose
private String content = ""; private String content = "";
@Expose
@ColumnInfo(defaultValue = "0") @ColumnInfo(defaultValue = "0")
private boolean favorite = false; private boolean favorite = false;
@Expose
@Nullable @Nullable
private String eTag; private String eTag;
@NonNull @NonNull
@ColumnInfo(defaultValue = "") @ColumnInfo(defaultValue = "")
private String excerpt = ""; private String excerpt = "";
@ColumnInfo(defaultValue = "0") @ColumnInfo(defaultValue = "0")
private int scrollY = 0; private int scrollY = 0;

View file

@ -0,0 +1,30 @@
package it.niedermann.owncloud.notes.persistence.sync;
import android.content.Context;
import androidx.annotation.NonNull;
import com.google.gson.GsonBuilder;
import com.nextcloud.android.sso.api.NextcloudAPI;
import com.nextcloud.android.sso.model.SingleSignOnAccount;
import retrofit2.NextcloudRetrofitApiBuilder;
/**
* Created by david on 26.05.17.
*/
public class ApiProvider {
private static final String API_ENDPOINT_NOTES = "/index.php/apps/notes/api/v1/";
private final NotesAPI notesAPI;
public ApiProvider(@NonNull Context context, @NonNull SingleSignOnAccount ssoAccount, @NonNull final NextcloudAPI.ApiConnectedListener callback) {
final NextcloudAPI nextcloudAPI = new NextcloudAPI(context, ssoAccount, new GsonBuilder().create(), callback);
notesAPI = new NextcloudRetrofitApiBuilder(nextcloudAPI, API_ENDPOINT_NOTES).create(NotesAPI.class);
}
public NotesAPI getNotesAPI() {
return notesAPI;
}
}

View file

@ -0,0 +1,34 @@
package it.niedermann.owncloud.notes.persistence.sync;
import java.util.Calendar;
import java.util.List;
import it.niedermann.owncloud.notes.persistence.entity.Note;
import retrofit2.Call;
import retrofit2.http.Body;
import retrofit2.http.DELETE;
import retrofit2.http.GET;
import retrofit2.http.POST;
import retrofit2.http.PUT;
import retrofit2.http.Path;
import retrofit2.http.Query;
/**
* @link <a href="https://deck.readthedocs.io/en/latest/API/">Deck REST API</a>
*/
public interface NotesAPI {
@GET("notes")
Call<List<Note>> getNotes(@Query("pruneBefore") Calendar lastModified, @Query("If-None-Match") String lastETag);
@POST("notes")
Call<Note> createNote(@Body Note note);
@PUT("notes/{remoteId}")
Call<Note> editNote(@Body Note note, @Path("remoteId") long remoteId);
@DELETE("notes/{remoteId}")
Call<Note> deleteNote(@Path("remoteId") long noteId);
}