diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/QueryFragmentTest.java b/app/src/androidTest/java/com/orgzly/android/espresso/QueryFragmentTest.java index ccb0f7e98..d9d6c95fc 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/QueryFragmentTest.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/QueryFragmentTest.java @@ -649,6 +649,7 @@ public void testSortByPriorityDesc() { "* Note D\n"); scenario = ActivityScenario.launch(MainActivity.class); + SystemClock.sleep(200); onView(allOf(withText("notebook"), isDisplayed())).perform(click()); searchForTextCloseKeyboard(".o.p"); onView(withId(R.id.fragment_query_search_view_flipper)).check(matches(isDisplayed())); @@ -779,6 +780,7 @@ public void testDeSelectRemovedNoteInSearch() { public void testNoNotesFoundMessageIsDisplayedInSearch() { scenario = ActivityScenario.launch(MainActivity.class); searchForTextCloseKeyboard("Note"); + SystemClock.sleep(200); onView(withText(R.string.no_notes_found_after_search)).check(matches(isDisplayed())); } diff --git a/app/src/androidTest/java/com/orgzly/android/repos/SyncTest.java b/app/src/androidTest/java/com/orgzly/android/repos/SyncTest.java index d0189fc40..a07da5217 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/SyncTest.java +++ b/app/src/androidTest/java/com/orgzly/android/repos/SyncTest.java @@ -225,6 +225,28 @@ public void testOnlyBookWithLink() { assertEquals(BookSyncStatus.ONLY_BOOK_WITH_LINK.toString(), book.getBook().getSyncStatus()); } + @Test + public void testOnlyBookWithoutLinkAndOneRepo() { + testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); + testUtils.setupBook("book-1", "Content"); + testUtils.sync(); + + BookView book = dataRepository.getBooks().get(0); + assertEquals(BookSyncStatus.ONLY_BOOK_WITHOUT_LINK_AND_ONE_REPO.toString(), book.getBook().getSyncStatus()); + } + + @Test + public void testOnlyBookWithoutLinkAndMultipleRepos() { + testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); + testUtils.setupRepo(RepoType.MOCK, "mock://repo-b"); + testUtils.setupBook("book-1", "Content"); + testUtils.sync(); + + BookView book = dataRepository.getBooks().get(0); + assertEquals(BookSyncStatus.ONLY_BOOK_WITHOUT_LINK_AND_MULTIPLE_REPOS.toString(), + book.getBook().getSyncStatus()); + } + @Test public void testMultipleRooks() { Repo repoA = testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); @@ -467,4 +489,48 @@ public void testRenameSyncedBookWithDifferentLink() throws IOException { assertEquals("mock://repo-b", book.getLinkRepo().getUrl()); assertEquals("mock://repo-a/Booky.org", book.getSyncedTo().getUri().toString()); } + + /** + * We remove the local book's' syncedTo attribute and repository link when its remote file + * has been deleted, to make it easier to ascertain the book's state during subsequent sync + * attempts. + */ + @Test + public void testRemoteFileDeletion() throws IOException { + BookView book; + Repo repo = testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); + testUtils.setupRook(repo, "mock://repo-a/book.org", "", "1abcdef", 1400067156); + testUtils.sync(); + book = dataRepository.getBooks().get(0); + assertNotNull(book.getLinkRepo()); + assertNotNull(book.getSyncedTo()); + dbRepoBookRepository.deleteBook(Uri.parse("mock://repo-a/book.org")); + testUtils.sync(); + book = dataRepository.getBooks().get(0); + assertEquals(BookSyncStatus.ROOK_NO_LONGER_EXISTS.toString(), book.getBook().getSyncStatus()); + assertNull(book.getLinkRepo()); + assertNull(book.getSyncedTo()); + } + + /** + * The "remote file has been deleted" error status is only shown once, and then the book's + * repo link is removed. Subsequent syncing of the same book should result in a more general + * message, indicating that the user may sync the book again by explicitly setting a repo link. + */ + @Test + public void testBookStatusAfterMultipleSyncsFollowingRemoteFileDeletion() throws IOException { + BookView book; + Repo repo = testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); + testUtils.setupRook(repo, "mock://repo-a/book.org", "", "1abcdef", 1400067156); + testUtils.sync(); + book = dataRepository.getBooks().get(0); + assertEquals(BookSyncStatus.DUMMY_WITHOUT_LINK_AND_ONE_ROOK.toString(), book.getBook().getSyncStatus()); + + dbRepoBookRepository.deleteBook(Uri.parse("mock://repo-a/book.org")); + testUtils.sync(); + testUtils.sync(); + book = dataRepository.getBooks().get(0); + assertNull(book.getLinkRepo()); + assertEquals(BookSyncStatus.BOOK_WITH_PREVIOUS_ERROR_AND_NO_LINK.toString(), book.getBook().getSyncStatus()); + } } diff --git a/app/src/main/java/com/orgzly/android/data/DataRepository.kt b/app/src/main/java/com/orgzly/android/data/DataRepository.kt index f427efb05..5b8067594 100644 --- a/app/src/main/java/com/orgzly/android/data/DataRepository.kt +++ b/app/src/main/java/com/orgzly/android/data/DataRepository.kt @@ -440,6 +440,10 @@ class DataRepository @Inject constructor( db.bookSync().upsert(bookId, versionedRookId) } + fun removeBookSyncedTo(bookId: Long) { + db.bookSync().delete(bookId) + } + private fun updateBookIsModified(bookId: Long, isModified: Boolean, time: Long = System.currentTimeMillis()) { updateBookIsModified(setOf(bookId), isModified, time) } diff --git a/app/src/main/java/com/orgzly/android/db/dao/BookSyncDao.kt b/app/src/main/java/com/orgzly/android/db/dao/BookSyncDao.kt index b4cc91def..17aebabeb 100644 --- a/app/src/main/java/com/orgzly/android/db/dao/BookSyncDao.kt +++ b/app/src/main/java/com/orgzly/android/db/dao/BookSyncDao.kt @@ -8,6 +8,9 @@ interface BookSyncDao : BaseDao { @Query("SELECT * FROM book_syncs WHERE book_id = :bookId") fun get(bookId: Long): BookSync? + @Query("DELETE FROM book_syncs WHERE book_id = :bookId") + fun delete(bookId: Long) + @Transaction fun upsert(bookId: Long, versionedRookId: Long) { val sync = get(bookId) diff --git a/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java b/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java index 632961742..625b1f557 100644 --- a/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java +++ b/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java @@ -307,6 +307,16 @@ public static void remindersForEventsEnabled(Context context, boolean value) { getDefaultSharedPreferences(context).edit().putBoolean(key, value).apply(); } + public static boolean anyNotificationsEnabled(Context context) { + return ( + showSyncNotifications(context) || + newNoteNotification(context) || + remindersForScheduledEnabled(context) || + remindersForDeadlineEnabled(context) || + remindersForEventsEnabled(context) + ); + } + public static boolean remindersSound(Context context) { return getDefaultSharedPreferences(context).getBoolean( context.getResources().getString(R.string.pref_key_reminders_sound), diff --git a/app/src/main/java/com/orgzly/android/sync/BookNamesake.java b/app/src/main/java/com/orgzly/android/sync/BookNamesake.java index 14e320207..a5c3271e1 100644 --- a/app/src/main/java/com/orgzly/android/sync/BookNamesake.java +++ b/app/src/main/java/com/orgzly/android/sync/BookNamesake.java @@ -3,6 +3,7 @@ import android.content.Context; import com.orgzly.android.BookName; +import com.orgzly.android.db.entity.BookAction; import com.orgzly.android.db.entity.BookView; import com.orgzly.android.db.entity.Repo; import com.orgzly.android.repos.VersionedRook; @@ -116,7 +117,6 @@ public String toString() { * - Book has a last-synced-with remote book * - Remote book exists */ - /* TODO: Case: Remote book deleted? */ public void updateStatus(int reposCount) { /* Sanity check. Group's name must come from somewhere - local or remote books. */ if (book == null && versionedRooks.isEmpty()) { @@ -142,17 +142,29 @@ public void updateStatus(int reposCount) { } else { if (book.hasLink()) { /* Only local book with a link. */ - status = BookSyncStatus.ONLY_BOOK_WITH_LINK; - + if (book.hasSync()) { + // Book was previously synced with a remote book which no longer exists. + status = BookSyncStatus.ROOK_NO_LONGER_EXISTS; + } else { + // Book is linked to a repo, but not yet synced. + status = BookSyncStatus.ONLY_BOOK_WITH_LINK; + } } else { /* Only local book without link. */ if (reposCount > 1) { status = BookSyncStatus.ONLY_BOOK_WITHOUT_LINK_AND_MULTIPLE_REPOS; - } else { - status = BookSyncStatus.ONLY_BOOK_WITHOUT_LINK_AND_ONE_REPO; - } // TODO: What about no repos? + } else { // Only one repository configured + BookAction lastAction = book.getBook().getLastAction(); + if (lastAction != null && lastAction.getType() == BookAction.Type.ERROR) { + // Book is an error state. + status = BookSyncStatus.BOOK_WITH_PREVIOUS_ERROR_AND_NO_LINK; + } else { + // Book is not in an error state (automatic linking may be + // attempted). + status = BookSyncStatus.ONLY_BOOK_WITHOUT_LINK_AND_ONE_REPO; + } + } } } - return; } diff --git a/app/src/main/java/com/orgzly/android/sync/BookSyncStatus.kt b/app/src/main/java/com/orgzly/android/sync/BookSyncStatus.kt index d590c335f..ed76ccae9 100644 --- a/app/src/main/java/com/orgzly/android/sync/BookSyncStatus.kt +++ b/app/src/main/java/com/orgzly/android/sync/BookSyncStatus.kt @@ -14,6 +14,7 @@ enum class BookSyncStatus { BOOK_WITH_LINK_AND_ROOK_EXISTS_BUT_LINK_POINTING_TO_DIFFERENT_ROOK, ONLY_DUMMY, ROOK_AND_VROOK_HAVE_DIFFERENT_REPOS, + BOOK_WITH_PREVIOUS_ERROR_AND_NO_LINK, /* Conflict. */ CONFLICT_BOTH_BOOK_AND_ROOK_MODIFIED, @@ -29,7 +30,10 @@ enum class BookSyncStatus { /* Book can be saved. */ ONLY_BOOK_WITHOUT_LINK_AND_ONE_REPO, BOOK_WITH_LINK_LOCAL_MODIFIED, - ONLY_BOOK_WITH_LINK; + ONLY_BOOK_WITH_LINK, + + /* Previously synced remote book no longer exists. */ + ROOK_NO_LONGER_EXISTS; // TODO: Extract string resources @JvmOverloads @@ -49,7 +53,7 @@ enum class BookSyncStatus { return context.getString(R.string.sync_status_no_book_multiple_rooks) ONLY_BOOK_WITHOUT_LINK_AND_MULTIPLE_REPOS -> - return "Notebook has no link and multiple repositories exist" + return context.getString(R.string.sync_status_no_link_and_multiple_repos) BOOK_WITH_LINK_AND_ROOK_EXISTS_BUT_LINK_POINTING_TO_DIFFERENT_ROOK -> return "Notebook has link and remote notebook with the same name exists, but link is pointing to a different remote notebook which does not exist" @@ -60,6 +64,9 @@ enum class BookSyncStatus { ROOK_AND_VROOK_HAVE_DIFFERENT_REPOS -> return "Linked and synced notebooks have different repositories" + BOOK_WITH_PREVIOUS_ERROR_AND_NO_LINK -> + return context.getString(R.string.sync_status_no_link_and_previous_error) + CONFLICT_BOTH_BOOK_AND_ROOK_MODIFIED -> return "Both local and remote notebook have been modified" @@ -75,6 +82,9 @@ enum class BookSyncStatus { ONLY_BOOK_WITHOUT_LINK_AND_ONE_REPO, BOOK_WITH_LINK_LOCAL_MODIFIED, ONLY_BOOK_WITH_LINK -> return context.getString(R.string.sync_status_saved, "$arg") + ROOK_NO_LONGER_EXISTS -> + return context.getString(R.string.sync_status_rook_no_longer_exists) + else -> throw IllegalArgumentException("Unknown sync status " + this) } diff --git a/app/src/main/java/com/orgzly/android/sync/SyncUtils.kt b/app/src/main/java/com/orgzly/android/sync/SyncUtils.kt index f0240cf5c..8d36f1873 100644 --- a/app/src/main/java/com/orgzly/android/sync/SyncUtils.kt +++ b/app/src/main/java/com/orgzly/android/sync/SyncUtils.kt @@ -31,10 +31,8 @@ object SyncUtils { for (repo in repoList) { if (repo is GitRepo && repo.isUnchanged) { for (book in dataRepository.getBooks()) { - if (book.hasLink()) { - if (book.linkRepo!!.url == repo.uri.toString()) { - result.add(book.syncedTo!!) - } + if (book.hasLink() && book.linkRepo!!.url == repo.uri.toString() && book.hasSync()) { + result.add(book.syncedTo!!) } } if (result.isNotEmpty()) { @@ -114,6 +112,8 @@ object SyncUtils { BookSyncStatus.NO_CHANGE -> bookAction = BookAction.forNow(BookAction.Type.INFO, namesake.status.msg()) + /* Error states */ + BookSyncStatus.BOOK_WITHOUT_LINK_AND_ONE_OR_MORE_ROOKS_EXIST, BookSyncStatus.DUMMY_WITHOUT_LINK_AND_MULTIPLE_ROOKS, BookSyncStatus.NO_BOOK_MULTIPLE_ROOKS, @@ -123,9 +123,18 @@ object SyncUtils { BookSyncStatus.CONFLICT_BOOK_WITH_LINK_AND_ROOK_BUT_NEVER_SYNCED_BEFORE, BookSyncStatus.CONFLICT_LAST_SYNCED_ROOK_AND_LATEST_ROOK_ARE_DIFFERENT, BookSyncStatus.ROOK_AND_VROOK_HAVE_DIFFERENT_REPOS, - BookSyncStatus.ONLY_DUMMY -> + BookSyncStatus.ONLY_DUMMY, + BookSyncStatus.BOOK_WITH_PREVIOUS_ERROR_AND_NO_LINK -> bookAction = BookAction.forNow(BookAction.Type.ERROR, namesake.status.msg()) + BookSyncStatus.ROOK_NO_LONGER_EXISTS -> { + /* Remove repository link and "synced to" information. User must set a repo link if + * they want to keep the book and sync it. */ + dataRepository.setLink(namesake.book.book.id, null) + dataRepository.removeBookSyncedTo(namesake.book.book.id) + bookAction = BookAction.forNow(BookAction.Type.ERROR, namesake.status.msg()) + } + /* Load remote book. */ BookSyncStatus.NO_BOOK_ONE_ROOK, BookSyncStatus.DUMMY_WITHOUT_LINK_AND_ONE_ROOK -> { @@ -169,8 +178,6 @@ object SyncUtils { } } - // if (BuildConfig.LOG_DEBUG) LogUtils.d(TAG, "Syncing $namesake: $bookAction") - return bookAction } @@ -214,4 +221,4 @@ object SyncUtils { } return noNewMergeConflicts } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/orgzly/android/ui/main/MainActivity.java b/app/src/main/java/com/orgzly/android/ui/main/MainActivity.java index 2cb9d9617..41beab1c1 100644 --- a/app/src/main/java/com/orgzly/android/ui/main/MainActivity.java +++ b/app/src/main/java/com/orgzly/android/ui/main/MainActivity.java @@ -6,6 +6,7 @@ import android.content.IntentFilter; import android.content.res.Configuration; import android.net.Uri; +import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.view.MenuItem; @@ -71,6 +72,7 @@ import com.orgzly.android.usecase.UseCase; import com.orgzly.android.usecase.UseCaseResult; import com.orgzly.android.usecase.UseCaseWorker; +import com.orgzly.android.util.AppPermissions; import com.orgzly.android.util.LogUtils; import com.orgzly.org.datetime.OrgDateTime; @@ -143,6 +145,13 @@ protected void onCreate(Bundle savedInstanceState) { setupDisplay(savedInstanceState); + if (AppPreferences.anyNotificationsEnabled(this)) { + if (Build.VERSION.SDK_INT >= 33) { + // Ensure we have the POST_NOTIFICATIONS permission + AppPermissions.isGrantedOrRequest(this, AppPermissions.Usage.POST_NOTIFICATIONS); + } + } + if (AppPreferences.newNoteNotification(this)) { Notifications.showOngoingNotification(this); } diff --git a/app/src/main/java/com/orgzly/android/ui/repo/git/GitRepoActivity.kt b/app/src/main/java/com/orgzly/android/ui/repo/git/GitRepoActivity.kt index 575d5a157..9a573e6ac 100644 --- a/app/src/main/java/com/orgzly/android/ui/repo/git/GitRepoActivity.kt +++ b/app/src/main/java/com/orgzly/android/ui/repo/git/GitRepoActivity.kt @@ -211,7 +211,7 @@ class GitRepoActivity : CommonActivity(), GitPreferences { // Using SSH transport requires notification permission (for server key verification) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { AppPermissions.isGrantedOrRequest( - App.getCurrentActivity(), + this, AppPermissions.Usage.POST_NOTIFICATIONS ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 84a81d6fc..8c702f5f2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -89,7 +89,7 @@ SSH key generation Generate key pair for Git repo sync View generated SSH public key - No link set + Notebook has no repository link Note created Failed to create note Failed to update note @@ -772,9 +772,12 @@ No change + Notebook has no link and multiple repositories exist Notebook has no link and one or more remote notebooks with the same name exist Notebook has no link and multiple remote notebooks with the same name exist No notebook and multiple remote notebooks with the same name exist + Remote file no longer exists; repository link removed. Set a link if you want to sync to a repository. + Notebook has no repository link Loaded from %s Saved to %s