diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java b/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java index c59cabb9aa8672bfacd42a1c8779e109e608239a..9843e4cabc4ad57e5a11711d7b5cb25d9faccdf6 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java @@ -4,28 +4,20 @@ import static android.os.Build.VERSION.SDK_INT; import static android.os.Build.VERSION_CODES.O; import static android.view.View.GONE; import static android.view.View.VISIBLE; -import static it.niedermann.owncloud.notes.NotesApplication.isDarkThemeActive; import static it.niedermann.owncloud.notes.NotesApplication.isGridViewEnabled; -import static it.niedermann.owncloud.notes.branding.BrandingUtil.getSecondaryForegroundColorDependingOnTheme; import static it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType.DEFAULT_CATEGORY; import static it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType.FAVORITES; import static it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType.RECENT; import static it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType.UNCATEGORIZED; -import static it.niedermann.owncloud.notes.shared.util.NotesColorUtil.contrastRatioIsSufficient; import static it.niedermann.owncloud.notes.shared.util.SSOUtil.askForNewAccount; import android.accounts.NetworkErrorException; import android.animation.AnimatorInflater; import android.app.SearchManager; import android.content.Intent; -import android.graphics.Color; -import android.graphics.PorterDuff; import android.net.Uri; import android.os.Bundle; import android.text.TextUtils; - -import it.niedermann.owncloud.notes.importaccount.ImportMurenaAccountViewModel; -import trikita.log.Log; import android.view.View; import androidx.annotation.NonNull; @@ -80,6 +72,7 @@ import it.niedermann.owncloud.notes.edit.category.CategoryViewModel; import it.niedermann.owncloud.notes.exception.ExceptionDialogFragment; import it.niedermann.owncloud.notes.exception.IntendedOfflineException; import it.niedermann.owncloud.notes.importaccount.ImportAccountActivity; +import it.niedermann.owncloud.notes.importaccount.ImportMurenaAccountViewModel; import it.niedermann.owncloud.notes.main.items.ItemAdapter; import it.niedermann.owncloud.notes.main.items.grid.GridItemDecoration; import it.niedermann.owncloud.notes.main.items.list.NotesListViewItemTouchHelper; @@ -101,6 +94,7 @@ import it.niedermann.owncloud.notes.shared.model.NoteClickListener; import it.niedermann.owncloud.notes.shared.util.CustomAppGlideModule; import it.niedermann.owncloud.notes.shared.util.NoteUtil; import it.niedermann.owncloud.notes.shared.util.ShareUtil; +import trikita.log.Log; public class MainActivity extends LockedActivity implements NoteClickListener, AccountPickerListener, AccountSwitcherListener, CategoryDialogFragment.CategoryDialogListener { diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/MainViewModel.java b/app/src/main/java/it/niedermann/owncloud/notes/main/MainViewModel.java index 0f1d3631b5899ed3e99d2ec7713931f414385fcd..95639fabdfe682a550d32d4f64442df38a7455af 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/main/MainViewModel.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/MainViewModel.java @@ -20,7 +20,6 @@ import android.accounts.NetworkErrorException; import android.app.Application; import android.content.Context; import android.text.TextUtils; -import trikita.log.Log; import android.util.Pair; import androidx.annotation.MainThread; @@ -66,6 +65,7 @@ import it.niedermann.owncloud.notes.shared.model.IResponseCallback; import it.niedermann.owncloud.notes.shared.model.ImportStatus; import it.niedermann.owncloud.notes.shared.model.Item; import it.niedermann.owncloud.notes.shared.model.NavigationCategory; +import trikita.log.Log; public class MainViewModel extends AndroidViewModel { @@ -667,5 +667,4 @@ public class MainViewModel extends AndroidViewModel { public void migrateOldNotes(@NonNull Account account) { repo.migrateOldNotesIfPossible(account.getId()); } - } \ No newline at end of file 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 06f78aef0278e0f575643a2575c36b62f0461c18..edd75950e815d050d852e125eab0c9750d54dd2c 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 @@ -4,6 +4,7 @@ import static android.os.Build.VERSION.SDK_INT; import static android.os.Build.VERSION_CODES.O; import static androidx.lifecycle.Transformations.distinctUntilChanged; import static androidx.lifecycle.Transformations.map; +import static java.util.stream.Collectors.groupingBy; import static java.util.stream.Collectors.toMap; import static it.niedermann.owncloud.notes.edit.EditNoteActivity.ACTION_SHORTCUT; import static it.niedermann.owncloud.notes.shared.util.NoteUtil.generateNoteExcerpt; @@ -43,9 +44,11 @@ import com.nextcloud.android.sso.model.SingleSignOnAccount; import java.util.ArrayList; import java.util.Calendar; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -81,6 +84,7 @@ import retrofit2.Call; public class NotesRepository { private static final String PREF_KEY_MIGRATION_DONE = "old_note_migration_done"; + private static final String PREF_KEY_REMOTE_CONFLICT_RESOLVED = "remote_conflict_resolved"; private static final String TAG = NotesRepository.class.getSimpleName(); @@ -465,6 +469,15 @@ public class NotesRepository { .collect(toMap(Note::getRemoteId, Note::getId)); } + @NonNull + @WorkerThread + public Map> getNoteMapByRemoteId(long accountId) { + return db.getNoteDao() + .getActiveNoteList(accountId) + .stream() + .collect(groupingBy(Note::getRemoteId)); + } + @AnyThread public void toggleFavoriteAndSync(Account account, long noteId) { executor.submit(() -> { @@ -1005,7 +1018,7 @@ public class NotesRepository { } private boolean isNoteAlreadyExistsInNewTable(@NonNull OldNote oldNote) { - return db.getNoteDao().countByTitleAndContent(oldNote.getTitle(), oldNote.getContent()) > 0; + return db.getNoteDao().countByTitleAndContent(oldNote.getTitle(), oldNote.getContent(), oldNote.getRemoteId()) > 0; } private void addNewNote(long accountId, @NonNull OldNote oldNote) { @@ -1026,4 +1039,114 @@ public class NotesRepository { .putBoolean(PREF_KEY_MIGRATION_DONE, true) .apply(); } + + private boolean isRemoteIdConflictResolved() { + final var sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext()); + return sharedPreferences.getBoolean(PREF_KEY_REMOTE_CONFLICT_RESOLVED, false); + } + + private void setRemoteIdConflictResolved() { + final var sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext()); + sharedPreferences.edit() + .putBoolean(PREF_KEY_REMOTE_CONFLICT_RESOLVED, true) + .apply(); + } + + @WorkerThread + public void removeDuplicateRemoteIds() { + if (isRemoteIdConflictResolved()) { + return; + } + + List accounts = getAccounts(); + + for (Account account : accounts) { + if (account == null) { + continue; + } + + removeDuplicateNotesForAccount(account); + } + + setRemoteIdConflictResolved(); + } + + @WorkerThread + private void removeDuplicateNotesForAccount(@NonNull Account account) { + Map> noteMap = getNoteMapByRemoteId(account.getId()); + + for (Map.Entry> mapEntry : noteMap.entrySet()) { + if (doNotHaveDuplicateNote(mapEntry)) { + continue; + } + + removeConflictedNotes(mapEntry.getValue()); + } + } + + private boolean doNotHaveDuplicateNote(@NonNull Map.Entry> mapEntry) { + return mapEntry.getValue().size() <= 1; + } + + /* + * Preserve the first note & remove the duplicates. + * If notes have different content texts, concat them in the preserve note, so user will not loss any content. + */ + @WorkerThread + private void removeConflictedNotes(@NonNull List noteList) { + Note preservedNote = noteList.get(0); + + Set contentSet = new HashSet<>(); + contentSet.add(preservedNote.getContent()); + + int contentCounter = 1; + + for (int i = 1; i < noteList.size(); i++) { + Note note = noteList.get(i); + + db.getNoteDao().deleteByNoteId(note.getId(), note.getStatus()); + + if (contentSet.contains(note.getContent())) { + continue; + } + + contentSet.add(note.getContent()); + contentCounter = updatePreservedNoteContent(contentCounter, preservedNote, note); + } + + updateNoteForConflict(contentCounter - 1, preservedNote); + } + + private int updatePreservedNoteContent(int position, @NonNull Note preservedNote, @NonNull Note note) { + String content = getPreviousContentForConflict(position, preservedNote); + content += getConflictedContent(++position, note); + preservedNote.setContent(content); + return position; + } + + @NonNull + private String getConflictedContent(int position, @NonNull Note note) { + String modified = note.getModified().getTime().toString(); + return context.getString(R.string.conflicted_note_content_title, position, modified) + + note.getContent(); + } + + @NonNull + private String getPreviousContentForConflict(int position, @NonNull Note note) { + if (position > 1) { + return note.getContent(); + } + + return context.getString(R.string.conflict_note_detected) + + getConflictedContent(position, note); + } + + @WorkerThread + private void updateNoteForConflict(int numberOfConflicts, @NonNull Note note) { + if (numberOfConflicts > 0) { + note.setStatus(DBStatus.LOCAL_EDITED); + note.setModified(Calendar.getInstance()); + db.getNoteDao().updateNote(note); + } + } } 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 b46c39f40309ee1cba592c473ca37d2183e230c6..c53f17e2c089fc20d9ef929bdb3baf86fef3e127 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 @@ -205,6 +205,8 @@ abstract class NotesServerSyncTask extends Thread { private boolean pullRemoteChanges() { Log.d(TAG, "pullRemoteChanges() for account " + localAccount.getAccountName()); try { + repo.removeDuplicateRemoteIds(); + final var idMap = repo.getIdMap(localAccount.getId()); // FIXME re-reading the localAccount is only a workaround for a not-up-to-date eTag in localAccount. diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/OldNoteRetriever.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/OldNoteRetriever.java index 2e2e6a7d2276494083a374ad84b9d28c2793e085..75d1c82e25b12ced89b38a4686f1fac81a36d823 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/OldNoteRetriever.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/OldNoteRetriever.java @@ -70,8 +70,8 @@ public final class OldNoteRetriever { private static boolean isOldNoteTableExists(@NonNull SupportSQLiteDatabase supportSQLiteDatabase) { final Cursor tableExistCursor = supportSQLiteDatabase.query("SELECT name FROM sqlite_master WHERE type='table' AND name='" + OLD_NOTES_TABLE_NAME + "'"); final boolean tableExists = tableExistCursor.moveToNext(); - tableExistCursor.close(); - return tableExists; + tableExistCursor.close(); + return tableExists; } @NonNull diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/NoteDao.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/NoteDao.java index ce1475ccf44b1bf73086ffba0864b4e16c7e2c4c..245d34e9de1f97f15bc115a809330b4aee1e2dcb 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/NoteDao.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/NoteDao.java @@ -138,6 +138,9 @@ public interface NoteDao { @Query("SELECT id, remoteId, 0 as accountId, '' as title, 0 as favorite, '' as excerpt, 0 as modified, '' as eTag, 0 as status, '' as category, '' as content, 0 as scrollY FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED' AND remoteId IS NOT NULL") List getRemoteIdAndId(long accountId); + @Query("SELECT * FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED' AND remoteId IS NOT NULL") + List getActiveNoteList(long accountId); + /** * Get a single {@link Note} by {@link Note#remoteId} (aka. Nextcloud file id) * @@ -194,6 +197,6 @@ public interface NoteDao { @Query("SELECT COUNT(*) FROM NOTE WHERE STATUS != '' AND accountId = :accountId") Long countUnsynchronizedNotes(long accountId); - @Query("SELECT COUNT(*) FROM NOTE WHERE title = :title AND content = :content") - Long countByTitleAndContent(String title, String content); + @Query("SELECT COUNT(*) FROM NOTE WHERE (title = :title AND content = :content) OR remoteId = :remoteId") + Long countByTitleAndContent(String title, String content, long remoteId); } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0ae7db8410b088ac7a03520ded6c3e9193e04f8b..29be49b2c8941391a4826ba5867c97ac47387b28 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -367,4 +367,6 @@ Privacy policy Terms of service Authors + # We faced a conflict between your notes. Please choose the proper one. + \n\n***Note %1$d (Last modified: %2$s):***\n