From 4676bb8b6bcb92e9b957b3457ee49601d6fea4e8 Mon Sep 17 00:00:00 2001 From: Stefan Niedermann Date: Mon, 26 Apr 2021 18:23:25 +0200 Subject: [PATCH] #1167 Use retrofit --- app/build.gradle | 6 + .../notes/persistence/NotesRepository.java | 7 +- .../persistence/NotesServerSyncTask.java | 186 ++++++++++++------ .../notes/persistence/entity/Note.java | 23 +++ .../notes/persistence/sync/ApiProvider.java | 30 +++ .../notes/persistence/sync/NotesAPI.java | 34 ++++ 6 files changed, 219 insertions(+), 67 deletions(-) create mode 100644 app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/ApiProvider.java create mode 100644 app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/NotesAPI.java diff --git a/app/build.gradle b/app/build.gradle index a1f7d7da..c89f692e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -98,6 +98,12 @@ dependencies { implementation "androidx.room:room-runtime: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' // Testing diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesRepository.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesRepository.java index 500593fe..fed86489 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesRepository.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesRepository.java @@ -312,6 +312,9 @@ public class NotesRepository { 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) { 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") + ") ..."); if (isSyncPossible() && (!Boolean.TRUE.equals(syncActive.get(account.getId())) || onlyLocalChanges)) { try { - SingleSignOnAccount ssoAccount = AccountImporter.getSingleSignOnAccount(context, account.getAccountName()); Log.d(TAG, "... starting now"); - final NotesClient notesClient = NotesClient.newInstance(account.getPreferredApiVersion(), context); - final NotesServerSyncTask syncTask = new NotesServerSyncTask(notesClient, this, account, ssoAccount, onlyLocalChanges) { + final NotesServerSyncTask syncTask = new NotesServerSyncTask(context, this, account, onlyLocalChanges) { @Override void onPreExecute() { syncStatus.postValue(true); diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncTask.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncTask.java index 2b4e6201..bb22fc30 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncTask.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesServerSyncTask.java @@ -1,26 +1,34 @@ package it.niedermann.owncloud.notes.persistence; +import android.content.Context; import android.util.Log; 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.TokenMismatchException; import com.nextcloud.android.sso.model.SingleSignOnAccount; import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import it.niedermann.owncloud.notes.persistence.entity.Account; 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.ISyncCallback; -import it.niedermann.owncloud.notes.shared.model.ServerResponse; 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.util.NoteUtil.generateNoteExcerpt; @@ -36,8 +44,13 @@ abstract class NotesServerSyncTask extends Thread { 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 - private final NotesClient notesClient; + private final Context context; + @NonNull + private ApiProvider notesClient; @NonNull private final NotesRepository repo; @NonNull @@ -50,12 +63,12 @@ abstract class NotesServerSyncTask extends Thread { @NonNull protected final ArrayList 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); - this.notesClient = notesClient; + this.context = context; this.repo = repo; this.localAccount = localAccount; - this.ssoAccount = ssoAccount; + this.ssoAccount = AccountImporter.getSingleSignOnAccount(context, localAccount.getAccountName()); this.onlyLocalChanges = onlyLocalChanges; } @@ -67,15 +80,30 @@ abstract class NotesServerSyncTask extends Thread { public void run() { onPreExecute(); - Log.i(TAG, "STARTING SYNCHRONIZATION"); - final SyncResultStatus status = new SyncResultStatus(); - status.pushSuccessful = pushLocalChanges(); - if (!onlyLocalChanges) { - status.pullSuccessful = pullRemoteChanges(); - } - Log.i(TAG, "SYNCHRONIZATION FINISHED"); + notesClient = new ApiProvider(context, ssoAccount, new NextcloudAPI.ApiConnectedListener() { + @Override + public void onConnected() { + new Thread(() -> { + Log.i(TAG, "STARTING SYNCHRONIZATION"); + final SyncResultStatus status = new SyncResultStatus(); + 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(); @@ -99,20 +127,31 @@ abstract class NotesServerSyncTask extends Thread { Log.v(TAG, " ...create/edit"); if (note.getRemoteId() != null) { Log.v(TAG, " ...Note has remoteId → try to edit"); - try { - remoteNote = notesClient.editNote(ssoAccount, note).getNote(); - } catch (NextcloudHttpRequestFailedException e) { - if (e.getStatusCode() == HTTP_NOT_FOUND) { + final Response editResponse = notesClient.getNotesAPI().editNote(note, note.getRemoteId()).execute(); + if (editResponse.isSuccessful()) { + remoteNote = editResponse.body(); + } else { + if (editResponse.code() == HTTP_NOT_FOUND) { Log.v(TAG, " ...Note does no longer exist on server → recreate"); - remoteNote = notesClient.createNote(ssoAccount, note).getNote(); + final Response createResponse = notesClient.getNotesAPI().createNote(note).execute(); + if (createResponse.isSuccessful()) { + remoteNote = createResponse.body(); + } else { + throw new Exception(createResponse.errorBody().string()); + } } else { - throw e; + throw new Exception(editResponse.errorBody().string()); } } } else { Log.v(TAG, " ...Note does not have a remoteId yet → create"); - remoteNote = notesClient.createNote(ssoAccount, note).getNote(); - repo.updateRemoteId(note.getId(), remoteNote.getRemoteId()); + final Response createResponse = notesClient.getNotesAPI().createNote(note).execute(); + 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. 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)"); } else { Log.v(TAG, " ...delete (from server and local)"); - try { - notesClient.deleteNote(ssoAccount, note.getRemoteId()); - } catch (NextcloudHttpRequestFailedException e) { - if (e.getStatusCode() == HTTP_NOT_FOUND) { + final Response deleteResponse = notesClient.getNotesAPI().deleteNote(note.getRemoteId()).execute(); + if (!deleteResponse.isSuccessful()) { + if (deleteResponse.code() == HTTP_NOT_FOUND) { Log.v(TAG, " ...delete (note has already been deleted remotely)"); } else { - throw e; + throw new Exception(deleteResponse.errorBody().string()); } } } @@ -173,51 +211,71 @@ abstract class NotesServerSyncTask extends Thread { localAccount.setModified(accountFromDatabase.getModified()); localAccount.setETag(accountFromDatabase.getETag()); - final ServerResponse.NotesResponse response = notesClient.getNotes(ssoAccount, localAccount.getModified(), localAccount.getETag()); - final List remoteNotes = response.getNotes(); - final Set remoteIDs = new HashSet<>(); - // pull remote changes: update or create each remote note - for (Note remoteNote : remoteNotes) { - Log.v(TAG, " Process Remote Note: " + remoteNote); - remoteIDs.add(remoteNote.getRemoteId()); - if (remoteNote.getModified() == null) { - Log.v(TAG, " ... unchanged"); - } else if (idMap.containsKey(remoteNote.getRemoteId())) { - Log.v(TAG, " ... found → Update"); - Long localId = idMap.get(remoteNote.getRemoteId()); - if (localId != null) { - repo.updateIfNotModifiedLocallyAndAnyRemoteColumnHasChanged( - localId, remoteNote.getModified().getTimeInMillis(), remoteNote.getTitle(), remoteNote.getFavorite(), remoteNote.getCategory(), remoteNote.getETag(), remoteNote.getContent(), generateNoteExcerpt(remoteNote.getContent(), remoteNote.getTitle())); + final Response> fetchResponse = notesClient.getNotesAPI().getNotes(localAccount.getModified(), localAccount.getETag()).execute(); + if (fetchResponse.isSuccessful()) { + final List remoteNotes = fetchResponse.body(); + final Set remoteIDs = new HashSet<>(); + // pull remote changes: update or create each remote note + for (Note remoteNote : remoteNotes) { + Log.v(TAG, " Process Remote Note: " + remoteNote); + remoteIDs.add(remoteNote.getRemoteId()); + if (remoteNote.getModified() == null) { + Log.v(TAG, " ... unchanged"); + } else if (idMap.containsKey(remoteNote.getRemoteId())) { + Log.v(TAG, " ... found → Update"); + Long localId = idMap.get(remoteNote.getRemoteId()); + if (localId != null) { + 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 { - 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)"); - // remove remotely deleted notes (only those without local changes) - for (Map.Entry entry : idMap.entrySet()) { - if (!remoteIDs.contains(entry.getKey())) { - Log.v(TAG, " ... remove " + entry.getValue()); - repo.deleteByNoteId(entry.getValue(), DBStatus.VOID); + Log.d(TAG, " Remove remotely deleted Notes (only those without local changes)"); + // remove remotely deleted notes (only those without local changes) + for (Map.Entry entry : idMap.entrySet()) { + if (!remoteIDs.contains(entry.getKey())) { + Log.v(TAG, " ... remove " + entry.getValue()); + repo.deleteByNoteId(entry.getValue(), DBStatus.VOID); + } } - } - // update ETag and Last-Modified in order to reduce size of next response - localAccount.setETag(response.getETag()); - localAccount.setModified(response.getLastModified()); - repo.updateETag(localAccount.getId(), localAccount.getETag()); - repo.updateModified(localAccount.getId(), localAccount.getModified().getTimeInMillis()); - try { - if (repo.updateApiVersion(localAccount.getId(), response.getSupportedApiVersions())) { - localAccount.setApiVersion(response.getSupportedApiVersions()); + // update ETag and Last-Modified in order to reduce size of next response + localAccount.setETag(fetchResponse.headers().get(HEADER_KEY_ETAG)); + + final Calendar lastModified = Calendar.getInstance(); + lastModified.setTimeInMillis(0); + final String lastModifiedHeader = fetchResponse.headers().get(HEADER_KEY_LAST_MODIFIED); + if (lastModifiedHeader != null) + 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) { - exceptions.add(e); + try { + 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) { Log.d(TAG, "Server returned HTTP Status Code " + e.getStatusCode() + " - " + e.getMessage()); if (e.getStatusCode() == HTTP_NOT_MODIFIED) { diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/Note.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/Note.java index 7224d4eb..e2d9a97a 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/Note.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/Note.java @@ -9,6 +9,9 @@ import androidx.room.Ignore; import androidx.room.Index; import androidx.room.PrimaryKey; +import com.google.gson.annotations.Expose; +import com.google.gson.annotations.SerializedName; + import java.io.Serializable; import java.util.Calendar; @@ -34,31 +37,51 @@ import it.niedermann.owncloud.notes.shared.model.Item; } ) public class Note implements Serializable, Item { + @SerializedName("localId") @PrimaryKey(autoGenerate = true) private long id; + @Nullable + @Expose + @SerializedName("id") private Long remoteId; + private long accountId; + @NonNull private DBStatus status = DBStatus.VOID; + @NonNull @ColumnInfo(defaultValue = "") + @Expose private String title = ""; + @NonNull + @Expose @ColumnInfo(defaultValue = "") private String category = ""; + + @Expose @Nullable private Calendar modified; + @NonNull @ColumnInfo(defaultValue = "") + @Expose private String content = ""; + + @Expose @ColumnInfo(defaultValue = "0") private boolean favorite = false; + + @Expose @Nullable private String eTag; + @NonNull @ColumnInfo(defaultValue = "") private String excerpt = ""; + @ColumnInfo(defaultValue = "0") private int scrollY = 0; diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/ApiProvider.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/ApiProvider.java new file mode 100644 index 00000000..63f10c99 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/ApiProvider.java @@ -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; + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/NotesAPI.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/NotesAPI.java new file mode 100644 index 00000000..d684a35c --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/NotesAPI.java @@ -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 Deck REST API + */ +public interface NotesAPI { + + @GET("notes") + Call> getNotes(@Query("pruneBefore") Calendar lastModified, @Query("If-None-Match") String lastETag); + + @POST("notes") + Call createNote(@Body Note note); + + @PUT("notes/{remoteId}") + Call editNote(@Body Note note, @Path("remoteId") long remoteId); + + @DELETE("notes/{remoteId}") + Call deleteNote(@Path("remoteId") long noteId); + +}