#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"
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

View file

@ -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);

View file

@ -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<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);
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<Note> 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<Note> 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<Note> 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<Note> 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<Note> remoteNotes = response.getNotes();
final Set<Long> 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<List<Note>> fetchResponse = notesClient.getNotesAPI().getNotes(localAccount.getModified(), localAccount.getETag()).execute();
if (fetchResponse.isSuccessful()) {
final List<Note> remoteNotes = fetchResponse.body();
final Set<Long> 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<Long, Long> 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<Long, Long> 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) {

View file

@ -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;

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);
}