diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index bb51aa23d..f58715c18 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -22,7 +22,41 @@ on: workflow_dispatch: jobs: - test: + + localUnitTests: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Gradle build Fdroid + run: ./gradlew assembleFdroidDebug + + - name: Gradle test Fdroid + run: ./gradlew testFdroidDebugUnitTest + + - name: Add Dropbox API credentials (for DropboxRepo tests) + shell: bash + run: | + echo "dropbox.refresh_token = \"${{ secrets.DROPBOX_REFRESH_TOKEN }}\"" >> app.properties + echo "dropbox.app_key = \"${{ secrets.DROPBOX_APP_KEY }}\"" >> app.properties + + - name: Gradle test Premium + run: ./gradlew testPremiumDebugUnitTest + + instrumentedTests: runs-on: ubuntu-latest strategy: matrix: @@ -39,6 +73,12 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + - name: Setup Gradle uses: gradle/actions/setup-gradle@v3 @@ -80,7 +120,7 @@ jobs: echo "dropbox.refresh_token = \"${{ secrets.DROPBOX_REFRESH_TOKEN }}\"" >> app.properties echo "dropbox.app_key = \"${{ secrets.DROPBOX_APP_KEY }}\"" >> app.properties - - name: Run tests + - name: Run instrumented tests uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ matrix.api-level }} @@ -91,4 +131,4 @@ jobs: disable-spellchecker: true profile: Nexus 6 # Tests should use the build which includes Dropbox code. - script: ./gradlew connected${{matrix.flavor}}DebugAndroidTest --no-watch-fs --build-cache --info + script: ./gradlew --no-configuration-cache connected${{matrix.flavor}}DebugAndroidTest --no-watch-fs --build-cache --info diff --git a/app/build.gradle b/app/build.gradle index 616f0326f..fb93273ec 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -38,9 +38,11 @@ android { viewBinding true } -// testOptions { -// execution 'ANDROIDX_TEST_ORCHESTRATOR' -// } + testOptions { + unitTests { + includeAndroidResources = true + } + } buildTypes { release { @@ -91,12 +93,12 @@ android { } compileOptions { - sourceCompatibility JavaVersion.VERSION_11 - targetCompatibility JavaVersion.VERSION_11 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = 11 + jvmTarget = 17 } packagingOptions { @@ -114,7 +116,7 @@ android { dependencies { implementation orgJavaLocation() - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:$versions.kotlin_coroutines") + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$versions.kotlin_coroutines" implementation "org.jetbrains:annotations:$versions.jetbrains_annotations" @@ -136,7 +138,6 @@ dependencies { // Room implementation "androidx.room:room-runtime:$versions.android_room" - testImplementation "androidx.room:room-testing:$versions.android_room" kapt "androidx.room:room-compiler:$versions.android_room" implementation("androidx.room:room-ktx:$versions.android_room") @@ -145,32 +146,31 @@ dependencies { implementation("androidx.lifecycle:lifecycle-livedata-ktx:$versions.android_lifecycle") implementation("androidx.lifecycle:lifecycle-runtime-ktx:$versions.android_lifecycle") - androidTestImplementation "androidx.annotation:annotation:$versions.android_annotation" - implementation "androidx.work:work-runtime-ktx:$versions.android_workmanager" - testImplementation "junit:junit:$versions.junit" + // Local JVM tests ("unit tests") + testImplementation(project(":shared-test")) + testImplementation "androidx.test.ext:junit:$versions.android_test_ext_junit" + testImplementation "org.robolectric:robolectric:$versions.robolectric" + testImplementation "io.github.atetzner:webdav-embedded-server:$versions.webdav_embedded_server" - // AndroidX Test + // Android instrumented tests + androidTestImplementation(project(":shared-test")) androidTestImplementation "androidx.test.espresso:espresso-core:$versions.android_test_espresso" androidTestImplementation "androidx.test.espresso:espresso-contrib:$versions.android_test_espresso" androidTestImplementation "androidx.test.espresso:espresso-intents:$versions.android_test_espresso" androidTestImplementation "androidx.test:runner:$versions.android_test" androidTestImplementation "androidx.test:rules:$versions.android_test" androidTestImplementation "androidx.test.ext:junit:$versions.android_test_ext_junit" + androidTestImplementation "androidx.test.uiautomator:uiautomator:$versions.android_test_uiautomator" + androidTestImplementation "de.sven-jacobs:loremipsum:$versions.loremipsum" + androidTestImplementation "androidx.annotation:annotation:$versions.android_annotation" /* For running tests on lower API versions (e.g. 18) to avoid: * Didn't find class "androidx.test.core.app.InstrumentationActivityInvoker$BootstrapActivity" */ implementation "androidx.test:core:$versions.android_test" - // For ANDROIDX_TEST_ORCHESTRATOR - // androidTestUtil "androidx.test:orchestrator:$android_test_version" - - androidTestImplementation "androidx.test.uiautomator:uiautomator:$versions.android_test_uiautomator" - - androidTestImplementation "de.sven-jacobs:loremipsum:$versions.loremipsum" - // Dagger implementation "com.google.dagger:dagger:$versions.dagger" kapt "com.google.dagger:dagger-compiler:$versions.dagger" diff --git a/app/src/androidTest/java/com/orgzly/android/OrgzlyTest.java b/app/src/androidTest/java/com/orgzly/android/OrgzlyTest.java index dea5ce2e8..42c7256ae 100644 --- a/app/src/androidTest/java/com/orgzly/android/OrgzlyTest.java +++ b/app/src/androidTest/java/com/orgzly/android/OrgzlyTest.java @@ -4,7 +4,6 @@ import android.Manifest; import android.app.Activity; -import android.app.UiAutomation; import android.content.Context; import android.content.Intent; import android.content.pm.PackageInfo; @@ -20,7 +19,6 @@ import com.orgzly.android.repos.RepoFactory; import com.orgzly.android.util.UserTimeFormatter; import com.orgzly.org.datetime.OrgDateTime; -import com.orgzly.test.BuildConfig; import org.junit.After; import org.junit.Before; diff --git a/app/src/androidTest/java/com/orgzly/android/TestUtils.java b/app/src/androidTest/java/com/orgzly/android/TestUtils.java index 07eb863a3..0da43deb3 100644 --- a/app/src/androidTest/java/com/orgzly/android/TestUtils.java +++ b/app/src/androidTest/java/com/orgzly/android/TestUtils.java @@ -46,12 +46,21 @@ public SyncRepo repoInstance(RepoType type, String url) { return dataRepository.getRepoInstance(13, type, url); } + public SyncRepo repoInstance(RepoType type, String url, Long id) { + return dataRepository.getRepoInstance(id, type, url); + } + public Repo setupRepo(RepoType type, String url) { long id = dataRepository.createRepo(new RepoWithProps(new Repo(0, type, url))); return dataRepository.getRepo(id); } + public Repo setupRepo(RepoType type, String url, Map props) { + long id = dataRepository.createRepo(new RepoWithProps(new Repo(0, type, url), props)); + return dataRepository.getRepo(id); + } + public void deleteRepo(String url) { Repo repo = dataRepository.getRepo(url); if (repo != null) { diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/BookPrefaceTest.kt b/app/src/androidTest/java/com/orgzly/android/espresso/BookPrefaceTest.kt index 8ed959efd..c9e61188d 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/BookPrefaceTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/espresso/BookPrefaceTest.kt @@ -84,8 +84,8 @@ class BookPrefaceTest : OrgzlyTest() { private fun setPrefaceSetting(@StringRes id: Int) { onActionItemClick(R.id.activity_action_settings, R.string.settings) - clickSetting("prefs_screen_notebooks", R.string.pref_title_notebooks) - clickSetting("pref_key_preface_in_book", R.string.preface_in_book) + clickSetting(R.string.pref_title_notebooks) + clickSetting(R.string.preface_in_book) onView(withText(id)).perform(click()) diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/BooksSortOrderTest.kt b/app/src/androidTest/java/com/orgzly/android/espresso/BooksSortOrderTest.kt index f88468432..f3d546c08 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/BooksSortOrderTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/espresso/BooksSortOrderTest.kt @@ -60,8 +60,8 @@ class BooksSortOrderTest : OrgzlyTest() { private fun setBooksSortOrder(@StringRes id: Int) { onActionItemClick(R.id.activity_action_settings, R.string.settings) - clickSetting("prefs_screen_notebooks", R.string.pref_title_notebooks) - clickSetting("pref_key_notebooks_sort_order", R.string.sort_order) + clickSetting(R.string.pref_title_notebooks) + clickSetting(R.string.sort_order) onData(hasToString(context.getString(id))).perform(click()) pressBack() pressBack() diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/CreatedAtPropertyTest.java b/app/src/androidTest/java/com/orgzly/android/espresso/CreatedAtPropertyTest.java index 93d88d82c..79791febb 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/CreatedAtPropertyTest.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/CreatedAtPropertyTest.java @@ -136,8 +136,8 @@ public void testNewNote() { private void enableCreatedAt() { onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_sync", R.string.sync); - clickSetting("pref_key_is_created_at_added", R.string.use_created_at_property); + clickSetting(R.string.sync); + clickSetting(R.string.use_created_at_property); onView(withText(R.string.yes)).perform(click()); pressBack(); pressBack(); @@ -145,8 +145,8 @@ private void enableCreatedAt() { private void changeCreatedAtProperty(String propName) { onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_sync", R.string.sync); - clickSetting("pref_key_created_at_property", R.string.created_at_property); + clickSetting(R.string.sync); + clickSetting(R.string.created_at_property); onView(instanceOf(EditText.class)).perform(replaceTextCloseKeyboard(propName)); onView(withText(android.R.string.ok)).perform(click()); onView(withText(R.string.yes)).perform(click()); diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/MiscTest.java b/app/src/androidTest/java/com/orgzly/android/espresso/MiscTest.java index f3ab040de..1c7ba9bf6 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/MiscTest.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/MiscTest.java @@ -108,8 +108,8 @@ public void testClearDatabaseWithFragmentsInBackStack() { onView(allOf(withText("book-two"), isDisplayed())).perform(click()); onView(withText("Note #2.")).perform(click()); onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_app", R.string.app); - clickSetting("pref_key_clear_database", R.string.clear_database); + clickSetting(R.string.app); + clickSetting(R.string.clear_database); onView(withText(R.string.ok)).perform(click()); pressBack(); pressBack(); diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/NoteEventsTest.kt b/app/src/androidTest/java/com/orgzly/android/espresso/NoteEventsTest.kt index 4eaa46e14..d037f594d 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/NoteEventsTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/espresso/NoteEventsTest.kt @@ -1,16 +1,25 @@ package com.orgzly.android.espresso -import android.os.SystemClock import android.icu.util.Calendar +import android.os.SystemClock import androidx.test.core.app.ActivityScenario import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.action.ViewActions.longClick import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.matcher.ViewMatchers.* +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText import com.orgzly.R import com.orgzly.android.OrgzlyTest -import com.orgzly.android.espresso.util.EspressoUtils.* +import com.orgzly.android.espresso.util.EspressoUtils.onBook +import com.orgzly.android.espresso.util.EspressoUtils.onItemInAgenda +import com.orgzly.android.espresso.util.EspressoUtils.onNoteInBook +import com.orgzly.android.espresso.util.EspressoUtils.onNoteInSearch +import com.orgzly.android.espresso.util.EspressoUtils.onNotesInAgenda +import com.orgzly.android.espresso.util.EspressoUtils.onNotesInSearch +import com.orgzly.android.espresso.util.EspressoUtils.recyclerViewItemCount +import com.orgzly.android.espresso.util.EspressoUtils.searchForTextCloseKeyboard import com.orgzly.android.ui.main.MainActivity import com.orgzly.org.datetime.OrgDateTime import org.hamcrest.Matchers.not @@ -117,6 +126,7 @@ class NoteEventsTest : OrgzlyTest() { scenario = ActivityScenario.launch(MainActivity::class.java) searchForTextCloseKeyboard("ad.1") + SystemClock.sleep(500) onNotesInAgenda().check(matches(recyclerViewItemCount(2))) } diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/NoteFragmentTest.kt b/app/src/androidTest/java/com/orgzly/android/espresso/NoteFragmentTest.kt index 8a0d73b20..236b3b14d 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/NoteFragmentTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/espresso/NoteFragmentTest.kt @@ -419,8 +419,8 @@ class NoteFragmentTest : OrgzlyTest() { /* Change lowest priority to A. */ onActionItemClick(R.id.activity_action_settings, R.string.settings) - clickSetting("prefs_screen_notebooks", R.string.pref_title_notebooks) - clickSetting("pref_key_min_priority", R.string.lowest_priority) + clickSetting(R.string.pref_title_notebooks) + clickSetting(R.string.lowest_priority) onData(hasToString(containsString("A"))).perform(click()) pressBack() pressBack() @@ -431,8 +431,8 @@ class NoteFragmentTest : OrgzlyTest() { /* Change lowest priority to C. */ onActionItemClick(R.id.activity_action_settings, R.string.settings) - clickSetting("prefs_screen_notebooks", R.string.pref_title_notebooks) - clickSetting("pref_key_min_priority", R.string.lowest_priority) + clickSetting(R.string.pref_title_notebooks) + clickSetting(R.string.lowest_priority) onData(hasToString(containsString("C"))).perform(click()) pressBack() pressBack() 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 0afbd5ef3..ec429e787 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/QueryFragmentTest.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/QueryFragmentTest.java @@ -385,6 +385,7 @@ public void testInheritedAndOwnTag() { scenario = ActivityScenario.launch(MainActivity.class); onView(allOf(withText("notebook-1"), isDisplayed())).perform(click()); + SystemClock.sleep(200); searchForTextCloseKeyboard("t.tag1 t.tag2"); onView(withId(R.id.fragment_query_search_view_flipper)).check(matches(isDisplayed())); onNotesInSearch().check(matches(recyclerViewItemCount(3))); @@ -725,6 +726,7 @@ public void testSearchWithState() { scenario = ActivityScenario.launch(MainActivity.class); onView(allOf(withText("notebook"), isDisplayed())).perform(click()); + SystemClock.sleep(200); searchForTextCloseKeyboard(".it.none"); onView(withId(R.id.fragment_query_search_view_flipper)).check(matches(isDisplayed())); onNotesInSearch().check(matches(recyclerViewItemCount(3))); diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/SettingsChangeTest.java b/app/src/androidTest/java/com/orgzly/android/espresso/SettingsChangeTest.java index 6ed707392..b2861cf18 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/SettingsChangeTest.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/SettingsChangeTest.java @@ -86,8 +86,8 @@ public void testDisplayedContentInBook() { .check(matches(allOf(withText(containsString("Content for [a-1]")), isDisplayed()))); onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_notebooks", R.string.pref_title_notebooks); - clickSetting("pref_key_is_notes_content_displayed_in_list", R.string.display_content); + clickSetting(R.string.pref_title_notebooks); + clickSetting(R.string.display_content); pressBack(); pressBack(); @@ -96,8 +96,8 @@ public void testDisplayedContentInBook() { private void setDefaultPriority(String priority) { onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_notebooks", R.string.pref_title_notebooks); - clickSetting("pref_key_default_priority", R.string.default_priority); + clickSetting(R.string.pref_title_notebooks); + clickSetting(R.string.default_priority); onData(hasToString(containsString(priority))).perform(click()); pressBack(); pressBack(); diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/SettingsFragmentTest.java b/app/src/androidTest/java/com/orgzly/android/espresso/SettingsFragmentTest.java index afffa075e..6241cc7a3 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/SettingsFragmentTest.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/SettingsFragmentTest.java @@ -39,16 +39,16 @@ public void setUp() throws Exception { @Test public void testImportingGettingStartedFromGettingStartedNotebook() { onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_app", R.string.app); - clickSetting("pref_key_reload_getting_started", R.string.reload_getting_started); + clickSetting(R.string.app); + clickSetting(R.string.reload_getting_started); pressBack(); pressBack(); onView(withId(R.id.fragment_books_view_flipper)).check(matches(isDisplayed())); onView(allOf(withText(R.string.getting_started_notebook_name), isDisplayed())).perform(click()); onView(withId(R.id.fragment_book_view_flipper)).check(matches(isDisplayed())); onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_app", R.string.app); - clickSetting("pref_key_reload_getting_started", R.string.reload_getting_started); + clickSetting(R.string.app); + clickSetting(R.string.reload_getting_started); pressBack(); pressBack(); onView(withId(R.id.fragment_book_view_flipper)).check(matches(isDisplayed())); @@ -60,15 +60,15 @@ public void testImportingGettingStartedFromGettingStartedNotebook() { public void testAddingNewTodoKeywordInSettingsAndChangingStateToItForNewNote() { onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_notebooks", R.string.pref_title_notebooks); - clickSetting("pref_key_states", R.string.states); + clickSetting(R.string.pref_title_notebooks); + clickSetting(R.string.states); onView(withId(R.id.todo_states)).perform(replaceTextCloseKeyboard("TODO AAA BBB CCC")); onView(withText(android.R.string.ok)).perform(click()); onView(withText(R.string.not_now)).perform(click()); - clickSetting("pref_key_new_note_state", R.string.state); + clickSetting(R.string.state); onData(hasToString(containsString("CCC"))).perform(click()); } @@ -77,14 +77,14 @@ public void testAddingNewTodoKeywordInSettingsAndChangingStateToItForNewNote() { public void testAddingNewTodoKeywordInSettingsNewNoteShouldHaveDefaultState() { onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_notebooks", R.string.pref_title_notebooks); - clickSetting("pref_key_states", R.string.states); + clickSetting(R.string.pref_title_notebooks); + clickSetting(R.string.states); onView(withId(R.id.todo_states)).perform(replaceTextCloseKeyboard("TODO CCC")); onView(withText(android.R.string.ok)).perform(click()); onView(withText(R.string.not_now)).perform(click()); - clickSetting("pref_key_new_note_state", R.string.state); + clickSetting(R.string.state); onData(hasToString(containsString("NOTE"))).perform(click()); } @@ -94,8 +94,8 @@ public void testStateSummaryAfterNoStates() { AppPreferences.states(context, "|"); onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_notebooks", R.string.pref_title_notebooks); - clickSetting("pref_key_states", R.string.states); + clickSetting(R.string.pref_title_notebooks); + clickSetting(R.string.states); onView(withId(R.id.todo_states)).perform(replaceTextCloseKeyboard("TODO")); onView(withText(android.R.string.ok)).perform(click()); onView(withText(R.string.not_now)).perform(click()); @@ -106,8 +106,8 @@ public void testStateSummaryAfterNoStates() { public void testStatesDuplicateDetectedIgnoringCase() { onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_notebooks", R.string.pref_title_notebooks); - clickSetting("pref_key_states", R.string.states); + clickSetting(R.string.pref_title_notebooks); + clickSetting(R.string.states); onView(withId(R.id.todo_states)).perform(replaceTextCloseKeyboard("TODO NEXT next")); @@ -121,8 +121,8 @@ public void testNewNoteDefaultStateIsInitiallyVisibleInSummary() { AppPreferences.newNoteState(context, "BBB"); onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_notebooks", R.string.pref_title_notebooks); - clickSetting("pref_key_new_note_state", R.string.state); + clickSetting(R.string.pref_title_notebooks); + clickSetting(R.string.state); onView(withText("BBB")).check(matches(isDisplayed())); } @@ -133,8 +133,8 @@ public void testNewNoteDefaultStateIsSetInitially() { AppPreferences.newNoteState(context, "BBB"); onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_notebooks", R.string.pref_title_notebooks); - clickSetting("pref_key_new_note_state", R.string.state); + clickSetting(R.string.pref_title_notebooks); + clickSetting(R.string.state); onData(hasToString(containsString("BBB"))).perform(click()); } @@ -145,11 +145,11 @@ public void testDefaultPriorityUpdateOnLowestPriorityChange() { AppPreferences.minPriority(context, "E"); onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_notebooks", R.string.pref_title_notebooks); - clickSetting("pref_key_min_priority", R.string.lowest_priority); + clickSetting(R.string.pref_title_notebooks); + clickSetting(R.string.lowest_priority); onData(hasToString(containsString("B"))).perform(click()); - clickSetting("pref_key_default_priority", R.string.default_priority); + clickSetting(R.string.default_priority); onData(hasToString("B")).check(matches(isChecked())); } @@ -159,11 +159,11 @@ public void testLowestPriorityUpdateOnDefaultPriorityChange() { AppPreferences.minPriority(context, "E"); onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_notebooks", R.string.pref_title_notebooks); - clickSetting("pref_key_default_priority", R.string.default_priority); + clickSetting(R.string.pref_title_notebooks); + clickSetting(R.string.default_priority); onData(hasToString(containsString("X"))).perform(click()); - clickSetting("pref_key_min_priority", R.string.lowest_priority); + clickSetting(R.string.lowest_priority); onData(hasToString("X")).check(matches(isChecked())); } @@ -171,8 +171,8 @@ public void testLowestPriorityUpdateOnDefaultPriorityChange() { public void testLowercaseStateConvertedToUppercase() { onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_notebooks", R.string.pref_title_notebooks); - clickSetting("pref_key_states", R.string.states); + clickSetting(R.string.pref_title_notebooks); + clickSetting(R.string.states); onView(withId(R.id.todo_states)).perform(replaceTextCloseKeyboard("TODO NEXT wait")); diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/SshKeyCreationTest.kt b/app/src/androidTest/java/com/orgzly/android/espresso/SshKeyCreationTest.kt index 62cda5c23..0d230f386 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/SshKeyCreationTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/espresso/SshKeyCreationTest.kt @@ -40,13 +40,13 @@ class SshKeyCreationTest(private val param: Parameter) : OrgzlyTest() { Assume.assumeFalse(BuildConfig.IS_GIT_REMOVED); ActivityScenario.launch(MainActivity::class.java).use { EspressoUtils.onActionItemClick(R.id.activity_action_settings, R.string.settings) - EspressoUtils.clickSetting(null, R.string.app) - EspressoUtils.clickSetting(null, R.string.developer_options) - EspressoUtils.clickSetting(null, R.string.git_repository_type) + EspressoUtils.clickSetting(R.string.app) + EspressoUtils.clickSetting(R.string.developer_options) + EspressoUtils.clickSetting(R.string.git_repository_type) pressBack() pressBack() - EspressoUtils.clickSetting(null, R.string.sync) - EspressoUtils.clickSetting(null, R.string.ssh_keygen_preference_title) + EspressoUtils.clickSetting(R.string.sync) + EspressoUtils.clickSetting(R.string.ssh_keygen_preference_title) onView(withText(param.keyType)).perform(click()) onView(withText(R.string.ssh_keygen_generate)).perform(click()) getInstrumentation().waitForIdleSync() diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/SyncingTest.java b/app/src/androidTest/java/com/orgzly/android/espresso/SyncingTest.java index d82bdf1ec..6fa4d738a 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/SyncingTest.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/SyncingTest.java @@ -74,29 +74,6 @@ public void testRunSync() { sync(); } - @Test - public void testForceLoadingBookWithLink() { - Repo repo = testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); - testUtils.setupRook(repo, "mock://repo-a/booky.org", "New content", "abc", 1234567890000L); - testUtils.setupBook("booky", "First book used for testing\n* Note A"); - scenario = ActivityScenario.launch(MainActivity.class); - - onView(allOf(withText("booky"), isDisplayed())).perform(longClick()); - contextualToolbarOverflowMenu().perform(click()); - onView(withText(R.string.books_context_menu_item_set_link)).perform(click()); - onView(withText("mock://repo-a")).perform(click()); - - onView(allOf(withText("booky"), isDisplayed())).perform(longClick()); - onView(withId(R.id.books_context_menu_force_load)).perform(click()); - onView(withText(R.string.overwrite)).perform(click()); - onBook(0, R.id.item_book_last_action) - .check(matches((withText(containsString(context.getString(R.string.force_loaded_from_uri, "mock://repo-a/booky.org")))))); - - onView(allOf(withText("booky"), isDisplayed())).perform(click()); - onView(allOf(withId(R.id.item_preface_text_view), withText("New content"))) - .check(matches(isDisplayed())); - } - @Test public void testAutoSyncIsTriggeredAfterCreatingNote() { Repo repo = testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); @@ -106,10 +83,10 @@ public void testAutoSyncIsTriggeredAfterCreatingNote() { // Set preference onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_sync", R.string.sync); - clickSetting("prefs_screen_auto_sync", R.string.auto_sync); - clickSetting("pref_key_auto_sync", R.string.auto_sync); - clickSetting("pref_key_auto_sync_on_note_create", R.string.pref_title_sync_after_note_create); + clickSetting(R.string.sync); + clickSetting(R.string.auto_sync); + clickSetting(R.string.auto_sync); + clickSetting(R.string.pref_title_sync_after_note_create); pressBack(); pressBack(); pressBack(); @@ -169,64 +146,6 @@ public void nonLinkedBookCannotBeMadeOutOfSync() { onBook(0, R.id.item_book_sync_needed_icon).check(matches(not(isDisplayed()))); } - @Test - public void testForceLoadingBookWithNoLinkNoRepos() { - testUtils.setupBook("booky", "First book used for testing\n* Note A"); - testUtils.setupBook("book-two", "Second book used for testing\n* Note 1\n* Note 2"); - scenario = ActivityScenario.launch(MainActivity.class); - - onView(allOf(withText("booky"), isDisplayed())).perform(longClick()); - onView(withId(R.id.books_context_menu_force_load)).perform(click()); - onView(withText(R.string.overwrite)).perform(click()); - onSnackbar().check(matches(withText(endsWith(context.getString(R.string.message_book_has_no_link))))); - } - - @Test - public void testForceLoadingBookWithNoLinkSingleRepo() { - testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); - testUtils.setupBook("booky", "First book used for testing\n* Note A"); - testUtils.setupBook("book-two", "Second book used for testing\n* Note 1\n* Note 2"); - scenario = ActivityScenario.launch(MainActivity.class); - - onView(allOf(withText("booky"), isDisplayed())).perform(longClick()); - onView(withId(R.id.books_context_menu_force_load)).perform(click()); - onView(withText(R.string.overwrite)).perform(click()); - onSnackbar().check(matches(withText(endsWith(context.getString(R.string.message_book_has_no_link))))); - } - - /* Books view was returning multiple entries for the same book, due to duplicates in encodings - * table. The last statement in this method will fail if there are multiple books matching. - */ - @Test - public void testForceLoadingMultipleTimes() { - Repo repo = testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); - testUtils.setupRook(repo, "mock://repo-a/book-one.org", "New content", "abc", 1234567890000L); - testUtils.setupBook("book-one", "First book used for testing\n* Note A"); - testUtils.setupBook("book-two", "Second book used for testing\n* Note 1\n* Note 2"); - scenario = ActivityScenario.launch(MainActivity.class); - - onView(allOf(withText("book-one"), isDisplayed())).perform(longClick()); - contextualToolbarOverflowMenu().perform(click()); - onView(withText(R.string.books_context_menu_item_set_link)).perform(click()); - onView(withText("mock://repo-a")).perform(click()); - - onView(allOf(withText("book-one"), isDisplayed())).perform(longClick()); - onView(withId(R.id.books_context_menu_force_load)).perform(click()); - onView(withText(R.string.overwrite)).perform(click()); - - onBook(0, R.id.item_book_last_action) - .check(matches(withText(endsWith( - context.getString(R.string.force_loaded_from_uri, "mock://repo-a/book-one.org"))))); - - onView(allOf(withText("book-one"), isDisplayed())).perform(longClick()); - onView(withId(R.id.books_context_menu_force_load)).perform(click()); - onView(withText(R.string.overwrite)).perform(click()); - - onBook(0, R.id.item_book_last_action) - .check(matches(withText(endsWith( - context.getString(R.string.force_loaded_from_uri, "mock://repo-a/book-one.org"))))); - } - /* * Book is left with out-of-sync icon when it's modified, then force-loaded. * This is because book's mtime was not being updated and was greater then remote book's mtime. @@ -296,69 +215,6 @@ public void testForceLoadingMultipleBooks() { onNoteInBook(1, R.id.item_head_title_view).check(matches(withText("Note 1"))); } - @Test - public void testForceSavingBookWithNoLinkAndMultipleRepos() { - testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); - testUtils.setupRepo(RepoType.MOCK, "mock://repo-b"); - testUtils.setupBook("book-one", "First book used for testing\n* Note A"); - testUtils.setupBook("book-two", "Second book used for testing\n* Note 1\n* Note 2"); - scenario = ActivityScenario.launch(MainActivity.class); - - onView(allOf(withText("book-one"), isDisplayed())).perform(longClick()); - onView(withId(R.id.books_context_menu_force_save)).perform(click()); - onView(withText(R.string.overwrite)).perform(click()); - - onBook(0, R.id.item_book_last_action) - .check(matches(withText(endsWith( - context.getString(R.string.force_saving_failed, context.getString(R.string.multiple_repos)))))); - - } - - @Test - public void testForceSavingBookWithNoLinkNoRepos() { - testUtils.setupBook("book-one", "First book used for testing\n* Note A"); - testUtils.setupBook("book-two", "Second book used for testing\n* Note 1\n* Note 2"); - scenario = ActivityScenario.launch(MainActivity.class); - - onView(allOf(withText("book-one"), isDisplayed())).perform(longClick()); - onView(withId(R.id.books_context_menu_force_save)).perform(click()); - onView(withText(R.string.overwrite)).perform(click()); - onBook(0, R.id.item_book_last_action) - .check(matches(withText(endsWith( - context.getString(R.string.force_saving_failed, context.getString(R.string.no_repos)))))); - } - - @Test - public void testForceSavingBookWithNoLinkSingleRepo() { - testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); - testUtils.setupBook("book-one", "First book used for testing\n* Note A"); - testUtils.setupBook("book-two", "Second book used for testing\n* Note 1\n* Note 2"); - scenario = ActivityScenario.launch(MainActivity.class); - - onView(allOf(withText("book-one"), isDisplayed())).perform(longClick()); - onView(withId(R.id.books_context_menu_force_save)).perform(click()); - onView(withText(R.string.overwrite)).perform(click()); - - onBook(0, R.id.item_book_last_action) - .check(matches(withText(endsWith( - context.getString(R.string.force_saved_to_uri, "mock://repo-a/book-one.org"))))); - } - - @Test - public void testForceSavingBookWithLink() { - Repo repo = testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); - testUtils.setupBook("booky", "First book used for testing\n* Note A", repo); - scenario = ActivityScenario.launch(MainActivity.class); - - onView(allOf(withText("booky"), isDisplayed())).perform(longClick()); - onView(withId(R.id.books_context_menu_force_save)).perform(click()); - onView(withText(R.string.overwrite)).perform(click()); - - onBook(0, R.id.item_book_last_action) - .check(matches(withText(endsWith( - context.getString(R.string.force_saved_to_uri, "mock://repo-a/booky.org"))))); - } - @Test public void testForceSavingMultipleBooks() { Repo repo = testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); @@ -678,8 +534,8 @@ public void testSettingLinkToRenamedRepo() throws JSONException { /* Rename repository. */ onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_sync", R.string.sync); - clickSetting("pref_key_repos", R.string.repos_preference_title); + clickSetting(R.string.sync); + clickSetting(R.string.repos_preference_title); onListItem(0).perform(click()); onView(withId(R.id.activity_repo_dropbox_directory)).perform(replaceTextCloseKeyboard("repo-b")); onView(withId(R.id.fab)).perform(click()); // Repo done @@ -730,8 +586,8 @@ public void testRenamingReposRemovesLinksWhatUsedThem() throws JSONException { /* Rename all repositories. */ onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_sync", R.string.sync); - clickSetting("pref_key_repos", R.string.repos_preference_title); + clickSetting(R.string.sync); + clickSetting(R.string.repos_preference_title); onListItem(0).perform(click()); onView(withId(R.id.activity_repo_dropbox_directory)).perform(replaceTextCloseKeyboard("repo-1")); onView(withId(R.id.fab)).perform(click()); // Repo done @@ -784,21 +640,6 @@ public void testSettingLinkForLoadedOrgTxtBook() { onBook(0, R.id.item_book_synced_url).check(matches(allOf(withText("mock://repo-a/booky.org.txt"), isDisplayed()))); } - @Test - public void testSpaceSeparatedBookName() { - Repo repo = testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); - testUtils.setupRook(repo, "mock://repo-a/Book%20Name.org", "", "1abcdef", 1400067155); - - scenario = ActivityScenario.launch(MainActivity.class); - - sync(); - - onBook(0, R.id.item_book_synced_url) - .check(matches(allOf(withText("mock://repo-a/Book%20Name.org"), isDisplayed()))); - onBook(0, R.id.item_book_last_action) - .check(matches(allOf(withText(endsWith("Loaded from mock://repo-a/Book%20Name.org")), isDisplayed()))); - } - @Test public void testRenameModifiedBook() { testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/util/EspressoUtils.java b/app/src/androidTest/java/com/orgzly/android/espresso/util/EspressoUtils.java index cc401eaba..e660e63f6 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/util/EspressoUtils.java +++ b/app/src/androidTest/java/com/orgzly/android/espresso/util/EspressoUtils.java @@ -306,7 +306,7 @@ public static void onActionItemClick(int id, int resourceId) { } } - public static void clickSetting(String key, int title) { + public static void clickSetting(int title) { onView(withId(R.id.recycler_view)) .perform(RecyclerViewActions.actionOnItem( hasDescendant(withText(title)), click())); @@ -323,8 +323,8 @@ public static void settingsSetDoneKeywords(String keywords) { private static void settingsSetKeywords(int viewId, String keywords) { onActionItemClick(R.id.activity_action_settings, R.string.settings); - clickSetting("prefs_screen_notebooks", R.string.pref_title_notebooks); - clickSetting("pref_key_states", R.string.states); + clickSetting(R.string.pref_title_notebooks); + clickSetting(R.string.states); onView(withId(viewId)).perform(replaceTextCloseKeyboard(keywords)); onView(withText(android.R.string.ok)).perform(click()); diff --git a/app/src/androidTest/java/com/orgzly/android/espresso/util/ScreenshotsTakingNotATest.kt b/app/src/androidTest/java/com/orgzly/android/espresso/util/ScreenshotsTakingNotATest.kt index 7adc581f4..81ddf0f4e 100644 --- a/app/src/androidTest/java/com/orgzly/android/espresso/util/ScreenshotsTakingNotATest.kt +++ b/app/src/androidTest/java/com/orgzly/android/espresso/util/ScreenshotsTakingNotATest.kt @@ -169,8 +169,8 @@ class ScreenshotsTakingNotATest : OrgzlyTest() { fun settings() { startActivity(SettingsActivity::class.java) - clickSetting("", R.string.sync) - clickSetting("", R.string.repositories) + clickSetting(R.string.sync) + clickSetting(R.string.repositories) takeScreenshot("repos.png") } diff --git a/app/src/androidTest/java/com/orgzly/android/repos/DataRepositoryTest.java b/app/src/androidTest/java/com/orgzly/android/repos/DataRepositoryTest.java index 021d7e963..9c123063b 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/DataRepositoryTest.java +++ b/app/src/androidTest/java/com/orgzly/android/repos/DataRepositoryTest.java @@ -76,9 +76,10 @@ public void testLoadRook() throws IOException { assertEquals("remote-book-1", book.getBook().getName()); assertEquals("/remote-book-1.org", book.getSyncedTo().getUri().getPath()); - assertEquals("remote-book-1", BookName.getInstance(context, book.getSyncedTo()).getName()); + assertEquals("remote-book-1", BookName.fromRook(book.getSyncedTo()).getName()); assertEquals("0abcdef", book.getSyncedTo().getRevision()); assertEquals(1400067156000L, book.getSyncedTo().getMtime()); + assertEquals(repo.getUrl(), vrook.getRepoUri().toString()); } @Test diff --git a/app/src/androidTest/java/com/orgzly/android/repos/DirectoryRepoTest.java b/app/src/androidTest/java/com/orgzly/android/repos/DirectoryRepoTest.java index 60133d30c..8a56286b1 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/DirectoryRepoTest.java +++ b/app/src/androidTest/java/com/orgzly/android/repos/DirectoryRepoTest.java @@ -62,8 +62,8 @@ public void testStoringFile() throws IOException { List books = repo.getBooks(); assertEquals(1, books.size()); - assertEquals("booky", BookName.getInstance(context, books.get(0)).getName()); - assertEquals("booky.org", BookName.getInstance(context, books.get(0)).getFileName()); + assertEquals("booky", BookName.fromRook(books.get(0)).getName()); + assertEquals("booky.org", BookName.fromRook(books.get(0)).getRepoRelativePath()); assertEquals(repoUriString, books.get(0).getRepoUri().toString()); assertEquals(repoUriString + "/booky.org", books.get(0).getUri().toString()); } @@ -79,8 +79,8 @@ public void testExtension() throws IOException { List books = repo.getBooks(); assertEquals(1, books.size()); - assertEquals("03", BookName.getInstance(context, books.get(0)).getName()); - assertEquals("03.org", BookName.getInstance(context, books.get(0)).getFileName()); + assertEquals("03", BookName.fromRook(books.get(0)).getName()); + assertEquals("03.org", BookName.fromRook(books.get(0)).getRepoRelativePath()); assertEquals(13, books.get(0).getRepoId()); assertEquals(repoUriString, books.get(0).getRepoUri().toString()); assertEquals(repoUriString + "/03.org", books.get(0).getUri().toString()); @@ -103,7 +103,7 @@ public void testGetBooksRespectsIgnoreRules() throws IOException { List books = repo.getBooks(); assertEquals(1, books.size()); - assertEquals("file2", BookName.getInstance(context, books.get(0)).getName()); + assertEquals("file2", BookName.fromRook(books.get(0)).getName()); assertEquals(repoUriString + "/file2.org", books.get(0).getUri().toString()); } @@ -118,7 +118,6 @@ public void testListDownloadsDirectory() throws IOException { assertNotNull(repo.getBooks()); } - // TODO: Do the same for dropbox repo @Test public void testRenameBook() { BookView bookView; diff --git a/app/src/androidTest/java/com/orgzly/android/repos/DocumentRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/DocumentRepoTest.kt new file mode 100644 index 000000000..6ee38a981 --- /dev/null +++ b/app/src/androidTest/java/com/orgzly/android/repos/DocumentRepoTest.kt @@ -0,0 +1,203 @@ +package com.orgzly.android.repos + +import android.net.Uri +import android.os.Build +import android.os.SystemClock +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiSelector +import com.orgzly.R +import com.orgzly.android.OrgzlyTest +import com.orgzly.android.db.entity.Repo +import com.orgzly.android.espresso.util.EspressoUtils +import com.orgzly.android.ui.repos.ReposActivity +import org.hamcrest.core.AllOf +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import java.io.IOException + +class DocumentRepoTest : SyncRepoTest, OrgzlyTest() { + + private lateinit var documentTreeSegment: String + private lateinit var repo: Repo + private lateinit var syncRepo: SyncRepo + private lateinit var repoDirectory: DocumentFile + + @Before + override fun setUp() { + super.setUp() + setupDocumentRepo() + } + + @After + override fun tearDown() { + super.tearDown() + for (file in repoDirectory.listFiles()) { + file.delete() + } + } + + @Test + override fun testGetBooks_singleOrgFile() { + SyncRepoTest.testGetBooks_singleOrgFile(repoDirectory, syncRepo) + } + + @Test + override fun testGetBooks_singleFileInSubfolder() { + SyncRepoTest.testGetBooks_singleFileInSubfolder(repoDirectory, syncRepo) + } + + @Test + override fun testGetBooks_allFilesAreIgnored() { + SyncRepoTest.testGetBooks_allFilesAreIgnored(repoDirectory, syncRepo) + } + + @Test + override fun testGetBooks_specificFileInSubfolderIsIgnored() { + SyncRepoTest.testGetBooks_specificFileInSubfolderIsIgnored(repoDirectory, syncRepo) + } + + @Test + override fun testGetBooks_specificFileIsUnignored() { + SyncRepoTest.testGetBooks_specificFileIsUnignored(repoDirectory, syncRepo) + } + + @Test + override fun testGetBooks_ignoredExtensions() { + SyncRepoTest.testGetBooks_ignoredExtensions(repoDirectory, syncRepo) + } + + @Test + override fun testStoreBook_expectedUri() { + SyncRepoTest.testStoreBook_expectedUri(syncRepo) + } + + @Test + override fun testStoreBook_producesSameUriAsRetrieveBook() { + SyncRepoTest.testStoreBook_producesSameUriAsRetrieveBook(syncRepo) + } + + @Test + override fun testStoreBook_producesSameUriAsGetBooks() { + SyncRepoTest.testStoreBook_producesSameUriAsGetBooks(repoDirectory, syncRepo) + } + + @Test + override fun testStoreBook_inSubfolder() { + SyncRepoTest.testStoreBook_inSubfolder(repoDirectory, syncRepo) + } + + @Test + override fun testRenameBook_expectedUri() { + SyncRepoTest.testRenameBook_expectedUri(syncRepo) + } + + @Test(expected = IOException::class) + override fun testRenameBook_repoFileAlreadyExists() { + SyncRepoTest.testRenameBook_repoFileAlreadyExists(repoDirectory, syncRepo) + } + + @Test + override fun testRenameBook_fromRootToSubfolder() { + SyncRepoTest.testRenameBook_fromRootToSubfolder(syncRepo) + } + + @Test + override fun testRenameBook_fromSubfolderToRoot() { + SyncRepoTest.testRenameBook_fromSubfolderToRoot(syncRepo) + } + + @Test + override fun testRenameBook_newSubfolderSameLeafName() { + SyncRepoTest.testRenameBook_newSubfolderSameLeafName(syncRepo) + } + + @Test + override fun testRenameBook_newSubfolderAndLeafName() { + SyncRepoTest.testRenameBook_newSubfolderAndLeafName(syncRepo) + } + + @Test + override fun testRenameBook_sameSubfolderNewLeafName() { + SyncRepoTest.testRenameBook_sameSubfolderNewLeafName(syncRepo) + } + + private fun setupDocumentRepo(extraDir: String? = null) { + val repoDirName = SyncRepoTest.repoDirName + documentTreeSegment = if (Build.VERSION.SDK_INT < 30) { + "/document/raw%3A%2Fstorage%2Femulated%2F0%2FDownload%2F$repoDirName%2F" + } else { + "/document/primary%3A$repoDirName%2F" + } + var treeDocumentFileUrl = if (Build.VERSION.SDK_INT < 30) { + "content://com.android.providers.downloads.documents/tree/raw%3A%2Fstorage%2Femulated%2F0%2FDownload%2F$repoDirName" + } else { + "content://com.android.externalstorage.documents/tree/primary%3A$repoDirName" + } + if (extraDir != null) { + treeDocumentFileUrl = "$treeDocumentFileUrl%2F" + Uri.encode(extraDir) + } + repoDirectory = DocumentFile.fromTreeUri(context, treeDocumentFileUrl.toUri())!! + repo = if (!repoDirectory.exists()) { + if (extraDir != null) { + setupDocumentRepoInUi(extraDir) + } else { + setupDocumentRepoInUi(repoDirName) + } + dataRepository.getRepos()[0] + } else { + testUtils.setupRepo(RepoType.DOCUMENT, treeDocumentFileUrl) + } + syncRepo = testUtils.repoInstance(RepoType.DOCUMENT, repo.url, repo.id) + Assert.assertEquals(treeDocumentFileUrl, repo.url) + } + + /** + * Note that this solution only works the first time the tests are run on any given virtual + * device. On the second run, the file picker will start in a different folder, resulting in + * a different repo URL, making some tests fail. If you are running locally, you must work + * around this by wiping the device's data between test suite runs. + */ + private fun setupDocumentRepoInUi(repoDirName: String) { + ActivityScenario.launch(ReposActivity::class.java).use { + Espresso.onView(ViewMatchers.withId(R.id.activity_repos_directory)) + .perform(ViewActions.click()) + Espresso.onView(ViewMatchers.withId(R.id.activity_repo_directory_browse_button)) + .perform(ViewActions.click()) + SystemClock.sleep(500) + // In Android file browser (Espresso cannot be used): + val mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + if (Build.VERSION.SDK_INT < 30) { + // Older system file picker UI + mDevice.findObject(UiSelector().description("More options")).click() + SystemClock.sleep(300) + mDevice.findObject(UiSelector().text("New folder")).click() + SystemClock.sleep(500) + mDevice.findObject(UiSelector().text("Folder name")).text = repoDirName + mDevice.findObject(UiSelector().text("OK")).click() + mDevice.findObject(UiSelector().textContains("ALLOW ACCESS TO")).click() + mDevice.findObject(UiSelector().text("ALLOW")).click() + } else { + mDevice.findObject(UiSelector().description("New folder")).click() + SystemClock.sleep(500) + mDevice.findObject(UiSelector().text("Folder name")).text = repoDirName + mDevice.findObject(UiSelector().text("OK")).click() + mDevice.findObject(UiSelector().text("USE THIS FOLDER")).click() + mDevice.findObject(UiSelector().text("ALLOW")).click() + } + // Back in Orgzly: + SystemClock.sleep(500) + Espresso.onView(ViewMatchers.isRoot()).perform(EspressoUtils.waitId(R.id.fab, 5000)) + Espresso.onView(AllOf.allOf(ViewMatchers.withId(R.id.fab), ViewMatchers.isDisplayed())) + .perform(ViewActions.click()) + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/orgzly/android/repos/DropboxRepoTest.java b/app/src/androidTest/java/com/orgzly/android/repos/DropboxRepoTest.java index 38a9ba6e0..35d5c7c3a 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/DropboxRepoTest.java +++ b/app/src/androidTest/java/com/orgzly/android/repos/DropboxRepoTest.java @@ -42,112 +42,11 @@ public void testUrl() { } @Test - public void testSyncingUrlWithTrailingSlash() throws IOException { + public void testSyncingUrlWithTrailingSlash() { testUtils.setupRepo(RepoType.DROPBOX, randomUrl() + "/"); assertNotNull(testUtils.sync()); } - @Test - public void testRenameBook() throws IOException { - BookView bookView; - String repoUriString = testUtils.repoInstance(RepoType.DROPBOX, randomUrl()).getUri().toString(); - - testUtils.setupRepo(RepoType.DROPBOX, repoUriString); - testUtils.setupBook("booky", ""); - - testUtils.sync(); - bookView = dataRepository.getBookView("booky"); - - assertEquals(repoUriString, bookView.getLinkRepo().getUrl()); - assertEquals(repoUriString, bookView.getSyncedTo().getRepoUri().toString()); - assertEquals(repoUriString + "/booky.org", bookView.getSyncedTo().getUri().toString()); - - dataRepository.renameBook(bookView, "booky-renamed"); - bookView = dataRepository.getBookView("booky-renamed"); - - assertEquals(repoUriString, bookView.getLinkRepo().getUrl()); - assertEquals(repoUriString, bookView.getSyncedTo().getRepoUri().toString()); - assertEquals(repoUriString + "/booky-renamed.org", bookView.getSyncedTo().getUri().toString()); - } - - @Test - public void testIgnoreRulePreventsLinkingBook() throws Exception { - Uri repoUri = testUtils.repoInstance(RepoType.DROPBOX, randomUrl()).getUri(); - testUtils.setupRepo(RepoType.DROPBOX, repoUri.toString()); - uploadFileToRepo(repoUri, RepoIgnoreNode.IGNORE_FILE, "*.org"); - testUtils.setupBook("booky", ""); - exceptionRule.expect(IOException.class); - exceptionRule.expectMessage("matches a rule in .orgzlyignore"); - testUtils.syncOrThrow(); - } - - @Test - public void testIgnoreRulePreventsLoadingBook() throws Exception { - SyncRepo repo = testUtils.repoInstance(RepoType.DROPBOX, randomUrl()); - testUtils.setupRepo(RepoType.DROPBOX, repo.getUri().toString()); - - // Create two .org files - uploadFileToRepo(repo.getUri(), "ignored.org", "1 2 3"); - uploadFileToRepo(repo.getUri(), "notignored.org", "1 2 3"); - // Create .orgzlyignore - uploadFileToRepo(repo.getUri(), RepoIgnoreNode.IGNORE_FILE, "ignored.org"); - testUtils.sync(); - - List bookViews = dataRepository.getBooks(); - assertEquals(1, bookViews.size()); - assertEquals("notignored", bookViews.get(0).getBook().getName()); - } - - @Test - public void testIgnoreRulePreventsRenamingBook() throws Exception { - BookView bookView; - Uri repoUri = testUtils.repoInstance(RepoType.DROPBOX, randomUrl()).getUri(); - testUtils.setupRepo(RepoType.DROPBOX, repoUri.toString()); - uploadFileToRepo(repoUri, RepoIgnoreNode.IGNORE_FILE, "badname*"); - testUtils.setupBook("goodname", ""); - testUtils.sync(); - bookView = dataRepository.getBookView("goodname"); - dataRepository.renameBook(bookView, "badname"); - bookView = dataRepository.getBooks().get(0); - assertTrue( - bookView.getBook() - .getLastAction() - .toString() - .contains("matches a rule in .orgzlyignore") - ); - } - - @Test - public void testDropboxFileRename() throws IOException { - SyncRepo repo = testUtils.repoInstance(RepoType.DROPBOX, randomUrl()); - - assertNotNull(repo); - assertEquals(0, repo.getBooks().size()); - - File file = File.createTempFile("notebook.", ".org"); - MiscUtils.writeStringToFile("1 2 3", file); - - VersionedRook vrook = repo.storeBook(file, file.getName()); - - file.delete(); - - assertEquals(1, repo.getBooks().size()); - - repo.renameBook(vrook.getUri(), "notebook-renamed"); - - assertEquals(1, repo.getBooks().size()); - assertEquals(repo.getUri() + "/notebook-renamed.org", repo.getBooks().get(0).getUri().toString()); - assertEquals("notebook-renamed.org", BookName.getInstance(context, repo.getBooks().get(0)).getFileName()); - } - - private void uploadFileToRepo(Uri repoUri, String fileName, String fileContents) throws IOException { - DropboxClient client = new DropboxClient(App.getAppContext(), 0); - File tmpFile = File.createTempFile("abc", null); - MiscUtils.writeStringToFile(fileContents, tmpFile); - client.upload(tmpFile, repoUri, fileName); - tmpFile.delete(); - } - private String randomUrl() { return "dropbox:"+ DROPBOX_TEST_DIR + "/" + UUID.randomUUID().toString(); } diff --git a/app/src/androidTest/java/com/orgzly/android/repos/GitRepoTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/GitRepoTest.kt deleted file mode 100644 index 3f4b1a260..000000000 --- a/app/src/androidTest/java/com/orgzly/android/repos/GitRepoTest.kt +++ /dev/null @@ -1,152 +0,0 @@ -package com.orgzly.android.repos - -import android.net.Uri -import android.os.Build -import androidx.core.net.toUri -import com.orgzly.R -import com.orgzly.android.OrgzlyTest -import com.orgzly.android.db.entity.BookView -import com.orgzly.android.db.entity.Repo -import com.orgzly.android.git.GitFileSynchronizer -import com.orgzly.android.git.GitPreferencesFromRepoPrefs -import com.orgzly.android.prefs.AppPreferences -import com.orgzly.android.prefs.RepoPreferences -import com.orgzly.android.util.MiscUtils -import org.eclipse.jgit.api.Git -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Assume -import org.junit.Rule -import org.junit.Test -import org.junit.rules.ExpectedException -import java.io.File -import java.io.IOException -import java.nio.file.Path -import kotlin.io.path.createTempDirectory - -class GitRepoTest : OrgzlyTest() { - - private lateinit var bareRepoPath: Path - private lateinit var repoUri: Uri - private lateinit var gitPreferences: GitPreferencesFromRepoPrefs - private lateinit var workingtree: File - private lateinit var repoPreferences: RepoPreferences - private lateinit var repo: Repo - private lateinit var syncRepo: GitRepo - private lateinit var git: Git - private lateinit var synchronizer: GitFileSynchronizer - - @Rule - @JvmField - var exceptionRule: ExpectedException = ExpectedException.none() - - override fun setUp() { - super.setUp() - bareRepoPath = createTempDirectory() - Git.init().setBare(true).setDirectory(bareRepoPath.toFile()).call() - AppPreferences.gitIsEnabled(context, true) - repoUri = bareRepoPath.toFile().toUri() - repo = testUtils.setupRepo(RepoType.GIT, repoUri.toString()) - repoPreferences = RepoPreferences(context, repo.id, repoUri) - gitPreferences = GitPreferencesFromRepoPrefs(repoPreferences) - workingtree = File(gitPreferences.repositoryFilepath()) - workingtree.mkdirs() - git = GitRepo.ensureRepositoryExists(gitPreferences, true, null) - syncRepo = dataRepository.getRepoInstance(repo.id, RepoType.GIT, repo.url) as GitRepo - synchronizer = GitFileSynchronizer(git, gitPreferences) - } - - override fun tearDown() { - super.tearDown() - testUtils.deleteRepo(repo.url) - workingtree.deleteRecursively() - bareRepoPath.toFile()?.deleteRecursively() - } - - @Test - fun testSyncNewBookWithoutLinkAndOneRepo() { - testUtils.setupBook("book1", "book content") - testUtils.sync() - val bookView = dataRepository.getBooks()[0] - assertEquals(repoUri.toString(), bookView.linkRepo?.url) - assertEquals(1, syncRepo.books.size) - assertEquals(bookView.syncedTo.toString(), syncRepo.books[0].toString()) - assertEquals(context.getString(R.string.sync_status_saved, repo.url), bookView.book.lastAction!!.message) - assertEquals("/book1.org", bookView.syncedTo!!.uri.toString()) - } - - @Test - fun testIgnoredFilesInRepoAreNotLoaded() { - Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - // Create ignore file in working tree and commit - val ignoreFileContents = """ - ignoredbook.org - ignored-*.org - """.trimIndent() - addAndCommitIgnoreFile(ignoreFileContents) - // Add multiple files to repo - for (fileName in arrayOf("ignoredbook.org", "ignored-3.org", "notignored.org")) { - val tmpFile = File.createTempFile("orgzlytest", null) - MiscUtils.writeStringToFile("book content", tmpFile) - synchronizer.addAndCommitNewFile(tmpFile, fileName) - tmpFile.delete() - } - testUtils.sync() - assertEquals(1, syncRepo.books.size) - assertEquals(1, dataRepository.getBooks().size) - assertEquals("notignored", dataRepository.getBooks()[0].book.name) - } - - @Test - fun testUnIgnoredFilesInRepoAreLoaded() { - Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - // Create ignore file in working tree and commit - val ignoreFileContents = """ - *.org - !notignored.org - """.trimIndent() - addAndCommitIgnoreFile(ignoreFileContents) - // Add multiple files to repo - for (fileName in arrayOf("ignoredbook.org", "ignored-3.org", "notignored.org")) { - val tmpFile = File.createTempFile("orgzlytest", null) - MiscUtils.writeStringToFile("book content", tmpFile) - synchronizer.addAndCommitNewFile(tmpFile, fileName) - tmpFile.delete() - } - testUtils.sync() - assertEquals(1, syncRepo.books.size) - assertEquals(1, dataRepository.getBooks().size) - assertEquals("notignored", dataRepository.getBooks()[0].book.name) - } - - @Test - fun testIgnoreRulePreventsLinkingBook() { - Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - addAndCommitIgnoreFile("*.org") - testUtils.setupBook("booky", "") - exceptionRule.expect(IOException::class.java) - exceptionRule.expectMessage("matches a rule in .orgzlyignore") - testUtils.syncOrThrow() - } - - @Test - fun testIgnoreRulePreventsRenamingBook() { - Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - addAndCommitIgnoreFile("badname*") - testUtils.setupBook("goodname", "") - testUtils.sync() - var bookView: BookView? = dataRepository.getBookView("goodname") - dataRepository.renameBook(bookView!!, "badname") - bookView = dataRepository.getBooks()[0] - assertTrue( - bookView.book.lastAction.toString().contains("matches a rule in .orgzlyignore") - ) - } - - private fun addAndCommitIgnoreFile(contents: String) { - val tmpFile = File.createTempFile("orgzlytest", null) - MiscUtils.writeStringToFile(contents, tmpFile) - synchronizer.addAndCommitNewFile(tmpFile, RepoIgnoreNode.IGNORE_FILE) - tmpFile.delete() - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/com/orgzly/android/repos/LocalDbRepoTest.java b/app/src/androidTest/java/com/orgzly/android/repos/LocalDbRepoTest.java index 7438e8955..c4d1ee6c8 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/LocalDbRepoTest.java +++ b/app/src/androidTest/java/com/orgzly/android/repos/LocalDbRepoTest.java @@ -38,7 +38,7 @@ public void testGetBooksFromAllRepos() throws IOException { VersionedRook vrook = books.get(0); - assertEquals("mock-book", BookName.getInstance(context, vrook).getName()); + assertEquals("mock-book", BookName.fromRook(vrook).getName()); assertEquals("mock://repo-a", vrook.getRepoUri().toString()); assertEquals("mock://repo-a/mock-book.org", vrook.getUri().toString()); assertEquals("rev1", vrook.getRevision()); @@ -58,7 +58,7 @@ public void testStoringBook() throws IOException { try { new NotesOrgExporter(dataRepository).exportBook(book, tmpFile); repo = testUtils.repoInstance(RepoType.MOCK, "mock://repo-a"); - repo.storeBook(tmpFile, BookName.fileName(book.getName(), BookFormat.ORG)); + repo.storeBook(tmpFile, BookName.repoRelativePath(book.getName(), BookFormat.ORG)); } finally { tmpFile.delete(); } @@ -67,7 +67,7 @@ public void testStoringBook() throws IOException { assertEquals(1, books.size()); VersionedRook vrook = books.get(0); - assertEquals("local-book-1", BookName.getInstance(context, vrook).getName()); + assertEquals("local-book-1", BookName.fromRook(vrook).getName()); assertEquals("mock://repo-a", vrook.getRepoUri().toString()); assertTrue(vrook.getMtime() >= now); } diff --git a/app/src/androidTest/java/com/orgzly/android/repos/RepoIgnoreNodeTest.kt b/app/src/androidTest/java/com/orgzly/android/repos/RepoIgnoreNodeTest.kt index 7f983087f..6f71b4547 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/RepoIgnoreNodeTest.kt +++ b/app/src/androidTest/java/com/orgzly/android/repos/RepoIgnoreNodeTest.kt @@ -12,8 +12,8 @@ import java.util.HashMap class RepoIgnoreNodeTest : OrgzlyTest() { class MockRepoWithMockIgnoreFile : MockRepo(repoWithProps, null) { - override fun openRepoFileInputStream(filePath: String): InputStream { - if (filePath == RepoIgnoreNode.IGNORE_FILE) { + override fun openRepoFileInputStream(repoRelativePath: String): InputStream { + if (repoRelativePath == RepoIgnoreNode.IGNORE_FILE) { val ignoreFileContents = """ IgnoredAnywhere.org /OnlyIgnoredInRoot.org 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 a07da5217..2fc5f4cac 100644 --- a/app/src/androidTest/java/com/orgzly/android/repos/SyncTest.java +++ b/app/src/androidTest/java/com/orgzly/android/repos/SyncTest.java @@ -1,7 +1,24 @@ package com.orgzly.android.repos; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; +import static com.orgzly.android.espresso.util.EspressoUtils.onBook; +import static org.hamcrest.Matchers.allOf; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeTrue; + import android.net.Uri; +import android.os.Build; +import com.orgzly.R; +import com.orgzly.android.BookFormat; import com.orgzly.android.BookName; import com.orgzly.android.LocalStorage; import com.orgzly.android.OrgzlyTest; @@ -15,21 +32,17 @@ import com.orgzly.android.util.MiscUtils; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.ExpectedException; import java.io.File; import java.io.IOException; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNotSame; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; - public class SyncTest extends OrgzlyTest { private static final String TAG = SyncTest.class.getName(); @@ -38,6 +51,9 @@ public void setUp() throws Exception { super.setUp(); } + @Rule + public ExpectedException exceptionRule = ExpectedException.none(); + @Test public void testOrgRange() { Repo repo = testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); @@ -226,13 +242,19 @@ public void testOnlyBookWithLink() { } @Test - public void testOnlyBookWithoutLinkAndOneRepo() { - testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); + public void testOnlyBookWithoutLinkAndOneRepo() throws IOException { + Repo repo = 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()); + assertEquals(context.getString(R.string.sync_status_saved, repo.getUrl()), + book.getBook().getLastAction().getMessage()); + assertEquals(repo.getUrl(), book.getLinkRepo().getUrl()); + SyncRepo syncRepo = testUtils.repoInstance(RepoType.MOCK, repo.getUrl()); + assertEquals(1, syncRepo.getBooks().size()); + assertEquals(syncRepo.getBooks().get(0).toString(), book.getSyncedTo().toString()); } @Test @@ -387,7 +409,7 @@ public void testMockFileRename() throws IOException { vrooks = repo.getBooks(); assertEquals(1, vrooks.size()); - assertEquals("Booky", BookName.getInstance(context, vrooks.get(0)).getName()); + assertEquals("Booky", BookName.fromRook(vrooks.get(0)).getName()); long mtime = vrooks.get(0).getMtime(); String rev = vrooks.get(0).getRevision(); @@ -401,7 +423,7 @@ public void testMockFileRename() throws IOException { vrooks = repo.getBooks(); assertEquals(1, vrooks.size()); - assertEquals("BookyRenamed", BookName.getInstance(context, vrooks.get(0)).getName()); + assertEquals("BookyRenamed", BookName.fromRook(vrooks.get(0)).getName()); assertEquals("mock://repo-a/BookyRenamed.org", vrooks.get(0).getUri().toString()); assertTrue(mtime < vrooks.get(0).getMtime()); assertNotSame(rev, vrooks.get(0).getRevision()); @@ -431,13 +453,13 @@ public void testDirectoryFileRename() throws IOException { assertEquals(1, repo.getBooks().size()); assertEquals(repo.getUri() + "/notebook-renamed.org", repo.getBooks().get(0).getUri().toString()); - assertEquals("notebook-renamed.org", BookName.getInstance(context, repo.getBooks().get(0)).getFileName()); + assertEquals("notebook-renamed.org", BookName.fromRook(repo.getBooks().get(0)).getRepoRelativePath()); LocalStorage.deleteRecursive(new File(repoDir)); } @Test - public void testRenameSyncedBook() { + public void testRenameSyncedBook() throws IOException { testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); testUtils.setupBook("Booky", "1 2 3"); @@ -455,6 +477,30 @@ public void testRenameSyncedBook() { assertEquals("mock://repo-a", renamedBook.getLinkRepo().getUrl()); assertEquals("mock://repo-a", renamedBook.getSyncedTo().getRepoUri().toString()); assertEquals("mock://repo-a/BookyRenamed.org", renamedBook.getSyncedTo().getUri().toString()); + assertEquals("1 2 3\n\n", dataRepository.getBookContent("BookyRenamed", BookFormat.ORG)); + } + + @Test + public void testRenameBookToNameWithSpace() throws IOException { + testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); + testUtils.setupBook("Booky", "1 2 3"); + + testUtils.sync(); + + BookView book = dataRepository.getBookView("Booky"); + + assertEquals("mock://repo-a/Booky.org", book.getSyncedTo().getUri().toString()); + + dataRepository.renameBook(book, "Booky Renamed"); + + BookView renamedBook = dataRepository.getBookView("Booky Renamed"); + + assertNotNull(renamedBook); + assertEquals("mock://repo-a", renamedBook.getLinkRepo().getUrl()); + assertEquals("mock://repo-a", renamedBook.getSyncedTo().getRepoUri().toString()); + assertEquals("mock://repo-a/Booky%20Renamed.org", + renamedBook.getSyncedTo().getUri().toString()); + assertEquals("1 2 3\n\n", dataRepository.getBookContent("Booky Renamed", BookFormat.ORG)); } @Test @@ -490,6 +536,84 @@ public void testRenameSyncedBookWithDifferentLink() throws IOException { assertEquals("mock://repo-a/Booky.org", book.getSyncedTo().getUri().toString()); } + @Test + public void testRenameBookToExistingBookName() { + testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); + testUtils.setupBook("a", ""); + testUtils.setupBook("b", ""); + assertEquals(2, dataRepository.getBooks().size()); + dataRepository.renameBook(dataRepository.getBookView("a"), "b"); + assertTrue(dataRepository.getBook("a") + .getLastAction() + .getMessage() + .contains("Renaming failed: Notebook b already exists") + ); + } + + @Test + public void testIgnoreRulePreventsRenamingBook() { + assumeTrue(Build.VERSION.SDK_INT >= 26); + String ignoreRules = "bad name*\n"; + Repo repo = testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); + + // Add ignore rules using repo properties (N.B. MockRepo-specific solution) + Map repoPropsMap = new HashMap<>(); + repoPropsMap.put(MockRepo.IGNORE_RULES_PREF_KEY, ignoreRules); + RepoWithProps repoWithProps = new RepoWithProps(repo, repoPropsMap); + dataRepository.updateRepo(repoWithProps); + + // Create book and sync it + testUtils.setupBook("good name", ""); + testUtils.sync(); + BookView bookView = dataRepository.getBookView("good name"); + + dataRepository.renameBook(bookView, "bad name"); + bookView = dataRepository.getBooks().get(0); + assertTrue(bookView.getBook() + .getLastAction() + .toString() + .contains("matches a rule in .orgzlyignore")); + } + + @Test + public void testIgnoreRulePreventsLinkingBook() throws Exception { + assumeTrue(Build.VERSION.SDK_INT >= 26); + String ignoreRules = "*.org\n"; + Repo repo = testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); + + // Add ignore rules using repo properties (N.B. MockRepo-specific solution) + Map repoPropsMap = new HashMap<>(); + repoPropsMap.put(MockRepo.IGNORE_RULES_PREF_KEY, ignoreRules); + RepoWithProps repoWithProps = new RepoWithProps(repo, repoPropsMap); + dataRepository.updateRepo(repoWithProps); + + // Create book and sync it + testUtils.setupBook("booky", ""); + exceptionRule.expect(IOException.class); + exceptionRule.expectMessage("matches a rule in .orgzlyignore"); + testUtils.syncOrThrow(); + } + + + /** + * Ensures that file names and book names are not parsed/created differently during + * force-loading. + */ + @Test + public void testForceLoadBookInSubfolder() { + Repo repo = testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); + BookView bookView = testUtils.setupBook("a folder/a book", "content"); + testUtils.sync(); + var books = dataRepository.getBooks(); + assertEquals(1, books.size()); + assertEquals("a folder/a book", books.get(0).getBook().getName()); + dataRepository.forceLoadBook(bookView.getBook().getId()); + books = dataRepository.getBooks(); + assertEquals(1, books.size()); + // Check that the name has not changed + assertEquals("a folder/a book", books.get(0).getBook().getName()); + } + /** * 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 @@ -533,4 +657,126 @@ public void testBookStatusAfterMultipleSyncsFollowingRemoteFileDeletion() throws assertNull(book.getLinkRepo()); assertEquals(BookSyncStatus.BOOK_WITH_PREVIOUS_ERROR_AND_NO_LINK.toString(), book.getBook().getSyncStatus()); } + + @Test + public void testSpaceSeparatedBookName() { + Repo repo = testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); + testUtils.setupRook(repo, "mock://repo-a/Book%20Name.org", "", "1abcdef", 1400067155); + + testUtils.sync(); + + BookView bookView = dataRepository.getBooks().get(0); + assertNotNull(bookView.getSyncedTo()); + assertEquals("Book Name", bookView.getBook().getName()); + assertEquals("mock://repo-a/Book%20Name.org", bookView.getSyncedTo().getUri().toString()); + assertEquals("Loaded from mock://repo-a/Book%20Name.org", + bookView.getBook().getLastAction().getMessage()); + } + + @Test + public void testForceLoadingBookWithLink() throws IOException { + Repo repo = testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); + testUtils.setupRook(repo, "mock://repo-a/booky.org", "New content", "abc", 1234567890000L); + Book book = testUtils.setupBook("booky", "First book used for testing\n* Note A").getBook(); + dataRepository.setLink(book.getId(), repo); + dataRepository.forceLoadBook(book.getId()); + + assertEquals(context.getString(R.string.force_loaded_from_uri, "mock://repo-a/booky.org") + , dataRepository.getBook(book.getName()).getLastAction().getMessage()); + assertEquals("New content\n\n", dataRepository.getBookContent("booky", BookFormat.ORG)); + } + + /** + * To ensure that book names are not parsed/constructed differently during force load + */ + @Test + public void testForceLoadBookWithSpaceInName() { + Repo repo = testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); + testUtils.setupRook(repo, "mock://repo-a/Book%20Name.org", "", "1abcdef", 1400067155); + + testUtils.sync(); + + BookView bookView = dataRepository.getBooks().get(0); + assertEquals("Book Name", bookView.getBook().getName()); + + dataRepository.forceLoadBook(bookView.getBook().getId()); + assertEquals("Book Name", dataRepository.getBooks().get(0).getBook().getName()); + } + + @Test + public void testForceLoadingBookWithNoLinkNoRepos() { + BookView book = testUtils.setupBook("booky", "First book used for testing\n* Note A"); + + exceptionRule.expect(IOException.class); + exceptionRule.expectMessage(context.getString(R.string.message_book_has_no_link)); + dataRepository.forceLoadBook(book.getBook().getId()); + } + + @Test + public void testForceLoadingBookWithNoLinkSingleRepo() { + testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); + BookView book = testUtils.setupBook("booky", "First book used for testing\n* Note A"); + + exceptionRule.expect(IOException.class); + exceptionRule.expectMessage(context.getString(R.string.message_book_has_no_link)); + dataRepository.forceLoadBook(book.getBook().getId()); + } + + /* Books view was returning multiple entries for the same book, due to duplicates in encodings + * table. The last statement in this method will fail if there are multiple books matching. + */ + @Test + public void testForceLoadingMultipleTimes() { + Repo repo = testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); + testUtils.setupRook(repo, "mock://repo-a/book-one.org", "New content", "abc", 1234567890000L); + Book book = testUtils.setupBook("book-one", "First book used for testing\n* Note A").getBook(); + dataRepository.setLink(book.getId(), repo); + dataRepository.forceLoadBook(book.getId()); + assertEquals( + context.getString(R.string.force_loaded_from_uri, "mock://repo-a/book-one.org"), + dataRepository.getBook(book.getId()).getLastAction().getMessage() + ); + dataRepository.forceLoadBook(book.getId()); + assertEquals( + context.getString(R.string.force_loaded_from_uri, "mock://repo-a/book-one.org"), + dataRepository.getBook(book.getId()).getLastAction().getMessage() + ); + } + + @Test + public void testForceSavingBookWithNoLinkAndMultipleRepos() { + testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); + testUtils.setupRepo(RepoType.MOCK, "mock://repo-b"); + Book book = testUtils.setupBook("book-one", "First book used for testing\n* Note A").getBook(); + exceptionRule.expect(IOException.class); + exceptionRule.expectMessage(context.getString(R.string.force_saving_failed, context.getString(R.string.multiple_repos))); + dataRepository.forceSaveBook(book.getId()); + } + + @Test + public void testForceSavingBookWithNoLinkNoRepos() { + Book book = testUtils.setupBook("book-one", "First book used for testing\n* Note A").getBook(); + exceptionRule.expect(IOException.class); + exceptionRule.expectMessage(context.getString(R.string.force_saving_failed, context.getString(R.string.no_repos))); + dataRepository.forceSaveBook(book.getId()); + } + + @Test + public void testForceSavingBookWithNoLinkSingleRepo() { + testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); + Book book = testUtils.setupBook("book-one", "First book used for testing\n* Note A").getBook(); + dataRepository.forceSaveBook(book.getId()); + assertEquals(context.getString(R.string.force_saved_to_uri, "mock://repo-a/book-one.org") + , dataRepository.getBook(book.getId()).getLastAction().getMessage()); + } + + @Test + public void testForceSavingBookWithLink() { + Repo repo = testUtils.setupRepo(RepoType.MOCK, "mock://repo-a"); + Book book = testUtils.setupBook("booky", "First book used for testing\n* Note A", repo).getBook(); + dataRepository.setLink(book.getId(), repo); + dataRepository.forceSaveBook(book.getId()); + assertEquals(context.getString(R.string.force_saved_to_uri, "mock://repo-a/booky.org") + , dataRepository.getBook(book.getId()).getLastAction().getMessage()); + } } diff --git a/app/src/main/java/com/orgzly/android/BookName.java b/app/src/main/java/com/orgzly/android/BookName.java index 139ea2d7e..be99f2ed1 100644 --- a/app/src/main/java/com/orgzly/android/BookName.java +++ b/app/src/main/java/com/orgzly/android/BookName.java @@ -5,7 +5,9 @@ import androidx.documentfile.provider.DocumentFile; import com.orgzly.BuildConfig; +import com.orgzly.android.db.entity.BookView; import com.orgzly.android.repos.Rook; +import com.orgzly.android.repos.VersionedRook; import com.orgzly.android.util.LogUtils; import java.util.regex.Matcher; @@ -21,34 +23,39 @@ public class BookName { private static final Pattern PATTERN = Pattern.compile("(.*)\\.(org)(\\.txt)?$"); private static final Pattern SKIP_PATTERN = Pattern.compile("^\\.#.*"); - private final String mFileName; + private final String mRepoRelativePath; private final String mName; private final BookFormat mFormat; - private BookName(String fileName, String name, BookFormat format) { - mFileName = fileName; + private BookName(String repoRelativePath, String name, BookFormat format) { + mRepoRelativePath = repoRelativePath; mName = name; mFormat = format; } - public static String getFileName(Context context, com.orgzly.android.db.entity.BookView bookView) { + public static String getRepoRelativePath(BookView bookView) { if (bookView.getSyncedTo() != null) { - return getFileName(context, bookView.getSyncedTo().getUri()); - + VersionedRook vrook = bookView.getSyncedTo(); + return getRepoRelativePath(vrook.getRepoUri(), vrook.getUri()); } else { - return fileName(bookView.getBook().getName(), BookFormat.ORG); + // There is no remote book; we can only guess the repo path from the book's name. + return repoRelativePath(bookView.getBook().getName(), BookFormat.ORG); } } + /** + * Used when creating a Book from an imported file. + * @param context Used for getting a DocumentFile, if possible + * @param uri URI provided by the file picker + * @return The book's file name + */ public static String getFileName(Context context, Uri uri) { String fileName; - DocumentFile documentFile = DocumentFile.fromSingleUri(context, uri); if ("content".equals(uri.getScheme()) && documentFile != null) { // Try using DocumentFile first (KitKat and above) fileName = documentFile.getName(); - } else { // Just get the last path segment fileName = uri.getLastPathSegment(); } @@ -62,38 +69,60 @@ public static String getFileName(Context context, Uri uri) { return fileName; } - public static BookName getInstance(Context context, Rook rook) { - return fromFileName(getFileName(context, rook.getUri())); + public static String getRepoRelativePath(Uri repoUri, Uri fileUri) { + /* The content:// repository type requires special handling */ + if ("content".equals(repoUri.getScheme())) { + String repoUriLastSegment = repoUri.toString().replaceAll("^.*/", ""); + String repoRootUriSegment = repoUri + "/document/" + repoUriLastSegment + "%2F"; + return Uri.decode(fileUri.toString().replace(repoRootUriSegment, "")); + } else { + // Just return the decoded fileUri stripped of the repoUri (if present), and stripped + // of any leading / (if present). + return Uri.decode( + fileUri.toString().replace(repoUri.toString(), "") + ).replaceFirst("^/", ""); + } + } + + public static BookName fromRook(Rook rook) { + return fromRepoRelativePath(getRepoRelativePath(rook.getRepoUri(), rook.getUri())); } - public static boolean isSupportedFormatFileName(String fileName) { - return PATTERN.matcher(fileName).matches() && !SKIP_PATTERN.matcher(fileName).matches(); + public static boolean isSupportedFormatFileName(String path) { + return PATTERN.matcher(path).matches() && !SKIP_PATTERN.matcher(path).matches(); } - public static String fileName(String name, BookFormat format) { + public static String repoRelativePath(String name, BookFormat format) { if (format == BookFormat.ORG) { return name + ".org"; + } else { + throw new IllegalArgumentException("Unsupported format " + format); + } + } + public static String lastPathSegment(String name, BookFormat format) { + if (format == BookFormat.ORG) { + return Uri.parse(name).getLastPathSegment() + ".org"; } else { throw new IllegalArgumentException("Unsupported format " + format); } } - public static BookName fromFileName(String fileName) { - if (fileName != null) { - Matcher m = PATTERN.matcher(fileName); + public static BookName fromRepoRelativePath(String repoRelativePath) { + if (repoRelativePath != null) { + Matcher m = PATTERN.matcher(repoRelativePath); if (m.find()) { String name = m.group(1); String extension = m.group(2); if (extension.equals("org")) { - return new BookName(fileName, name, BookFormat.ORG); + return new BookName(repoRelativePath, name, BookFormat.ORG); } } } - throw new IllegalArgumentException("Unsupported book file name " + fileName); + throw new IllegalArgumentException("Unsupported book file name " + repoRelativePath); } public String getName() { @@ -104,8 +133,8 @@ public BookFormat getFormat() { return mFormat; } - public String getFileName() { - return mFileName; + public String getRepoRelativePath() { + return mRepoRelativePath; } } diff --git a/app/src/main/java/com/orgzly/android/LocalStorage.java b/app/src/main/java/com/orgzly/android/LocalStorage.java index 360237600..c3f476753 100644 --- a/app/src/main/java/com/orgzly/android/LocalStorage.java +++ b/app/src/main/java/com/orgzly/android/LocalStorage.java @@ -34,7 +34,7 @@ public LocalStorage(Context context) { * @throws IOException if external directory is not available */ public File getExportFile(String name, BookFormat format) throws IOException { - return new File(downloadsDirectory(), BookName.fileName(name, format)); + return new File(downloadsDirectory(), BookName.repoRelativePath(name, format)); } /** 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 c6eb371ef..da70ff72f 100644 --- a/app/src/main/java/com/orgzly/android/data/DataRepository.kt +++ b/app/src/main/java/com/orgzly/android/data/DataRepository.kt @@ -81,9 +81,9 @@ class DataRepository @Inject constructor( BookAction.Type.PROGRESS, resources.getString(R.string.force_loading_from_uri, book.linkRepo.url))) - val fileName = BookName.getFileName(context, book) + val repoRelativePath = BookName.getRepoRelativePath(book) - val loadedBook = loadBookFromRepo(book.linkRepo.id, book.linkRepo.type, book.linkRepo.url, fileName) + val loadedBook = loadBookFromRepo(book.linkRepo.id, book.linkRepo.type, book.linkRepo.url, repoRelativePath) setBookLastActionAndSyncStatus(loadedBook!!.book.id, BookAction.forNow( BookAction.Type.INFO, @@ -104,7 +104,7 @@ class DataRepository @Inject constructor( val book = getBookView(bookId) ?: throw IOException(resources.getString(R.string.book_does_not_exist_anymore)) - val fileName: String = BookName.getFileName(context, book) + val repositoryPath: String = BookName.getRepoRelativePath(book) try { /* Prefer link. */ @@ -114,7 +114,7 @@ class DataRepository @Inject constructor( BookAction.Type.PROGRESS, resources.getString(R.string.force_saving_to_uri, repoEntity))) - saveBookToRepo(repoEntity, fileName, book, BookFormat.ORG) + saveBookToRepo(repoEntity, repositoryPath, book, BookFormat.ORG) val savedBook = getBookView(bookId) @@ -145,7 +145,7 @@ class DataRepository @Inject constructor( @Throws(IOException::class) fun saveBookToRepo( repoEntity: Repo, - fileName: String, + repositoryPath: String, bookView: BookView, @Suppress("UNUSED_PARAMETER") format: BookFormat) { @@ -159,7 +159,7 @@ class DataRepository @Inject constructor( NotesOrgExporter(this).exportBook(bookView.book, tmpFile) /* Upload to repo. */ - uploadedBook = repo.storeBook(tmpFile, fileName) + uploadedBook = repo.storeBook(tmpFile, repositoryPath) } finally { /* Delete temporary file. */ @@ -387,7 +387,7 @@ class DataRepository @Inject constructor( /* Do not rename if the new filename will be ignored */ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - RepoUtils.ensureFileNameIsNotIgnored(repo, BookName.fileName(name, BookFormat.ORG)) + RepoUtils.ensurePathIsNotIgnored(repo, BookName.repoRelativePath(name, BookFormat.ORG)) val movedVrook = repo.renameBook(vrook.uri, name) @@ -514,8 +514,8 @@ class DataRepository @Inject constructor( // Ensure that the resulting file name is not ignored in this repo val syncRepo = getRepoInstance(repo.id, repo.type, repo.url) val bookName = getBook(bookId)!!.name - val fileName = BookName.fileName(bookName, BookFormat.ORG) - RepoUtils.ensureFileNameIsNotIgnored(syncRepo, fileName) + val repoRelativePath = BookName.repoRelativePath(bookName, BookFormat.ORG) + RepoUtils.ensurePathIsNotIgnored(syncRepo, repoRelativePath) } db.bookLink().upsert(bookId, repoId) @@ -1635,13 +1635,13 @@ class DataRepository @Inject constructor( @Throws(IOException::class) fun loadBookFromRepo(rook: Rook): BookView? { - val fileName = BookName.getFileName(context, rook.uri) + val repoRelativePath = BookName.getRepoRelativePath(rook.repoUri, rook.uri) - return loadBookFromRepo(rook.repoId, rook.repoType, rook.repoUri.toString(), fileName) + return loadBookFromRepo(rook.repoId, rook.repoType, rook.repoUri.toString(), repoRelativePath) } @Throws(IOException::class) - fun loadBookFromRepo(repoId: Long, repoType: RepoType, repoUrl: String, fileName: String): BookView? { + fun loadBookFromRepo(repoId: Long, repoType: RepoType, repoUrl: String, repoRelativePath: String): BookView? { val book: BookView? val repo = getRepoInstance(repoId, repoType, repoUrl) @@ -1649,9 +1649,9 @@ class DataRepository @Inject constructor( val tmpFile = getTempBookFile() try { /* Download from repo. */ - val vrook = repo.retrieveBook(fileName, tmpFile) + val vrook = repo.retrieveBook(repoRelativePath, tmpFile) - val bookName = BookName.fromFileName(fileName) + val bookName = BookName.fromRepoRelativePath(repoRelativePath) /* Store from file to Shelf. */ book = loadBookFromFile(bookName.name, bookName.format, tmpFile, vrook) diff --git a/app/src/main/java/com/orgzly/android/external/ExternalAccessReceiver.kt b/app/src/main/java/com/orgzly/android/external/ExternalAccessReceiver.kt index 97b8ec116..300f3d41b 100644 --- a/app/src/main/java/com/orgzly/android/external/ExternalAccessReceiver.kt +++ b/app/src/main/java/com/orgzly/android/external/ExternalAccessReceiver.kt @@ -8,15 +8,14 @@ import com.orgzly.android.external.actionhandlers.* import com.orgzly.android.external.types.Response class ExternalAccessReceiver : BroadcastReceiver() { - val actionHandlers = listOf( + override fun onReceive(context: Context?, intent: Intent?) { + val actionHandlers = listOf( GetOrgInfo(), RunSearch(), EditNotes(), EditSavedSearches(), ManageWidgets() - ) - - override fun onReceive(context: Context?, intent: Intent?) { + ) val response = actionHandlers.asSequence() .mapNotNull { it.handle(intent!!, context!!) } .firstOrNull() diff --git a/app/src/main/java/com/orgzly/android/git/GitFileSynchronizer.java b/app/src/main/java/com/orgzly/android/git/GitFileSynchronizer.java index cfa0fa4fe..f626246a6 100644 --- a/app/src/main/java/com/orgzly/android/git/GitFileSynchronizer.java +++ b/app/src/main/java/com/orgzly/android/git/GitFileSynchronizer.java @@ -60,11 +60,11 @@ private GitTransportSetter transportSetter() { public void retrieveLatestVersionOfFile( String repositoryPath, File destination) throws IOException { - MiscUtils.copyFile(repoDirectoryFile(repositoryPath), destination); + MiscUtils.copyFile(workTreeFile(repositoryPath), destination); } public InputStream openRepoFileInputStream(String repositoryPath) throws FileNotFoundException { - return new FileInputStream(repoDirectoryFile(repositoryPath)); + return new FileInputStream(workTreeFile(repositoryPath)); } private void fetch() throws IOException { @@ -119,13 +119,13 @@ private String createMergeBranchName(String repositoryPath, ObjectId commitHash) } public boolean updateAndCommitFileFromRevisionAndMerge( - File sourceFile, String repositoryPath, + File sourceFile, String repoRelativePath, ObjectId fileRevision, RevCommit revision) throws IOException { ensureRepoIsClean(); - if (updateAndCommitFileFromRevision(sourceFile, repositoryPath, fileRevision)) { + if (updateAndCommitFileFromRevision(sourceFile, repoRelativePath, fileRevision)) { if (BuildConfig.LOG_DEBUG) { - LogUtils.d(TAG, String.format("File '%s' committed without conflicts.", repositoryPath)); + LogUtils.d(TAG, String.format("File '%s' committed without conflicts.", repoRelativePath)); } return true; } @@ -134,7 +134,7 @@ public boolean updateAndCommitFileFromRevisionAndMerge( if (BuildConfig.LOG_DEBUG) { LogUtils.d(TAG, String.format("originalBranch is set to %s", originalBranch)); } - String mergeBranch = createMergeBranchName(repositoryPath, fileRevision); + String mergeBranch = createMergeBranchName(repoRelativePath, fileRevision); if (BuildConfig.LOG_DEBUG) { LogUtils.d(TAG, String.format("originalBranch is set to %s", originalBranch)); LogUtils.d(TAG, String.format("Temporary mergeBranch is set to %s", mergeBranch)); @@ -157,12 +157,12 @@ public boolean updateAndCommitFileFromRevisionAndMerge( setStartPoint(branchStartPoint).setName(mergeBranch).call(); if (!currentHead().equals(branchStartPoint)) throw new IOException("Failed to create new branch at " + branchStartPoint.toString()); - if (!updateAndCommitFileFromRevision(sourceFile, repositoryPath, fileRevision)) + if (!updateAndCommitFileFromRevision(sourceFile, repoRelativePath, fileRevision)) throw new IOException( String.format( "The provided file revision %s for %s is " + "not the same as the one found in the provided commit %s.", - fileRevision.toString(), repositoryPath, revision.toString())); + fileRevision.toString(), repoRelativePath, revision.toString())); mergeSucceeded = doMerge(mergeTarget); if (mergeSucceeded) { RevCommit merged = currentHead(); @@ -287,11 +287,11 @@ private void gitResetMerge() throws IOException, GitAPIException { } public boolean updateAndCommitFileFromRevision( - File sourceFile, String repositoryPath, ObjectId revision) throws IOException { + File sourceFile, String repoRelativePath, ObjectId revision) throws IOException { ensureRepoIsClean(); - ObjectId repositoryRevision = getFileRevision(repositoryPath, currentHead()); + ObjectId repositoryRevision = getFileRevision(repoRelativePath, currentHead()); if (repositoryRevision.equals(revision)) { - updateAndCommitFile(sourceFile, repositoryPath); + updateAndCommitFile(sourceFile, repoRelativePath); return true; } return false; @@ -377,7 +377,7 @@ public boolean attemptReturnToMainBranch() throws IOException { public void updateAndCommitExistingFile(File sourceFile, String repositoryPath) throws IOException { ensureRepoIsClean(); - File destinationFile = repoDirectoryFile(repositoryPath); + File destinationFile = workTreeFile(repositoryPath); if (!destinationFile.exists()) { throw new FileNotFoundException("File " + destinationFile + " does not exist"); } @@ -392,21 +392,32 @@ public void updateAndCommitExistingFile(File sourceFile, String repositoryPath) */ public void addAndCommitNewFile(File sourceFile, String repositoryPath) throws IOException { ensureRepoIsClean(); - File destinationFile = repoDirectoryFile(repositoryPath); + File destinationFile = workTreeFile(repositoryPath); if (destinationFile.exists()) { throw new IOException("Can't add new file " + repositoryPath + " that already exists."); } + ensureDirectoryHierarchy(repositoryPath); updateAndCommitFile(sourceFile, repositoryPath); } + private void ensureDirectoryHierarchy(String repositoryPath) throws IOException { + if (repositoryPath.contains("/")) { + File targetDir = workTreeFile(repositoryPath).getParentFile(); + if (!(targetDir.exists() || targetDir.mkdirs())) { + throw new IOException("The directory " + targetDir.getAbsolutePath() + " could " + + "not be created"); + } + } + } + private void updateAndCommitFile( - File sourceFile, String repositoryPath) throws IOException { - File destinationFile = repoDirectoryFile(repositoryPath); + File sourceFile, String repoRelativePath) throws IOException { + File destinationFile = workTreeFile(repoRelativePath); MiscUtils.copyFile(sourceFile, destinationFile); try { - git.add().addFilepattern(repositoryPath).call(); + git.add().addFilepattern(repoRelativePath).call(); if (!gitRepoIsClean()) - commit(String.format("Orgzly update: %s", repositoryPath)); + commit(String.format("Orgzly update: %s", repoRelativePath)); } catch (GitAPIException e) { throw new IOException("Failed to commit changes."); } @@ -432,11 +443,11 @@ public RevCommit getCommit(String identifier) throws IOException { } public RevCommit getLastCommitOfFile(Uri uri) throws GitAPIException { - String fileName = uri.toString().replaceFirst("^/", ""); - return git.log().setMaxCount(1).addPath(fileName).call().iterator().next(); + String repoRelativePath = uri.toString().replaceFirst("^/", ""); + return git.log().setMaxCount(1).addPath(repoRelativePath).call().iterator().next(); } - public String repoPath() { + public String workTreePath() { return git.getRepository().getWorkTree().getAbsolutePath(); } @@ -454,8 +465,8 @@ private void ensureRepoIsClean() throws IOException { throw new IOException("Refusing to update because there are uncommitted changes."); } - public File repoDirectoryFile(String filePath) { - return new File(repoPath(), filePath); + public File workTreeFile(String filePath) { + return new File(workTreePath(), filePath); } public boolean isEmptyRepo() throws IOException{ @@ -469,37 +480,38 @@ public ObjectId getFileRevision(String pathString, RevCommit commit) throws IOEx public boolean deleteFileFromRepo(Uri uri) throws IOException { if (mergeWithRemote()) { - String fileName = uri.toString().replaceFirst("^/", ""); + String repoRelativePath = uri.toString().replaceFirst("^/", ""); try { - git.rm().addFilepattern(fileName).call(); + git.rm().addFilepattern(repoRelativePath).call(); if (!gitRepoIsClean()) - commit(String.format("Orgzly deletion: %s", fileName)); + commit(String.format("Orgzly deletion: %s", repoRelativePath)); return true; } catch (GitAPIException e) { - throw new IOException(String.format("Failed to commit deletion of %s, %s", fileName, e.getMessage())); + throw new IOException(String.format("Failed to commit deletion of %s, %s", repoRelativePath, e.getMessage())); } } else { return false; } } - public boolean renameFileInRepo(String oldFileName, String newFileName) throws IOException { + public boolean renameFileInRepo(String oldPath, String newPath) throws IOException { ensureRepoIsClean(); if (mergeWithRemote()) { - File oldFile = repoDirectoryFile(oldFileName); - File newFile = repoDirectoryFile(newFileName); + File oldFile = workTreeFile(oldPath); + File newFile = workTreeFile(newPath); // Abort if destination file exists if (newFile.exists()) { - throw new IOException("Can't add new file " + newFileName + " that already exists."); + throw new IOException("Repository file " + newPath + " already exists."); } + ensureDirectoryHierarchy(newPath); // Copy the file contents and add it to the index MiscUtils.copyFile(oldFile, newFile); try { - git.add().addFilepattern(newFileName).call(); + git.add().addFilepattern(newPath).call(); if (!gitRepoIsClean()) { // Remove the old file from the Git index - git.rm().addFilepattern(oldFileName).call(); - commit(String.format("Orgzly: rename %s to %s", oldFileName, newFileName)); + git.rm().addFilepattern(oldPath).call(); + commit(String.format("Orgzly: rename %s to %s", oldPath, newPath)); return true; } } catch (GitAPIException e) { diff --git a/app/src/main/java/com/orgzly/android/repos/ContentRepo.java b/app/src/main/java/com/orgzly/android/repos/ContentRepo.java deleted file mode 100644 index ef87d2ac2..000000000 --- a/app/src/main/java/com/orgzly/android/repos/ContentRepo.java +++ /dev/null @@ -1,215 +0,0 @@ -package com.orgzly.android.repos; - -import android.content.Context; -import android.net.Uri; -import android.os.Build; -import android.provider.DocumentsContract; -import android.util.Log; - -import androidx.documentfile.provider.DocumentFile; - -import com.orgzly.BuildConfig; -import com.orgzly.android.BookName; -import com.orgzly.android.db.entity.Repo; -import com.orgzly.android.util.LogUtils; -import com.orgzly.android.util.MiscUtils; - -import java.io.File; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; - -/** - * Using DocumentFile, for devices running Lollipop or later. - */ -public class ContentRepo implements SyncRepo { - private static final String TAG = ContentRepo.class.getName(); - - public static final String SCHEME = "content"; - - private final long repoId; - private final Uri repoUri; - - private final Context context; - - private final DocumentFile repoDocumentFile; - - public ContentRepo(RepoWithProps repoWithProps, Context context) { - Repo repo = repoWithProps.getRepo(); - - this.repoId = repo.getId(); - this.repoUri = Uri.parse(repo.getUrl()); - - this.context = context; - - this.repoDocumentFile = DocumentFile.fromTreeUri(context, repoUri); - } - - @Override - public boolean isConnectionRequired() { - return false; - } - - @Override - public boolean isAutoSyncSupported() { - return true; - } - - @Override - public Uri getUri() { - return repoUri; - } - - @Override - public List getBooks() throws IOException { - List result = new ArrayList<>(); - - DocumentFile[] files = repoDocumentFile.listFiles(); - - RepoIgnoreNode ignores = new RepoIgnoreNode(this); - - if (files != null) { - // Can't compare TreeDocumentFile - // Arrays.sort(files); - - for (DocumentFile file : files) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (ignores.isPathIgnored(Objects.requireNonNull(file.getName()), false)) { - continue; - } - } if (BookName.isSupportedFormatFileName(file.getName())) { - - if (BuildConfig.LOG_DEBUG) { - LogUtils.d(TAG, - "file.getName()", file.getName(), - "getUri()", getUri(), - "repoDocumentFile.getUri()", repoDocumentFile.getUri(), - "file", file, - "file.getUri()", file.getUri(), - "file.getParentFile()", file.getParentFile().getUri()); - } - - result.add(new VersionedRook( - repoId, - RepoType.DOCUMENT, - getUri(), - file.getUri(), - String.valueOf(file.lastModified()), - file.lastModified() - )); - } - } - - } else { - Log.e(TAG, "Listing files in " + getUri() + " returned null."); - } - - return result; - } - - @Override - public VersionedRook retrieveBook(String fileName, File destinationFile) throws IOException { - DocumentFile sourceFile = repoDocumentFile.findFile(fileName); - if (sourceFile == null) { - throw new FileNotFoundException("Book " + fileName + " not found in " + repoUri); - } else { - if (BuildConfig.LOG_DEBUG) { - LogUtils.d(TAG, "Found DocumentFile for " + fileName + ": " + sourceFile.getUri()); - } - } - - /* "Download" the file. */ - try (InputStream is = context.getContentResolver().openInputStream(sourceFile.getUri())) { - MiscUtils.writeStreamToFile(is, destinationFile); - } - - String rev = String.valueOf(sourceFile.lastModified()); - long mtime = sourceFile.lastModified(); - - return new VersionedRook(repoId, RepoType.DOCUMENT, repoUri, sourceFile.getUri(), rev, mtime); - } - - @Override - public InputStream openRepoFileInputStream(String fileName) throws IOException { - DocumentFile sourceFile = repoDocumentFile.findFile(fileName); - if (sourceFile == null) throw new FileNotFoundException(); - return context.getContentResolver().openInputStream(sourceFile.getUri()); - } - - @Override - public VersionedRook storeBook(File file, String fileName) throws IOException { - if (!file.exists()) { - throw new FileNotFoundException("File " + file + " does not exist"); - } - - /* Delete existing file. */ - DocumentFile existingFile = repoDocumentFile.findFile(fileName); - if (existingFile != null) { - existingFile.delete(); - } - - /* Create new file. */ - DocumentFile destinationFile = repoDocumentFile.createFile("text/*", fileName); - - if (destinationFile == null) { - throw new IOException("Failed creating " + fileName + " in " + repoUri); - } - - Uri uri = destinationFile.getUri(); - - /* Write file content to uri. */ - OutputStream out = context.getContentResolver().openOutputStream(uri); - try { - MiscUtils.writeFileToStream(file, out); - } finally { - if (out != null) { - out.close(); - } - } - - String rev = String.valueOf(destinationFile.lastModified()); - long mtime = System.currentTimeMillis(); - - return new VersionedRook(repoId, RepoType.DOCUMENT, getUri(), uri, rev, mtime); - } - - @Override - public VersionedRook renameBook(Uri from, String name) throws IOException { - DocumentFile fromDocFile = DocumentFile.fromSingleUri(context, from); - BookName bookName = BookName.fromFileName(fromDocFile.getName()); - String newFileName = BookName.fileName(name, bookName.getFormat()); - - /* Check if document already exists. */ - DocumentFile existingFile = repoDocumentFile.findFile(newFileName); - if (existingFile != null) { - throw new IOException("File at " + existingFile.getUri() + " already exists"); - } - - Uri newUri = DocumentsContract.renameDocument(context.getContentResolver(), from, newFileName); - - long mtime = fromDocFile.lastModified(); - String rev = String.valueOf(mtime); - - return new VersionedRook(repoId, RepoType.DOCUMENT, getUri(), newUri, rev, mtime); - } - - @Override - public void delete(Uri uri) throws IOException { - DocumentFile docFile = DocumentFile.fromSingleUri(context, uri); - - if (docFile != null && docFile.exists()) { - if (! docFile.delete()) { - throw new IOException("Failed deleting document " + uri); - } - } - } - - @Override - public String toString() { - return getUri().toString(); - } -} diff --git a/app/src/main/java/com/orgzly/android/repos/DatabaseRepo.java b/app/src/main/java/com/orgzly/android/repos/DatabaseRepo.java index 7d371a12e..a4f575f28 100644 --- a/app/src/main/java/com/orgzly/android/repos/DatabaseRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/DatabaseRepo.java @@ -48,24 +48,24 @@ public List getBooks() { } @Override - public VersionedRook retrieveBook(String fileName, File file) { - Uri uri = repoUri.buildUpon().appendPath(fileName).build(); + public VersionedRook retrieveBook(String repoRelativePath, File file) { + Uri uri = repoUri.buildUpon().appendPath(repoRelativePath).build(); return dbRepo.retrieveBook(repoId, repoUri, uri, file); } @Override - public InputStream openRepoFileInputStream(String fileName) throws IOException { + public InputStream openRepoFileInputStream(String repoRelativePath) throws IOException { throw new UnsupportedOperationException("Not implemented"); } @Override - public VersionedRook storeBook(File file, String fileName) throws IOException { + public VersionedRook storeBook(File file, String repoRelativePath) throws IOException { String content = MiscUtils.readStringFromFile(file); String rev = "MockedRevision-" + System.currentTimeMillis(); long mtime = System.currentTimeMillis(); - Uri uri = repoUri.buildUpon().appendPath(fileName).build(); + Uri uri = repoUri.buildUpon().appendPath(repoRelativePath).build(); VersionedRook vrook = new VersionedRook(repoId, RepoType.MOCK, repoUri, uri, rev, mtime); @@ -73,9 +73,9 @@ public VersionedRook storeBook(File file, String fileName) throws IOException { } @Override - public VersionedRook renameBook(Uri fromUri, String name) { - Uri toUri = UriUtils.getUriForNewName(fromUri, name); - return dbRepo.renameBook(repoId, fromUri, toUri); + public VersionedRook renameBook(Uri oldFullUri, String newName) { + Uri toUri = UriUtils.getUriForNewName(oldFullUri, newName); + return dbRepo.renameBook(repoId, oldFullUri, toUri); } @Override diff --git a/app/src/main/java/com/orgzly/android/repos/DirectoryRepo.java b/app/src/main/java/com/orgzly/android/repos/DirectoryRepo.java index db2b59064..aaaddca22 100644 --- a/app/src/main/java/com/orgzly/android/repos/DirectoryRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/DirectoryRepo.java @@ -124,8 +124,8 @@ public List getBooks() { } @Override - public VersionedRook retrieveBook(String fileName, File destinationFile) throws IOException { - Uri uri = repoUri.buildUpon().appendPath(fileName).build(); + public VersionedRook retrieveBook(String repoRelativePath, File destinationFile) throws IOException { + Uri uri = repoUri.buildUpon().appendPath(repoRelativePath).build(); String path = uri.getPath(); @@ -145,17 +145,17 @@ public VersionedRook retrieveBook(String fileName, File destinationFile) throws } @Override - public InputStream openRepoFileInputStream(String fileName) throws IOException { - return new FileInputStream(repoUri.buildUpon().appendPath(fileName).build().getPath()); + public InputStream openRepoFileInputStream(String repoRelativePath) throws IOException { + return new FileInputStream(repoUri.buildUpon().appendPath(repoRelativePath).build().getPath()); } @Override - public VersionedRook storeBook(File file, String fileName) throws IOException { + public VersionedRook storeBook(File file, String repoRelativePath) throws IOException { if (!file.exists()) { throw new FileNotFoundException("File " + file + " does not exist"); } - File destinationFile = new File(mDirectory, fileName); + File destinationFile = new File(mDirectory, repoRelativePath); File destinationFileParent = destinationFile.getParentFile(); @@ -172,21 +172,21 @@ public VersionedRook storeBook(File file, String fileName) throws IOException { String rev = String.valueOf(destinationFile.lastModified()); long mtime = destinationFile.lastModified(); - Uri uri = repoUri.buildUpon().appendPath(fileName).build(); + Uri uri = repoUri.buildUpon().appendPath(repoRelativePath).build(); return new VersionedRook(repoId, RepoType.DIRECTORY, repoUri, uri, rev, mtime); } @Override - public VersionedRook renameBook(Uri fromUri, String name) throws IOException { - String fromFilePath = fromUri.getPath(); + public VersionedRook renameBook(Uri oldFullUri, String newName) throws IOException { + String fromFilePath = oldFullUri.getPath(); if (fromFilePath == null) { - throw new IllegalArgumentException("No path in " + fromUri); + throw new IllegalArgumentException("No path in " + oldFullUri); } File fromFile = new File(fromFilePath); - Uri newUri = UriUtils.getUriForNewName(fromUri, name); + Uri newUri = UriUtils.getUriForNewName(oldFullUri, newName); String toFilePath = newUri.getPath(); if (toFilePath == null) { diff --git a/app/src/main/java/com/orgzly/android/repos/DocumentRepo.java b/app/src/main/java/com/orgzly/android/repos/DocumentRepo.java new file mode 100644 index 000000000..03dc61eca --- /dev/null +++ b/app/src/main/java/com/orgzly/android/repos/DocumentRepo.java @@ -0,0 +1,301 @@ +package com.orgzly.android.repos; + +import android.content.Context; +import android.net.Uri; +import android.os.Build; +import android.provider.DocumentsContract; +import android.util.Log; + +import androidx.documentfile.provider.DocumentFile; + +import com.orgzly.BuildConfig; +import com.orgzly.R; +import com.orgzly.android.BookName; +import com.orgzly.android.db.entity.Repo; +import com.orgzly.android.util.LogUtils; +import com.orgzly.android.util.MiscUtils; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +/** + * Using DocumentFile, for devices running Lollipop or later. + */ +public class DocumentRepo implements SyncRepo { + private static final String TAG = DocumentRepo.class.getName(); + + public static final String SCHEME = "content"; + + private final long repoId; + private final Uri repoUri; + + private final Context context; + + private final DocumentFile repoDocumentFile; + + public DocumentRepo(RepoWithProps repoWithProps, Context context) { + Repo repo = repoWithProps.getRepo(); + + this.repoId = repo.getId(); + this.repoUri = Uri.parse(repo.getUrl()); + + this.context = context; + + this.repoDocumentFile = DocumentFile.fromTreeUri(context, repoUri); + } + + @Override + public boolean isConnectionRequired() { + return false; + } + + @Override + public boolean isAutoSyncSupported() { + return true; + } + + @Override + public Uri getUri() { + return repoUri; + } + + @Override + public List getBooks() throws IOException { + List result = new ArrayList<>(); + + List files = walkFileTree(); + + if (files.size() > 0) { + for (DocumentFile file : files) { + if (BookName.isSupportedFormatFileName(file.getName())) { + + if (BuildConfig.LOG_DEBUG) { + LogUtils.d(TAG, + "file.getName()", file.getName(), + "getUri()", getUri(), + "repoDocumentFile.getUri()", repoDocumentFile.getUri(), + "file", file, + "file.getUri()", file.getUri(), + "file.getParentFile()", file.getParentFile().getUri()); + } + + result.add(new VersionedRook( + repoId, + RepoType.DOCUMENT, + getUri(), + file.getUri(), + String.valueOf(file.lastModified()), + file.lastModified() + )); + } + } + + } else { + Log.e(TAG, "Listing files in " + getUri() + " returned null."); + } + + return result; + } + + /** + * @return All file nodes in the repo tree which are not excluded by .orgzlyignore + */ + private List walkFileTree() { + List result = new ArrayList<>(); + List directoryNodes = new ArrayList<>(); + RepoIgnoreNode ignores = new RepoIgnoreNode(this); + directoryNodes.add(repoDocumentFile); + while (!directoryNodes.isEmpty()) { + DocumentFile currentDir = directoryNodes.remove(0); + for (DocumentFile node : currentDir.listFiles()) { + String repoRelativePath = BookName.getRepoRelativePath(repoUri, node.getUri()); + if (node.isDirectory()) { + if (Build.VERSION.SDK_INT >= 26) { + if (ignores.isPathIgnored(repoRelativePath, true)) { + continue; + } + } + directoryNodes.add(node); + } else { + if (Build.VERSION.SDK_INT >= 26) { + if (ignores.isPathIgnored(repoRelativePath, false)) { + continue; + } + } result.add(node); + } + } + } + return result; + } + + private DocumentFile getDocumentFileFromPath(String path) { + String fullUri = repoDocumentFile.getUri() + Uri.encode("/" + path); + return DocumentFile.fromSingleUri(context, Uri.parse(fullUri)); + } + + @Override + public VersionedRook retrieveBook(String repoRelativePath, File destinationFile) throws IOException { + DocumentFile sourceFile = getDocumentFileFromPath(repoRelativePath); + if (sourceFile == null) { + throw new FileNotFoundException("Book " + repoRelativePath + " not found in " + repoUri); + } else { + if (BuildConfig.LOG_DEBUG) { + LogUtils.d(TAG, "Found DocumentFile for " + repoRelativePath + ": " + sourceFile.getUri()); + } + } + + /* "Download" the file. */ + try (InputStream is = context.getContentResolver().openInputStream(sourceFile.getUri())) { + MiscUtils.writeStreamToFile(is, destinationFile); + } + + String rev = String.valueOf(sourceFile.lastModified()); + long mtime = sourceFile.lastModified(); + + return new VersionedRook(repoId, RepoType.DOCUMENT, repoUri, sourceFile.getUri(), rev, mtime); + } + + @Override + public InputStream openRepoFileInputStream(String repoRelativePath) throws IOException { + DocumentFile sourceFile = getDocumentFileFromPath(repoRelativePath); + if (!sourceFile.exists()) throw new FileNotFoundException(); + return context.getContentResolver().openInputStream(sourceFile.getUri()); + } + + @Override + public VersionedRook storeBook(File file, String repoRelativePath) throws IOException { + if (!file.exists()) { + throw new FileNotFoundException("File " + file + " does not exist"); + } + DocumentFile destinationFile = getDocumentFileFromPath(repoRelativePath); + if (repoRelativePath.contains("/")) { + DocumentFile destinationDir = ensureDirectoryHierarchy(repoRelativePath); + String fileName = Uri.parse(repoRelativePath).getLastPathSegment(); + if (destinationDir.findFile(fileName) == null) { + destinationFile = destinationDir.createFile("text/*", fileName); + } + } else { + if (!destinationFile.exists()) { + repoDocumentFile.createFile("text/*", repoRelativePath); + } + } + + try (OutputStream out = context.getContentResolver().openOutputStream(destinationFile.getUri())) { + MiscUtils.writeFileToStream(file, out); + } + + long mtime = destinationFile.lastModified(); + String rev = String.valueOf(mtime); + + return new VersionedRook(repoId, RepoType.DOCUMENT, getUri(), destinationFile.getUri(), rev, mtime); + } + + /** + * Given a relative path, ensures that all directory levels are created unless they already + * exist. + * @param relativePath Path relative to the repository root directory + * @return The DocumentFile object of the leaf directory where the file should be placed. + */ + private DocumentFile ensureDirectoryHierarchy(String relativePath) { + List levels = new ArrayList<>(Arrays.asList(relativePath.split("/"))); + DocumentFile currentDir = repoDocumentFile; + while (levels.size() > 1) { + String nextDirName = levels.remove(0); + DocumentFile nextDir = currentDir.findFile(nextDirName); + if (nextDir == null) { + currentDir = currentDir.createDirectory(nextDirName); + } else { + currentDir = nextDir; + } + } + return currentDir; + } + + /** + * Allows renaming a notebook to any subdirectory (indicated with a "/"), ensuring that all + * required subdirectories are created, if they do not already exist. Note that the file is + * moved, but no "abandoned" directories are deleted. + * @param oldFullUri + * @param newName + * @return + * @throws IOException + */ + @Override + public VersionedRook renameBook(Uri oldFullUri, String newName) throws IOException { + DocumentFile oldDocFile = DocumentFile.fromSingleUri(context, oldFullUri); + long mtime = oldDocFile.lastModified(); + String rev = String.valueOf(mtime); + String oldDocFileName = oldDocFile.getName(); + Uri oldDirUri = Uri.parse( + oldFullUri.toString().replace( + Uri.encode("/" + oldDocFile.getName()), + "" + ) + ); + BookName oldBookName = BookName.fromRepoRelativePath(BookName.getRepoRelativePath(repoUri, oldFullUri)); + String newRelativePath = BookName.repoRelativePath(newName, oldBookName.getFormat()); + String newDocFileName = Uri.parse(newRelativePath).getLastPathSegment(); + DocumentFile newDir; + Uri newUri = oldFullUri; + + if (newName.contains("/")) { + newDir = ensureDirectoryHierarchy(newName); + } else { + newDir = repoDocumentFile; + } + + /* Abort if destination file already exists. */ + DocumentFile existingFile = newDir.findFile(newDocFileName); + if (existingFile != null) { + throw new IOException("File at " + existingFile.getUri() + " already exists"); + } + + if (!newDir.getUri().toString().equals(oldDirUri.toString())) { + // File should be moved to a different directory + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { + newUri = DocumentsContract.moveDocument( + context.getContentResolver(), oldFullUri, + oldDirUri, + newDir.getUri() + ); + } else { + throw new IllegalArgumentException( + context.getString(R.string.moving_between_subdirectories_requires_api_24)); + } + } + + if (!Objects.equals(newDocFileName, oldDocFileName)) { + // File should be renamed + newUri = DocumentsContract.renameDocument( + context.getContentResolver(), + newUri, + newDocFileName + ); + } + + return new VersionedRook(repoId, RepoType.DOCUMENT, repoUri, newUri, rev, mtime); + } + + @Override + public void delete(Uri uri) throws IOException { + DocumentFile docFile = DocumentFile.fromSingleUri(context, uri); + + if (docFile != null && docFile.exists()) { + if (! docFile.delete()) { + throw new IOException("Failed deleting document " + uri); + } + } + } + + @Override + public String toString() { + return getUri().toString(); + } +} diff --git a/app/src/main/java/com/orgzly/android/repos/DropboxClient.java b/app/src/main/java/com/orgzly/android/repos/DropboxClient.java index 940039415..fceec62ee 100644 --- a/app/src/main/java/com/orgzly/android/repos/DropboxClient.java +++ b/app/src/main/java/com/orgzly/android/repos/DropboxClient.java @@ -12,6 +12,7 @@ import com.dropbox.core.json.JsonReadException; import com.dropbox.core.oauth.DbxCredential; import com.dropbox.core.v2.DbxClientV2; +import com.dropbox.core.v2.files.DeleteResult; import com.dropbox.core.v2.files.FileMetadata; import com.dropbox.core.v2.files.FolderMetadata; import com.dropbox.core.v2.files.GetMetadataErrorException; @@ -145,41 +146,56 @@ public List getBooks(Uri repoUri, RepoIgnoreNode ignores) throws /* Strip trailing slashes. */ path = path.replaceAll("/+$", ""); + List folderPaths = new ArrayList<>(List.of(path)); + try { if (ROOT_PATH.equals(path) || dbxClient.files().getMetadata(path) instanceof FolderMetadata) { /* Get folder content. */ - ListFolderResult result = dbxClient.files().listFolder(path); - while (true) { - for (Metadata metadata : result.getEntries()) { - if (metadata instanceof FileMetadata) { - FileMetadata file = (FileMetadata) metadata; - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (ignores.isPathIgnored(file.getName(), false)) { - continue; + while (folderPaths.size() > 0) { + ListFolderResult result = dbxClient.files().listFolder(folderPaths.remove(0)); + while (true) { + for (Metadata metadata : result.getEntries()) { + String pathRelativeToRepoRoot = + metadata.getPathDisplay().replaceAll("^" + path + "/", ""); + if (metadata instanceof FileMetadata) { + FileMetadata file = (FileMetadata) metadata; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (ignores.isPathIgnored(pathRelativeToRepoRoot, false)) { + continue; + } } - } - if (BookName.isSupportedFormatFileName(file.getName())) { - Uri uri = repoUri.buildUpon().appendPath(file.getName()).build(); - VersionedRook book = new VersionedRook( - repoId, - RepoType.DROPBOX, - repoUri, - uri, - file.getRev(), - file.getServerModified().getTime()); - - list.add(book); + if (BookName.isSupportedFormatFileName(file.getName())) { + String encodedRelativePath = Uri.encode(pathRelativeToRepoRoot, "/"); + Uri uri = repoUri.buildUpon().appendEncodedPath(encodedRelativePath).build(); + VersionedRook book = new VersionedRook( + repoId, + RepoType.DROPBOX, + repoUri, + uri, + file.getRev(), + file.getServerModified().getTime()); + + list.add(book); + } + } + if (metadata instanceof FolderMetadata) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (ignores.isPathIgnored(pathRelativeToRepoRoot, true)) { + continue; + } + } + folderPaths.add(metadata.getPathDisplay()); } } - } - if (!result.getHasMore()) { - break; - } + if (!result.getHasMore()) { + break; + } - result = dbxClient.files().listFolderContinue(result.getCursor()); + result = dbxClient.files().listFolderContinue(result.getCursor()); + } } } else { @@ -204,13 +220,18 @@ public List getBooks(Uri repoUri, RepoIgnoreNode ignores) throws return list; } + private Uri getFullUriFromRelativePath(Uri repoUri, String repoRelativePath) { + String encodedPath = Uri.encode(repoRelativePath, "/"); + return Uri.withAppendedPath(repoUri, encodedPath); + } + /** * Download file from Dropbox and store it to a local file. */ - public VersionedRook download(Uri repoUri, String fileName, File localFile) throws IOException { + public VersionedRook download(Uri repoUri, String repoRelativePath, File localFile) throws IOException { linkedOrThrow(); - Uri uri = repoUri.buildUpon().appendPath(fileName).build(); + Uri uri = getFullUriFromRelativePath(repoUri, repoRelativePath); OutputStream out = new BufferedOutputStream(new FileOutputStream(localFile)); @@ -242,10 +263,10 @@ public VersionedRook download(Uri repoUri, String fileName, File localFile) thro } } - public InputStream streamFile(Uri repoUri, String fileName) throws IOException { + public InputStream streamFile(Uri repoUri, String repoRelativePath) throws IOException { linkedOrThrow(); - Uri uri = repoUri.buildUpon().appendPath(fileName).build(); + Uri uri = repoUri.buildUpon().appendPath(repoRelativePath).build(); FileMetadata metadata; String rev; DbxDownloader downloader; @@ -267,10 +288,10 @@ public InputStream streamFile(Uri repoUri, String fileName) throws IOException { } /** Upload file to Dropbox. */ - public VersionedRook upload(File file, Uri repoUri, String fileName) throws IOException { + public VersionedRook upload(File file, Uri repoUri, String relativePath) throws IOException { linkedOrThrow(); - Uri bookUri = repoUri.buildUpon().appendPath(fileName).build(); + Uri bookUri = getFullUriFromRelativePath(repoUri, relativePath); if (file.length() > UPLOAD_FILE_SIZE_LIMIT * 1024 * 1024) { throw new IOException(LARGE_FILE); @@ -323,6 +344,12 @@ public void delete(String path) throws IOException { public VersionedRook move(Uri repoUri, Uri from, Uri to) throws IOException { linkedOrThrow(); + /* Abort if destination file already exists. */ + try { + if (dbxClient.files().getMetadata(to.getPath()) instanceof FileMetadata) + throw new IOException("File at " + to.getPath() + " already exists"); + } catch (DbxException ignored) {} + try { RelocationResult relocationRes = dbxClient.files().moveV2(from.getPath(), to.getPath()); Metadata metadata = relocationRes.getMetadata(); @@ -348,4 +375,23 @@ public VersionedRook move(Uri repoUri, Uri from, Uri to) throws IOException { } } } + + public void deleteFolder(String path) throws IOException { + linkedOrThrow(); + + try { + if (dbxClient.files().getMetadata(path) instanceof FolderMetadata) { + dbxClient.files().deleteV2(path); + } else { + throw new IOException("Not a directory: " + path); + } + } catch (DbxException e) { + e.printStackTrace(); + if (e.getMessage() != null) { + throw new IOException("Failed deleting " + path + " on Dropbox: " + e.getMessage()); + } else { + throw new IOException("Failed deleting " + path + " on Dropbox: " + e); + } + } + } } diff --git a/app/src/main/java/com/orgzly/android/repos/DropboxRepo.java b/app/src/main/java/com/orgzly/android/repos/DropboxRepo.java index 74dd25a65..21cfdf65c 100644 --- a/app/src/main/java/com/orgzly/android/repos/DropboxRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/DropboxRepo.java @@ -3,11 +3,12 @@ import android.content.Context; import android.net.Uri; -import com.orgzly.android.util.UriUtils; +import androidx.annotation.NonNull; + +import com.orgzly.android.BookName; import java.io.File; import java.io.IOException; - import java.io.InputStream; import java.util.List; @@ -44,24 +45,27 @@ public List getBooks() throws IOException { } @Override - public VersionedRook retrieveBook(String fileName, File file) throws IOException { - return client.download(repoUri, fileName, file); + public VersionedRook retrieveBook(String repoRelativePath, File file) throws IOException { + return client.download(repoUri, repoRelativePath, file); } @Override - public InputStream openRepoFileInputStream(String fileName) throws IOException { - return client.streamFile(repoUri, fileName); + public InputStream openRepoFileInputStream(String repoRelativePath) throws IOException { + return client.streamFile(repoUri, repoRelativePath); } @Override - public VersionedRook storeBook(File file, String fileName) throws IOException { - return client.upload(file, repoUri, fileName); + public VersionedRook storeBook(File file, String repoRelativePath) throws IOException { + return client.upload(file, repoUri, repoRelativePath); } @Override - public VersionedRook renameBook(Uri fromUri, String name) throws IOException { - Uri toUri = UriUtils.getUriForNewName(fromUri, name); - return client.move(repoUri, fromUri, toUri); + public VersionedRook renameBook(Uri oldFullUri, String newName) throws IOException { + BookName oldBookName = BookName.fromRepoRelativePath(BookName.getRepoRelativePath(repoUri, oldFullUri)); + String newRelativePath = BookName.repoRelativePath(newName, oldBookName.getFormat()); + String newEncodedRelativePath = Uri.encode(newRelativePath, "/"); + Uri newFullUri = repoUri.buildUpon().appendEncodedPath(newEncodedRelativePath).build(); + return client.move(repoUri, oldFullUri, newFullUri); } @Override @@ -69,6 +73,14 @@ public void delete(Uri uri) throws IOException { client.delete(uri.getPath()); } + /** + * Only used by tests. The delete() method does not allow deleting directories. + */ + public void deleteDirectory(Uri uri) throws IOException { + client.deleteFolder(uri.getPath()); + } + + @NonNull @Override public String toString() { return repoUri.toString(); diff --git a/app/src/main/java/com/orgzly/android/repos/GitRepo.java b/app/src/main/java/com/orgzly/android/repos/GitRepo.java index 4bd759889..2cf1941e1 100644 --- a/app/src/main/java/com/orgzly/android/repos/GitRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/GitRepo.java @@ -185,16 +185,16 @@ public boolean isAutoSyncSupported() { return true; } - public VersionedRook storeBook(File file, String fileName) throws IOException { - File destination = synchronizer.repoDirectoryFile(fileName); + public VersionedRook storeBook(File file, String repoRelativePath) throws IOException { + File destination = synchronizer.workTreeFile(repoRelativePath); if (destination.exists()) { - synchronizer.updateAndCommitExistingFile(file, fileName); + synchronizer.updateAndCommitExistingFile(file, repoRelativePath); } else { - synchronizer.addAndCommitNewFile(file, fileName); + synchronizer.addAndCommitNewFile(file, repoRelativePath); } synchronizer.tryPush(); - return currentVersionedRook(Uri.EMPTY.buildUpon().appendPath(fileName).build()); + return currentVersionedRook(Uri.EMPTY.buildUpon().appendPath(repoRelativePath).build()); } private RevWalk walk() { @@ -206,9 +206,9 @@ RevCommit getCommitFromRevisionString(String revisionString) throws IOException } @Override - public VersionedRook retrieveBook(String fileName, File destination) throws IOException { + public VersionedRook retrieveBook(String repoRelativePath, File destination) throws IOException { - Uri sourceUri = Uri.parse(fileName); + Uri sourceUri = Uri.parse("/" + repoRelativePath); // Ensure our repo copy is up-to-date. This is necessary when force-loading a book. synchronizer.mergeWithRemote(); @@ -219,8 +219,8 @@ public VersionedRook retrieveBook(String fileName, File destination) throws IOEx } @Override - public InputStream openRepoFileInputStream(String fileName) throws IOException { - Uri sourceUri = Uri.parse(fileName); + public InputStream openRepoFileInputStream(String repoRelativePath) throws IOException { + Uri sourceUri = Uri.parse(repoRelativePath); return synchronizer.openRepoFileInputStream(sourceUri.getPath()); } @@ -265,12 +265,12 @@ public List getBooks() throws IOException { public boolean include(TreeWalk walker) { final FileMode mode = walk.getFileMode(); final boolean isDirectory = mode == FileMode.TREE; - final String filePath = walk.getPathString(); - if (ignores.isIgnored(filePath, isDirectory) == IgnoreNode.MatchResult.IGNORED) + final String repoRelativePath = walk.getPathString(); + if (ignores.isIgnored(repoRelativePath, isDirectory) == IgnoreNode.MatchResult.IGNORED) return false; if (isDirectory) return true; - return BookName.isSupportedFormatFileName(filePath); + return BookName.isSupportedFormatFileName(repoRelativePath); } @Override @@ -297,12 +297,12 @@ public void delete(Uri uri) throws IOException { if (synchronizer.deleteFileFromRepo(uri)) synchronizer.tryPush(); } - public VersionedRook renameBook(Uri oldUri, String newBookName) throws IOException { - String oldFileName = oldUri.toString().replaceFirst("^/", ""); - String newFileName = BookName.fileName(newBookName, BookFormat.ORG); - if (synchronizer.renameFileInRepo(oldFileName, newFileName)) { + public VersionedRook renameBook(Uri oldFullUri, String newName) throws IOException { + String oldPath = oldFullUri.toString().replaceFirst("^/", ""); + String newPath = BookName.repoRelativePath(newName, BookFormat.ORG); + if (synchronizer.renameFileInRepo(oldPath, newPath)) { synchronizer.tryPush(); - return currentVersionedRook(Uri.EMPTY.buildUpon().appendPath(newFileName).build()); + return currentVersionedRook(Uri.EMPTY.buildUpon().appendPath(newPath).build()); } else { return null; } @@ -311,16 +311,16 @@ public VersionedRook renameBook(Uri oldUri, String newBookName) throws IOExcepti @Override public TwoWaySyncResult syncBook( Uri uri, VersionedRook current, File fromDB) throws IOException { - String fileName = uri.getPath().replaceFirst("^/", ""); + String repoRelativePath = uri.getPath().replaceFirst("^/", ""); boolean merged = true; if (current != null) { RevCommit rookCommit = getCommitFromRevisionString(current.getRevision()); if (BuildConfig.LOG_DEBUG) { - LogUtils.d(TAG, String.format("Syncing file %s, rookCommit: %s", fileName, rookCommit)); + LogUtils.d(TAG, String.format("Syncing file %s, rookCommit: %s", repoRelativePath, rookCommit)); } merged = synchronizer.updateAndCommitFileFromRevisionAndMerge( - fromDB, fileName, - synchronizer.getFileRevision(fileName, rookCommit), + fromDB, repoRelativePath, + synchronizer.getFileRevision(repoRelativePath, rookCommit), rookCommit); if (merged) { @@ -333,9 +333,9 @@ public TwoWaySyncResult syncBook( } else { Log.w(TAG, "Unable to find previous commit, loading from repository."); } - File writeBackFile = synchronizer.repoDirectoryFile(fileName); + File writeBackFile = synchronizer.workTreeFile(repoRelativePath); return new TwoWaySyncResult( - currentVersionedRook(Uri.EMPTY.buildUpon().appendPath(fileName).build()), merged, + currentVersionedRook(Uri.EMPTY.buildUpon().appendPath(repoRelativePath).build()), merged, writeBackFile); } diff --git a/app/src/main/java/com/orgzly/android/repos/MockRepo.java b/app/src/main/java/com/orgzly/android/repos/MockRepo.java index 9145c9a3d..df2bedbb0 100644 --- a/app/src/main/java/com/orgzly/android/repos/MockRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/MockRepo.java @@ -4,9 +4,14 @@ import android.os.SystemClock; +import androidx.test.core.app.ApplicationProvider; + import com.orgzly.android.data.DbRepoBookRepository; +import com.orgzly.android.prefs.AppPreferences; +import java.io.ByteArrayInputStream; import java.io.File; +import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; @@ -25,10 +30,16 @@ public class MockRepo implements SyncRepo { private static final long SLEEP_FOR_STORE_BOOK = 200; private static final long SLEEP_FOR_DELETE_BOOK = 100; + public static final String IGNORE_RULES_PREF_KEY = "ignore_rules"; + + private String ignoreRules; + private DatabaseRepo databaseRepo; public MockRepo(RepoWithProps repoWithProps, DbRepoBookRepository dbRepo) { databaseRepo = new DatabaseRepo(repoWithProps, dbRepo); + ignoreRules = AppPreferences.repoPropsMap(ApplicationProvider.getApplicationContext(), + repoWithProps.getRepo().getId()).get(IGNORE_RULES_PREF_KEY); } @Override @@ -53,26 +64,30 @@ public List getBooks() throws IOException { } @Override - public VersionedRook retrieveBook(String fileName, File file) throws IOException { + public VersionedRook retrieveBook(String repoRelativePath, File file) throws IOException { SystemClock.sleep(SLEEP_FOR_RETRIEVE_BOOK); - return databaseRepo.retrieveBook(fileName, file); + return databaseRepo.retrieveBook(repoRelativePath, file); } @Override - public InputStream openRepoFileInputStream(String fileName) throws IOException { - throw new FileNotFoundException(); + public InputStream openRepoFileInputStream(String repoRelativePath) throws IOException { + if (repoRelativePath.equals(RepoIgnoreNode.IGNORE_FILE) && ignoreRules != null) { + return new ByteArrayInputStream(ignoreRules.getBytes()); + } else { + throw new FileNotFoundException(); + } } @Override - public VersionedRook storeBook(File file, String fileName) throws IOException { + public VersionedRook storeBook(File file, String repoRelativePath) throws IOException { SystemClock.sleep(SLEEP_FOR_STORE_BOOK); - return databaseRepo.storeBook(file, fileName); + return databaseRepo.storeBook(file, repoRelativePath); } @Override - public VersionedRook renameBook(Uri fromUri, String name) throws IOException { + public VersionedRook renameBook(Uri oldFullUri, String newName) throws IOException { SystemClock.sleep(SLEEP_FOR_STORE_BOOK); - return databaseRepo.renameBook(fromUri, name); + return databaseRepo.renameBook(oldFullUri, newName); } @Override diff --git a/app/src/main/java/com/orgzly/android/repos/RepoFactory.kt b/app/src/main/java/com/orgzly/android/repos/RepoFactory.kt index 25ad16cc7..f72a23298 100644 --- a/app/src/main/java/com/orgzly/android/repos/RepoFactory.kt +++ b/app/src/main/java/com/orgzly/android/repos/RepoFactory.kt @@ -25,8 +25,10 @@ class RepoFactory @Inject constructor( type == RepoType.DIRECTORY.id -> DirectoryRepo(repoWithProps, false) - type == RepoType.DOCUMENT.id -> - ContentRepo(repoWithProps, context) + type == RepoType.DOCUMENT.id -> DocumentRepo( + repoWithProps, + context + ) type == RepoType.WEBDAV.id -> WebdavRepo.getInstance(repoWithProps) diff --git a/app/src/main/java/com/orgzly/android/repos/RepoIgnoreNode.kt b/app/src/main/java/com/orgzly/android/repos/RepoIgnoreNode.kt index d8fe16411..25e7826b8 100644 --- a/app/src/main/java/com/orgzly/android/repos/RepoIgnoreNode.kt +++ b/app/src/main/java/com/orgzly/android/repos/RepoIgnoreNode.kt @@ -44,7 +44,7 @@ class RepoIgnoreNode(repo: SyncRepo) : IgnoreNode() { } @RequiresApi(Build.VERSION_CODES.O) - fun ensureFileNameIsNotIgnored(filePath: String) { + fun ensurePathIsNotIgnored(filePath: String) { if (isPathIgnored(filePath, false)) { throw IOException( App.getAppContext().getString( diff --git a/app/src/main/java/com/orgzly/android/repos/RepoUtils.java b/app/src/main/java/com/orgzly/android/repos/RepoUtils.java index 639e77cc2..9c4f2942d 100644 --- a/app/src/main/java/com/orgzly/android/repos/RepoUtils.java +++ b/app/src/main/java/com/orgzly/android/repos/RepoUtils.java @@ -32,8 +32,8 @@ public static boolean isAutoSyncSupported(Collection repos) { } @RequiresApi(api = Build.VERSION_CODES.O) - public static void ensureFileNameIsNotIgnored(SyncRepo repo, String fileName) { - new RepoIgnoreNode(repo).ensureFileNameIsNotIgnored(fileName); + public static void ensurePathIsNotIgnored(SyncRepo repo, String repoRelativePath) { + new RepoIgnoreNode(repo).ensurePathIsNotIgnored(repoRelativePath); } } diff --git a/app/src/main/java/com/orgzly/android/repos/SyncRepo.java b/app/src/main/java/com/orgzly/android/repos/SyncRepo.java index ed22ef2d1..11d6cfb33 100644 --- a/app/src/main/java/com/orgzly/android/repos/SyncRepo.java +++ b/app/src/main/java/com/orgzly/android/repos/SyncRepo.java @@ -31,24 +31,32 @@ public interface SyncRepo { /** * Download the latest available revision of the book and store its content to {@code File}. */ - VersionedRook retrieveBook(String fileName, File destination) throws IOException; + VersionedRook retrieveBook(String repoRelativePath, File destination) throws IOException; /** * Open a file in the repository for reading. Originally added for parsing the .orgzlyignore * file. - * @param fileName The file to open + * @param repoRelativePath The file to open * @throws IOException */ - InputStream openRepoFileInputStream(String fileName) throws IOException; + InputStream openRepoFileInputStream(String repoRelativePath) throws IOException; /** * Uploads book storing it under given filename under repo's url. * @param file The contents of this file should be stored at the remote location/repo - * @param fileName The contents ({@code file}) should be stored under this name + * @param repoRelativePath The contents ({@code file}) should be stored under this + * (non-encoded) name */ - VersionedRook storeBook(File file, String fileName) throws IOException; + VersionedRook storeBook(File file, String repoRelativePath) throws IOException; - VersionedRook renameBook(Uri from, String name) throws IOException; + /** + * + * @param oldFullUri Uri of the original repository file + * @param newName The new desired book name + * @return + * @throws IOException + */ + VersionedRook renameBook(Uri oldFullUri, String newName) throws IOException; // VersionedRook moveBook(Uri from, Uri uri) throws IOException; diff --git a/app/src/main/java/com/orgzly/android/repos/TwoWaySyncRepo.kt b/app/src/main/java/com/orgzly/android/repos/TwoWaySyncRepo.kt index a63aab93e..d3d7dfa7e 100644 --- a/app/src/main/java/com/orgzly/android/repos/TwoWaySyncRepo.kt +++ b/app/src/main/java/com/orgzly/android/repos/TwoWaySyncRepo.kt @@ -9,4 +9,6 @@ interface TwoWaySyncRepo { fun syncBook(uri: Uri, current: VersionedRook?, fromDB: File): TwoWaySyncResult fun tryPushIfHeadDiffersFromRemote() + + fun getUri(): Uri } \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt b/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt index da62353a2..81599b893 100644 --- a/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt +++ b/app/src/main/java/com/orgzly/android/repos/WebdavRepo.kt @@ -10,7 +10,6 @@ import com.burgstaller.okhttp.digest.CachingAuthenticator import com.burgstaller.okhttp.digest.Credentials import com.burgstaller.okhttp.digest.DigestAuthenticator import com.orgzly.android.BookName -import com.orgzly.android.util.UriUtils import com.thegrizzlylabs.sardineandroid.DavResource import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine import okhttp3.OkHttpClient @@ -18,10 +17,12 @@ import okio.Buffer import java.io.File import java.io.FileNotFoundException import java.io.FileOutputStream +import java.io.IOException import java.io.InputStream +import java.net.URI import java.security.KeyStore import java.security.cert.CertificateFactory -import java.util.* +import java.util.Arrays import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit import javax.net.ssl.SSLContext @@ -166,16 +167,16 @@ class WebdavRepo( val ignores = RepoIgnoreNode(this) return sardine - .list(url) + .list(url, -1) .mapNotNull { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (it.isDirectory || !BookName.isSupportedFormatFileName(it.name) || ignores.isPathIgnored(it.name, false)) { + if (!BookName.isSupportedFormatFileName(it.name) || ignores.isPathIgnored(it.getRelativePath(), it.isDirectory)) { null } else { it.toVersionedRook() } } else { - if (it.isDirectory || !BookName.isSupportedFormatFileName(it.name)) { + if (!BookName.isSupportedFormatFileName(it.name)) { null } else { it.toVersionedRook() @@ -185,8 +186,8 @@ class WebdavRepo( .toMutableList() } - override fun retrieveBook(fileName: String?, destination: File?): VersionedRook { - val fileUrl = Uri.withAppendedPath(uri, fileName).toUrl() + override fun retrieveBook(repoRelativePath: String?, destination: File?): VersionedRook { + val fileUrl = Uri.withAppendedPath(uri, repoRelativePath).toUrl() sardine.get(fileUrl).use { inputStream -> FileOutputStream(destination).use { outputStream -> @@ -197,25 +198,57 @@ class WebdavRepo( return sardine.list(fileUrl).first().toVersionedRook() } - override fun openRepoFileInputStream(fileName: String): InputStream { - val fileUrl = Uri.withAppendedPath(uri, fileName).toUrl() + override fun openRepoFileInputStream(repoRelativePath: String): InputStream { + val fileUrl = Uri.withAppendedPath(uri, repoRelativePath).toUrl() if (!sardine.exists(fileUrl)) throw FileNotFoundException() return sardine.get(fileUrl) } - override fun storeBook(file: File?, fileName: String?): VersionedRook { - val fileUrl = Uri.withAppendedPath(uri, fileName).toUrl() + private fun ensureDirectoryHierarchy(relativePath: String) { + val levels: ArrayList = ArrayList(relativePath.split("/")) + // N.B. Strip off trailing slash from repo URL, if present + var currentDir: String = uri.toString().replace(Regex("/$"), "") + while (levels.size > 1) { + val nextDirName: String = levels.removeAt(0) + currentDir = "$currentDir/$nextDirName" + if (!sardine.exists(currentDir)) { + sardine.createDirectory(currentDir) + } + } + } + + override fun storeBook(file: File, repoRelativePath: String): VersionedRook { + val encodedRepoPath = Uri.encode(repoRelativePath, "/") + if (encodedRepoPath != null) { + if (encodedRepoPath.contains("/")) { + ensureDirectoryHierarchy(encodedRepoPath) + } + } + val fileUrl = uri.buildUpon().appendEncodedPath(encodedRepoPath).build().toUrl() sardine.put(fileUrl, file, null) return sardine.list(fileUrl).first().toVersionedRook() } - override fun renameBook(from: Uri, name: String?): VersionedRook { - val destUrl = UriUtils.getUriForNewName(from, name).toUrl() - sardine.move(from.toUrl(), destUrl) - return sardine.list(destUrl).first().toVersionedRook() + override fun renameBook(oldFullUri: Uri, newName: String): VersionedRook { + val oldBookName = BookName.fromRepoRelativePath(BookName.getRepoRelativePath(uri, oldFullUri)) + val newRelativePath = BookName.repoRelativePath(newName, oldBookName.format) + val newEncodedRelativePath = Uri.encode(newRelativePath, "/") + val newFullUrl = uri.buildUpon().appendEncodedPath(newEncodedRelativePath).build().toUrl() + + /* Abort if destination file already exists. */ + if (sardine.exists(newFullUrl)) { + throw IOException("File at $newFullUrl already exists") + } + + if (newName.contains("/")) { + ensureDirectoryHierarchy(newEncodedRelativePath) + } + + sardine.move(oldFullUri.toUrl(), newFullUrl) + return sardine.list(newFullUrl).first().toVersionedRook() } override fun delete(uri: Uri) { @@ -227,13 +260,34 @@ class WebdavRepo( repoId, RepoType.WEBDAV, uri, - Uri.withAppendedPath(uri, this.name), - this.name + this.modified.time.toString(), + Uri.parse(this.getFullUrlString()), + this.modified.time.toString(), this.modified.time ) } + /** + * A WebDAV href can be either an absolute (full) URI, or an "absolute path" + * (cf. http://www.webdav.org/specs/rfc4918.html#url-handling). The full URI can be built from + * the absolute path. + */ + private fun DavResource.getFullUrlString(): String { + if (this.href.isAbsolute) { + // absolute-URI - return the href as-is + return this.href.toString() + } else { + // path-absolute - build the absolut URI + return uri.scheme + "://" + uri.authority + this.href.toString() + } + } + + private fun DavResource.getRelativePath(): String { + val absoluteUri = URI.create(this.getFullUrlString()) + val relativePath = URI.create(uri.toString()).relativize(absoluteUri) + return relativePath.path + } + private fun Uri.toUrl(): String { return this.toString().replace("^(?:web)?dav(s?://)".toRegex(), "http$1") } -} +} \ No newline at end of file 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 a5c3271e1..7d86055f6 100644 --- a/app/src/main/java/com/orgzly/android/sync/BookNamesake.java +++ b/app/src/main/java/com/orgzly/android/sync/BookNamesake.java @@ -1,7 +1,5 @@ package com.orgzly.android.sync; -import android.content.Context; - import com.orgzly.android.BookName; import com.orgzly.android.db.entity.BookAction; import com.orgzly.android.db.entity.BookView; @@ -37,7 +35,7 @@ public BookNamesake(String name) { /** * Create links between each local book and each remote book with the same name. */ - public static Map getAll(Context context, List books, List versionedRooks) { + public static Map getAll(List books, List versionedRooks) { Map namesakes = new HashMap<>(); /* Create links from all local books first. */ @@ -49,8 +47,8 @@ public static Map getAll(Context context, List b /* Set repo books. */ for (VersionedRook book: versionedRooks) { - String fileName = BookName.getFileName(context, book.getUri()); - String name = BookName.fromFileName(fileName).getName(); + String repoRelativePath = BookName.getRepoRelativePath(book.getRepoUri(), book.getUri()); + String name = BookName.fromRepoRelativePath(repoRelativePath).getName(); BookNamesake pair = namesakes.get(name); if (pair == null) { 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 714592a11..ddf69afe1 100644 --- a/app/src/main/java/com/orgzly/android/sync/SyncUtils.kt +++ b/app/src/main/java/com/orgzly/android/sync/SyncUtils.kt @@ -1,7 +1,7 @@ package com.orgzly.android.sync +import androidx.core.net.toUri import com.orgzly.BuildConfig -import com.orgzly.android.App import com.orgzly.android.BookFormat import com.orgzly.android.BookName import com.orgzly.android.NotesOrgExporter @@ -63,8 +63,7 @@ object SyncUtils { val versionedRooks = getBooksFromAllRepos(dataRepository, repos) /* Group local and remote books by name. */ - val namesakes = BookNamesake.getAll( - App.getAppContext(), localBooks, versionedRooks) + val namesakes = BookNamesake.getAll(localBooks, versionedRooks) /* If there is no local book, create empty "dummy" one. */ for (namesake in namesakes.values) { @@ -88,7 +87,7 @@ object SyncUtils { fun syncNamesake(dataRepository: DataRepository, namesake: BookNamesake): BookAction { val repoEntity: Repo? val repoUrl: String - val fileName: String + val repositoryPath: String var bookAction: BookAction? = null // FIXME: This is a pretty nasty hack that completely circumvents the existing code path @@ -156,26 +155,26 @@ object SyncUtils { BookSyncStatus.ONLY_BOOK_WITHOUT_LINK_AND_ONE_REPO -> { repoEntity = dataRepository.getRepos().iterator().next() repoUrl = repoEntity.url - fileName = BookName.fileName(namesake.book.book.name, BookFormat.ORG) + repositoryPath = BookName.repoRelativePath(namesake.book.book.name, BookFormat.ORG) /* Set repo link before saving to ensure repo ignore rules are checked */ dataRepository.setLink(namesake.book.book.id, repoEntity) - dataRepository.saveBookToRepo(repoEntity, fileName, namesake.book, BookFormat.ORG) + dataRepository.saveBookToRepo(repoEntity, repositoryPath, namesake.book, BookFormat.ORG) bookAction = BookAction.forNow(BookAction.Type.INFO, namesake.status.msg(repoUrl)) } BookSyncStatus.BOOK_WITH_LINK_LOCAL_MODIFIED -> { repoEntity = namesake.book.linkRepo repoUrl = repoEntity!!.url - fileName = BookName.getFileName(App.getAppContext(), namesake.book.syncedTo!!.uri) - dataRepository.saveBookToRepo(repoEntity, fileName, namesake.book, BookFormat.ORG) + repositoryPath = BookName.getRepoRelativePath(repoUrl.toUri(), namesake.book.syncedTo!!.uri) + dataRepository.saveBookToRepo(repoEntity, repositoryPath, namesake.book, BookFormat.ORG) bookAction = BookAction.forNow(BookAction.Type.INFO, namesake.status.msg(repoUrl)) } BookSyncStatus.ONLY_BOOK_WITH_LINK -> { repoEntity = namesake.book.linkRepo repoUrl = repoEntity!!.url - fileName = BookName.fileName(namesake.book.book.name, BookFormat.ORG) - dataRepository.saveBookToRepo(repoEntity, fileName, namesake.book, BookFormat.ORG) + repositoryPath = BookName.repoRelativePath(namesake.book.book.name, BookFormat.ORG) + dataRepository.saveBookToRepo(repoEntity, repositoryPath, namesake.book, BookFormat.ORG) bookAction = BookAction.forNow(BookAction.Type.INFO, namesake.status.msg(repoUrl)) } } @@ -191,8 +190,8 @@ object SyncUtils { var noNewMergeConflicts = true // If there are only local changes, the GitRepo.syncBook method is overly complicated. if (namesake.status == BookSyncStatus.BOOK_WITH_LINK_LOCAL_MODIFIED) { - val fileName = BookName.getFileName(App.getAppContext(), namesake.book.syncedTo!!.uri) - dataRepository.saveBookToRepo(namesake.book.linkRepo!!, fileName, namesake.book, BookFormat.ORG) + val repoRelativePath = BookName.getRepoRelativePath(repo.getUri(), namesake.book.syncedTo!!.uri) + dataRepository.saveBookToRepo(namesake.book.linkRepo!!, repoRelativePath, namesake.book, BookFormat.ORG) } else { val dbFile = dataRepository.getTempBookFile() try { @@ -203,8 +202,8 @@ object SyncUtils { newRook = newRook1 // We only need to write it if syncback is needed if (loadFile != null) { - val fileName = BookName.getFileName(App.getAppContext(), newRook.uri) - val bookName = BookName.fromFileName(fileName) + val repoRelativePath = BookName.getRepoRelativePath(repo.getUri(), newRook.uri) + val bookName = BookName.fromRepoRelativePath(repoRelativePath) if (BuildConfig.LOG_DEBUG) LogUtils.d(TAG, "Loading from file '$loadFile'") dataRepository.loadBookFromFile( bookName.name, diff --git a/app/src/main/java/com/orgzly/android/ui/books/BooksFragment.kt b/app/src/main/java/com/orgzly/android/ui/books/BooksFragment.kt index e2f3d938e..3fbeb664e 100644 --- a/app/src/main/java/com/orgzly/android/ui/books/BooksFragment.kt +++ b/app/src/main/java/com/orgzly/android/ui/books/BooksFragment.kt @@ -312,7 +312,7 @@ class BooksFragment : CommonFragment(), DrawerItem, OnViewHolderClickListenerNotebook has no repository link Loaded from %s Saved to %s + + Renaming notebook to a different subdirectory requires Android 7 or higher diff --git a/app/src/test/java/com/orgzly/android/repos/DropboxRepoTest.kt b/app/src/test/java/com/orgzly/android/repos/DropboxRepoTest.kt new file mode 100644 index 000000000..1b49db0b4 --- /dev/null +++ b/app/src/test/java/com/orgzly/android/repos/DropboxRepoTest.kt @@ -0,0 +1,135 @@ +package com.orgzly.android.repos + +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.orgzly.BuildConfig +import com.orgzly.android.db.entity.Repo +import com.orgzly.android.prefs.AppPreferences +import org.json.JSONObject +import org.junit.After +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.IOException +import java.util.UUID + +@RunWith(AndroidJUnit4::class) +class DropboxRepoTest : SyncRepoTest { + + private lateinit var syncRepo: SyncRepo + private lateinit var client: DropboxClient + + @Before + fun setup() { + assumeTrue(BuildConfig.DROPBOX_APP_KEY.isNotEmpty()) + assumeTrue(BuildConfig.DROPBOX_REFRESH_TOKEN.isNotEmpty()) + val mockSerializedDbxCredential = JSONObject() + mockSerializedDbxCredential.put("access_token", "dummy") + mockSerializedDbxCredential.put("expires_at", System.currentTimeMillis()) + mockSerializedDbxCredential.put("refresh_token", BuildConfig.DROPBOX_REFRESH_TOKEN) + mockSerializedDbxCredential.put("app_key", BuildConfig.DROPBOX_APP_KEY) + AppPreferences.dropboxSerializedCredential( + ApplicationProvider.getApplicationContext(), + mockSerializedDbxCredential.toString() + ) + val repo = Repo(0, RepoType.DROPBOX, "dropbox:/${SyncRepoTest.repoDirName}/" + UUID.randomUUID().toString()) + val repoPropsMap = HashMap() + val repoWithProps = RepoWithProps(repo, repoPropsMap) + syncRepo = DropboxRepo(repoWithProps, ApplicationProvider.getApplicationContext()) + client = DropboxClient(ApplicationProvider.getApplicationContext(), repo.id) + } + + @After + fun tearDown() { + if (this::syncRepo.isInitialized) { + val dropboxRepo = syncRepo as DropboxRepo + dropboxRepo.deleteDirectory(syncRepo.uri) + } + } + + @Test + override fun testGetBooks_singleOrgFile() { + SyncRepoTest.testGetBooks_singleOrgFile(client, syncRepo) + } + + @Test + override fun testGetBooks_singleFileInSubfolder() { + SyncRepoTest.testGetBooks_singleFileInSubfolder(client, syncRepo) + } + + @Test + override fun testGetBooks_allFilesAreIgnored() { + SyncRepoTest.testGetBooks_allFilesAreIgnored(client, syncRepo) + } + + @Test + override fun testGetBooks_specificFileInSubfolderIsIgnored() { + SyncRepoTest.testGetBooks_specificFileInSubfolderIsIgnored(client, syncRepo) + } + + @Test + override fun testGetBooks_specificFileIsUnignored() { + SyncRepoTest.testGetBooks_specificFileIsUnignored(client, syncRepo) + } + + @Test + override fun testGetBooks_ignoredExtensions() { + SyncRepoTest.testGetBooks_ignoredExtensions(client, syncRepo) + } + + @Test + override fun testStoreBook_expectedUri() { + SyncRepoTest.testStoreBook_expectedUri(syncRepo) + } + + @Test + override fun testStoreBook_producesSameUriAsRetrieveBook() { + SyncRepoTest.testStoreBook_producesSameUriAsRetrieveBook(syncRepo) + } + + @Test + override fun testStoreBook_producesSameUriAsGetBooks() { + SyncRepoTest.testStoreBook_producesSameUriAsGetBooks(client, syncRepo) + } + + @Test + override fun testStoreBook_inSubfolder() { + SyncRepoTest.testStoreBook_inSubfolder(client, syncRepo) + } + + @Test + override fun testRenameBook_expectedUri() { + SyncRepoTest.testRenameBook_expectedUri(syncRepo) + } + + @Test(expected = IOException::class) + override fun testRenameBook_repoFileAlreadyExists() { + SyncRepoTest.testRenameBook_repoFileAlreadyExists(client, syncRepo) + } + + @Test + override fun testRenameBook_fromRootToSubfolder() { + SyncRepoTest.testRenameBook_fromRootToSubfolder(syncRepo) + } + + @Test + override fun testRenameBook_fromSubfolderToRoot() { + SyncRepoTest.testRenameBook_fromSubfolderToRoot(syncRepo) + } + + @Test + override fun testRenameBook_newSubfolderSameLeafName() { + SyncRepoTest.testRenameBook_newSubfolderSameLeafName(syncRepo) + } + + @Test + override fun testRenameBook_newSubfolderAndLeafName() { + SyncRepoTest.testRenameBook_newSubfolderAndLeafName(syncRepo) + } + + @Test + override fun testRenameBook_sameSubfolderNewLeafName() { + SyncRepoTest.testRenameBook_sameSubfolderNewLeafName(syncRepo) + } +} diff --git a/app/src/test/java/com/orgzly/android/repos/GitRepoTest.kt b/app/src/test/java/com/orgzly/android/repos/GitRepoTest.kt new file mode 100644 index 000000000..883be5b03 --- /dev/null +++ b/app/src/test/java/com/orgzly/android/repos/GitRepoTest.kt @@ -0,0 +1,137 @@ +package com.orgzly.android.repos + +import android.content.Context +import androidx.core.net.toUri +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.orgzly.android.db.entity.Repo +import com.orgzly.android.git.GitFileSynchronizer +import com.orgzly.android.git.GitPreferencesFromRepoPrefs +import com.orgzly.android.prefs.AppPreferences +import com.orgzly.android.prefs.RepoPreferences +import org.eclipse.jgit.api.Git +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.io.IOException +import kotlin.io.path.createTempDirectory + +@RunWith(AndroidJUnit4::class) +class GitRepoTest : SyncRepoTest { + + private lateinit var gitWorkingTree: File + private lateinit var bareRepoDir: File + private lateinit var gitFileSynchronizer: GitFileSynchronizer + private lateinit var syncRepo: SyncRepo + private val context: Context = ApplicationProvider.getApplicationContext() + + @Before + fun setup() { + bareRepoDir = createTempDirectory().toFile() + Git.init().setBare(true).setDirectory(bareRepoDir).call() + AppPreferences.gitIsEnabled(context, true) + val repo = Repo(0, RepoType.GIT, "file://$bareRepoDir") + val repoPreferences = RepoPreferences(context, repo.id, repo.url.toUri()) + val gitPreferences = GitPreferencesFromRepoPrefs(repoPreferences) + gitWorkingTree = File(gitPreferences.repositoryFilepath()) + gitWorkingTree.mkdirs() + val git = GitRepo.ensureRepositoryExists(gitPreferences, true, null) + gitFileSynchronizer = GitFileSynchronizer(git, gitPreferences) + val repoPropsMap = HashMap() + val repoWithProps = RepoWithProps(repo, repoPropsMap) + syncRepo = GitRepo.getInstance(repoWithProps, context) + } + + @After + fun tearDown() { + gitWorkingTree.deleteRecursively() + bareRepoDir.deleteRecursively() + } + + @Test + override fun testGetBooks_singleOrgFile() { + SyncRepoTest.testGetBooks_singleOrgFile(gitWorkingTree, syncRepo) + } + + @Test + override fun testGetBooks_singleFileInSubfolder() { + SyncRepoTest.testGetBooks_singleFileInSubfolder(gitWorkingTree, syncRepo) + } + + @Test + override fun testGetBooks_allFilesAreIgnored() { + SyncRepoTest.testGetBooks_allFilesAreIgnored(gitWorkingTree, syncRepo) + } + + @Test + override fun testGetBooks_specificFileInSubfolderIsIgnored() { + SyncRepoTest.testGetBooks_specificFileInSubfolderIsIgnored(gitWorkingTree, syncRepo) + } + + @Test + override fun testGetBooks_specificFileIsUnignored() { + SyncRepoTest.testGetBooks_specificFileIsUnignored(gitWorkingTree, syncRepo) + } + + @Test + override fun testGetBooks_ignoredExtensions() { + SyncRepoTest.testGetBooks_ignoredExtensions(gitWorkingTree, syncRepo) + } + + @Test + override fun testStoreBook_expectedUri() { + SyncRepoTest.testStoreBook_expectedUri(syncRepo) + } + + @Test + override fun testStoreBook_producesSameUriAsRetrieveBook() { + SyncRepoTest.testStoreBook_producesSameUriAsRetrieveBook(syncRepo) + } + + @Test + override fun testStoreBook_producesSameUriAsGetBooks() { + SyncRepoTest.testStoreBook_producesSameUriAsGetBooks(gitWorkingTree, syncRepo) + } + + @Test + override fun testStoreBook_inSubfolder() { + SyncRepoTest.testStoreBook_inSubfolder(gitWorkingTree, syncRepo) + } + + @Test + override fun testRenameBook_expectedUri() { + SyncRepoTest.testRenameBook_expectedUri(syncRepo) + } + + @Test(expected = IOException::class) + override fun testRenameBook_repoFileAlreadyExists() { + SyncRepoTest.testRenameBook_repoFileAlreadyExists(gitWorkingTree, syncRepo) + } + + @Test + override fun testRenameBook_fromRootToSubfolder() { + SyncRepoTest.testRenameBook_fromRootToSubfolder(syncRepo) + } + + @Test + override fun testRenameBook_fromSubfolderToRoot() { + SyncRepoTest.testRenameBook_fromSubfolderToRoot(syncRepo) + } + + @Test + override fun testRenameBook_newSubfolderSameLeafName() { + SyncRepoTest.testRenameBook_newSubfolderSameLeafName(syncRepo) + } + + @Test + override fun testRenameBook_newSubfolderAndLeafName() { + SyncRepoTest.testRenameBook_newSubfolderAndLeafName(syncRepo) + } + + @Test + override fun testRenameBook_sameSubfolderNewLeafName() { + SyncRepoTest.testRenameBook_sameSubfolderNewLeafName(syncRepo) + } +} \ No newline at end of file diff --git a/app/src/test/java/com/orgzly/android/repos/WebdavRepoTest.kt b/app/src/test/java/com/orgzly/android/repos/WebdavRepoTest.kt new file mode 100644 index 000000000..273e1b752 --- /dev/null +++ b/app/src/test/java/com/orgzly/android/repos/WebdavRepoTest.kt @@ -0,0 +1,138 @@ +package com.orgzly.android.repos + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.orgzly.android.db.entity.Repo +import com.orgzly.android.repos.WebdavRepo.Companion.PASSWORD_PREF_KEY +import com.orgzly.android.repos.WebdavRepo.Companion.USERNAME_PREF_KEY +import io.github.atetzner.webdav.server.MiltonWebDAVFileServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.io.IOException + + +@RunWith(AndroidJUnit4::class) +class WebdavRepoTest : SyncRepoTest { + + private val serverUrl = "http://localhost:8081" + + private lateinit var serverRootDir: File + private lateinit var localServer: MiltonWebDAVFileServer + private lateinit var syncRepo: SyncRepo + private lateinit var tmpFile: File + + @Before + fun setup() { + serverRootDir = java.nio.file.Files.createTempDirectory("orgzly-webdav-test-").toFile() + localServer = MiltonWebDAVFileServer(serverRootDir) + localServer.userCredentials["user"] = "secret" + localServer.start() + val repo = Repo(0, RepoType.WEBDAV, serverUrl) + val repoPropsMap = HashMap() + repoPropsMap[USERNAME_PREF_KEY] = "user" + repoPropsMap[PASSWORD_PREF_KEY] = "secret" + val repoWithProps = RepoWithProps(repo, repoPropsMap) + syncRepo = WebdavRepo.getInstance(repoWithProps) + assertEquals(serverUrl, repo.url) + tmpFile = kotlin.io.path.createTempFile().toFile() + } + + @After + fun tearDown() { + tmpFile.delete() + if (this::localServer.isInitialized) { + localServer.stop() + } + if (this::serverRootDir.isInitialized) { + serverRootDir.deleteRecursively() + } + } + + @Test + override fun testGetBooks_singleOrgFile() { + SyncRepoTest.testGetBooks_singleOrgFile(serverRootDir, syncRepo) + } + + @Test + override fun testGetBooks_singleFileInSubfolder() { + SyncRepoTest.testGetBooks_singleFileInSubfolder(serverRootDir, syncRepo) + } + + @Test + override fun testGetBooks_allFilesAreIgnored() { + SyncRepoTest.testGetBooks_allFilesAreIgnored(serverRootDir, syncRepo) + } + + @Test + override fun testGetBooks_specificFileInSubfolderIsIgnored() { + SyncRepoTest.testGetBooks_specificFileInSubfolderIsIgnored(serverRootDir, syncRepo) + } + + @Test + override fun testGetBooks_specificFileIsUnignored() { + SyncRepoTest.testGetBooks_specificFileIsUnignored(serverRootDir, syncRepo) + } + + @Test + override fun testGetBooks_ignoredExtensions() { + SyncRepoTest.testGetBooks_ignoredExtensions(serverRootDir, syncRepo) + } + + @Test + override fun testStoreBook_expectedUri() { + SyncRepoTest.testStoreBook_expectedUri(syncRepo) + } + + @Test + override fun testStoreBook_producesSameUriAsRetrieveBook() { + SyncRepoTest.testStoreBook_producesSameUriAsRetrieveBook(syncRepo) + } + + @Test + override fun testStoreBook_producesSameUriAsGetBooks() { + SyncRepoTest.testStoreBook_producesSameUriAsGetBooks(serverRootDir, syncRepo) + } + + @Test + override fun testStoreBook_inSubfolder() { + SyncRepoTest.testStoreBook_inSubfolder(serverRootDir, syncRepo) + } + + @Test + override fun testRenameBook_expectedUri() { + SyncRepoTest.testRenameBook_expectedUri(syncRepo) + } + + @Test(expected = IOException::class) + override fun testRenameBook_repoFileAlreadyExists() { + SyncRepoTest.testRenameBook_repoFileAlreadyExists(serverRootDir, syncRepo) + } + + @Test + override fun testRenameBook_fromRootToSubfolder() { + SyncRepoTest.testRenameBook_fromRootToSubfolder(syncRepo) + } + + @Test + override fun testRenameBook_fromSubfolderToRoot() { + SyncRepoTest.testRenameBook_fromSubfolderToRoot(syncRepo) + } + + @Test + override fun testRenameBook_newSubfolderSameLeafName() { + SyncRepoTest.testRenameBook_newSubfolderSameLeafName(syncRepo) + } + + @Test + override fun testRenameBook_newSubfolderAndLeafName() { + SyncRepoTest.testRenameBook_newSubfolderAndLeafName(syncRepo) + } + + @Test + override fun testRenameBook_sameSubfolderNewLeafName() { + SyncRepoTest.testRenameBook_sameSubfolderNewLeafName(syncRepo) + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index 3926e82c0..4a15a34b4 100644 --- a/build.gradle +++ b/build.gradle @@ -71,6 +71,10 @@ buildscript { versions.biometric_ktx = '1.2.0-alpha04' + versions.robolectric = '4.13' + + versions.webdav_embedded_server = '0.2.1' + ext.versions = versions repositories { diff --git a/gradle.properties b/gradle.properties index 877be9ba9..bdcc4dff5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -18,4 +18,5 @@ android.databinding.incremental = true kotlin.code.style = official org.gradle.unsafe.configuration-cache=true -# org.gradle.warning.mode=all \ No newline at end of file +# org.gradle.warning.mode=all +android.jetifier.ignorelist = bcprov-jdk18on-1.78.1.jar diff --git a/settings.gradle b/settings.gradle index 2a35ddd25..6af49b8d1 100644 --- a/settings.gradle +++ b/settings.gradle @@ -34,3 +34,4 @@ if (gradle.ext.appProperties.org_java_directory?.trim()) { } include ':app' +include ':shared-test' diff --git a/shared-test/.gitignore b/shared-test/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/shared-test/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/shared-test/build.gradle b/shared-test/build.gradle new file mode 100644 index 000000000..f933f5514 --- /dev/null +++ b/shared-test/build.gradle @@ -0,0 +1,51 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-kapt' + +android { + namespace = "com.orgzly.shared.test" + compileSdk 33 + + defaultConfig { + minSdk 21 + buildConfigField "String", "DROPBOX_APP_KEY", gradle.ext.appProperties.getProperty("dropbox.app_key", '""') + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + debug { + buildConfigField "String", "DROPBOX_REFRESH_TOKEN", gradle.ext.appProperties.getProperty("dropbox.refresh_token", '""') + } + } + flavorDimensions "store" + productFlavors { + premium { + dimension "store" + } + + fdroid { + dimension "store" + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = 11 + } + + packagingOptions { + resources.merges.add("plugin.properties") + } +} + +dependencies { + implementation project(":app") + implementation "junit:junit:$versions.junit" + implementation 'androidx.documentfile:documentfile:1.0.1' + implementation "org.eclipse.jgit:org.eclipse.jgit:$versions.jgit" +} diff --git a/shared-test/proguard-rules.pro b/shared-test/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/shared-test/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/shared-test/src/main/AndroidManifest.xml b/shared-test/src/main/AndroidManifest.xml new file mode 100644 index 000000000..a522a4c23 --- /dev/null +++ b/shared-test/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + \ No newline at end of file diff --git a/shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTest.kt b/shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTest.kt new file mode 100644 index 000000000..385910575 --- /dev/null +++ b/shared-test/src/main/java/com/orgzly/android/repos/SyncRepoTest.kt @@ -0,0 +1,422 @@ +package com.orgzly.android.repos + +import android.annotation.SuppressLint +import android.net.Uri +import android.os.Build +import androidx.documentfile.provider.DocumentFile +import com.orgzly.android.BookName +import com.orgzly.android.util.MiscUtils +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.storage.file.FileRepositoryBuilder +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import java.io.File +import java.io.IOException + +@SuppressLint("NewApi") +interface SyncRepoTest { + + fun testGetBooks_singleOrgFile() + fun testGetBooks_singleFileInSubfolder() + fun testGetBooks_allFilesAreIgnored() + fun testGetBooks_specificFileInSubfolderIsIgnored() + fun testGetBooks_specificFileIsUnignored() + fun testGetBooks_ignoredExtensions() + fun testStoreBook_expectedUri() + fun testStoreBook_producesSameUriAsRetrieveBook() + fun testStoreBook_producesSameUriAsGetBooks() + fun testStoreBook_inSubfolder() + fun testRenameBook_expectedUri() + fun testRenameBook_repoFileAlreadyExists() + fun testRenameBook_fromRootToSubfolder() + fun testRenameBook_fromSubfolderToRoot() + fun testRenameBook_newSubfolderSameLeafName() + fun testRenameBook_newSubfolderAndLeafName() + fun testRenameBook_sameSubfolderNewLeafName() + + companion object { + + const val repoDirName = "orgzly-android-test" + private var treeDocumentFileExtraSegment = if (Build.VERSION.SDK_INT < 30) { + "/document/raw%3A%2Fstorage%2Femulated%2F0%2FDownload%2F$repoDirName%2F" + } else { + "/document/primary%3A$repoDirName%2F" + } + + fun testGetBooks_singleOrgFile(repoManipulationPoint: Any, syncRepo: SyncRepo) { + // Given + val fileContent = "\n\n...\n\n" + val fileName = "Book one.org" + val expectedRookUri = writeFileToRepo(fileContent, syncRepo, repoManipulationPoint, fileName) + + // When + val books = syncRepo.books + val retrieveBookDestinationFile = kotlin.io.path.createTempFile().toFile() + syncRepo.retrieveBook(fileName, retrieveBookDestinationFile) + + // Then + assertEquals(1, books.size) + assertEquals(expectedRookUri, books[0].uri.toString()) + assertEquals(fileContent, retrieveBookDestinationFile.readText()) + assertEquals(fileName, BookName.getRepoRelativePath(syncRepo.uri, books[0].uri)) + } + + fun testGetBooks_singleFileInSubfolder(repoManipulationPoint: Any, syncRepo: SyncRepo) { + // Given + val repoFilePath = "Folder/Book one.org" + val fileContent = "\n\n...\n\n" + val expectedRookUri = writeFileToRepo(fileContent, syncRepo, repoManipulationPoint, "Book one.org", "Folder") + + // When + val books = syncRepo.books + val retrieveBookDestinationFile = kotlin.io.path.createTempFile().toFile() + syncRepo.retrieveBook(repoFilePath, retrieveBookDestinationFile) + + // Then + assertEquals(1, books.size) + assertEquals(expectedRookUri, books[0].uri.toString()) + assertEquals(repoFilePath, BookName.getRepoRelativePath(syncRepo.uri, books[0].uri)) + assertEquals(fileContent, retrieveBookDestinationFile.readText()) + } + + fun testGetBooks_allFilesAreIgnored(repoManipulationPoint: Any, syncRepo: SyncRepo) { + // Given + val ignoreFileContent = "*\n" + writeFileToRepo("...", syncRepo, repoManipulationPoint, "book one.org", "folder") + writeFileToRepo(ignoreFileContent, syncRepo, repoManipulationPoint, RepoIgnoreNode.IGNORE_FILE) + // When + val books = syncRepo.books + // Then + assertEquals(0, books.size) + } + + fun testGetBooks_specificFileInSubfolderIsIgnored(repoManipulationPoint: Any, syncRepo: SyncRepo) { + // Given + val ignoreFileContent = "folder/book one.org\n" + writeFileToRepo("...", syncRepo, repoManipulationPoint, "book one.org", "folder") + writeFileToRepo(ignoreFileContent, syncRepo, repoManipulationPoint, RepoIgnoreNode.IGNORE_FILE) + // When + val books = syncRepo.books + // Then + assertEquals(0, books.size) + } + fun testGetBooks_specificFileIsUnignored(repoManipulationPoint: Any, syncRepo: SyncRepo) { + // Given + val folderName = "My Folder" + val fileName = "My file.org" + val ignoreFileContent = "folder/**\n!$folderName/$fileName\n" + writeFileToRepo("...", syncRepo, repoManipulationPoint, fileName, folderName) + writeFileToRepo(ignoreFileContent, syncRepo, repoManipulationPoint, RepoIgnoreNode.IGNORE_FILE) + // When + val books = syncRepo.books + // Then + assertEquals(1, books.size) + } + + fun testGetBooks_ignoredExtensions(repoManipulationPoint: Any, syncRepo: SyncRepo) { + // Given + val testBookContent = "\n\n...\n\n" + for (fileName in arrayOf("file one.txt", "file two.o", "file three.org")) { + writeFileToRepo(testBookContent, syncRepo, repoManipulationPoint, fileName) + } + // When + val books = syncRepo.books + // Then + assertEquals(1, books.size.toLong()) + assertEquals("file three", BookName.fromRepoRelativePath(BookName.getRepoRelativePath(syncRepo.uri, books[0].uri)).name) + } + + fun testStoreBook_expectedUri(syncRepo: SyncRepo) { + // Given + val tmpFile = kotlin.io.path.createTempFile().toFile() + MiscUtils.writeStringToFile("...", tmpFile) + // When + val vrook = syncRepo.storeBook(tmpFile, "Book one.org") + tmpFile.delete() + // Then + val expectedRookUri = when (syncRepo) { + is GitRepo -> "/Book one.org" + is DocumentRepo -> syncRepo.uri.toString() + treeDocumentFileExtraSegment + "Book%20one.org" + else -> syncRepo.uri.toString() + "/Book%20one.org" + } + assertEquals(expectedRookUri, vrook.uri.toString()) + } + + fun testStoreBook_producesSameUriAsRetrieveBook(syncRepo: SyncRepo) { + // Given + val tmpFile = kotlin.io.path.createTempFile().toFile() + val repositoryPath = "a folder/a book.org" + MiscUtils.writeStringToFile("...", tmpFile) + // When + val storedRook = syncRepo.storeBook(tmpFile, repositoryPath) + val retrievedBook = syncRepo.retrieveBook(repositoryPath, tmpFile) + tmpFile.delete() + // Then + assertEquals(retrievedBook.uri, storedRook.uri) + } + + fun testStoreBook_producesSameUriAsGetBooks(repoManipulationPoint: Any, syncRepo: SyncRepo) { + // Given + val tmpFile = kotlin.io.path.createTempFile().toFile() + val folderName = "A folder" + val fileName = "A book.org" + writeFileToRepo("...", syncRepo, repoManipulationPoint, fileName, folderName) + // When + val gottenBook = syncRepo.books[0] + MiscUtils.writeStringToFile("......", tmpFile) // N.B. Different content to ensure the repo file is actually changed + val storedRook = syncRepo.storeBook(tmpFile, "$folderName/$fileName") + tmpFile.delete() + // Then + assertEquals(gottenBook.uri, storedRook.uri) + } + + fun testStoreBook_inSubfolder(repoManipulationPoint: Any, syncRepo: SyncRepo) { + // Given + val tmpFile = kotlin.io.path.createTempFile().toFile() + val repositoryPath = "A folder/A book.org" + val testBookContent = "\n\n...\n\n" + MiscUtils.writeStringToFile(testBookContent, tmpFile) + // When + syncRepo.storeBook(tmpFile, repositoryPath) + tmpFile.delete() + // Then + when (syncRepo) { + is WebdavRepo -> { + repoManipulationPoint as File + val subFolder = File(repoManipulationPoint, "A folder") + assertTrue(subFolder.exists()) + val bookFile = File(subFolder, "A book.org") + assertTrue(bookFile.exists()) + assertEquals(testBookContent, bookFile.readText()) + } + is GitRepo -> { + repoManipulationPoint as File + val git = Git( + FileRepositoryBuilder() + .addCeilingDirectory(repoManipulationPoint) + .findGitDir(repoManipulationPoint) + .build() + ) + git.pull().call() + val subFolder = File(repoManipulationPoint, "A folder") + assertTrue(subFolder.exists()) + val bookFile = File(subFolder, "A book.org") + assertTrue(bookFile.exists()) + assertEquals(testBookContent, bookFile.readText()) + } + is DocumentRepo -> { + repoManipulationPoint as DocumentFile + val subFolder = repoManipulationPoint.findFile("A folder") + assertTrue(subFolder!!.exists()) + assertTrue(subFolder.isDirectory) + val bookFile = subFolder.findFile("A book.org") + assertTrue(bookFile!!.exists()) + assertEquals(testBookContent, MiscUtils.readStringFromDocumentFile(bookFile)) + } + is DropboxRepo -> { + // Not really much to assert here; we don't really care how Dropbox implements things, + // as long as URLs work as expected. + repoManipulationPoint as DropboxClient + val retrievedFile = kotlin.io.path.createTempFile().toFile() + repoManipulationPoint.download(syncRepo.uri, repositoryPath, retrievedFile) + assertEquals(testBookContent, retrievedFile.readText()) + } + } + } + + fun testRenameBook_expectedUri(syncRepo: SyncRepo) { + // Given + val tmpFile = kotlin.io.path.createTempFile().toFile() + val oldFileName = "Original book.org" + val newBookName = "Renamed book" + val testBookContent = "\n\n...\n\n" + MiscUtils.writeStringToFile(testBookContent, tmpFile) + // When + val originalVrook = syncRepo.storeBook(tmpFile, oldFileName) + tmpFile.delete() + syncRepo.renameBook(originalVrook.uri, newBookName) + // Then + val renamedVrook = syncRepo.books[0] + val expectedRookUri = when (syncRepo) { + is GitRepo -> "/Renamed book.org" + is DocumentRepo -> syncRepo.uri.toString() + treeDocumentFileExtraSegment + "Renamed%20book.org" + else -> syncRepo.uri.toString() + "/Renamed%20book.org" + } + assertEquals(expectedRookUri, renamedVrook.uri.toString()) + } + + fun testRenameBook_repoFileAlreadyExists(repoManipulationPoint: Any, syncRepo: SyncRepo) { + // Given + for (fileName in arrayOf("Original.org", "Renamed.org")) { + writeFileToRepo("...", syncRepo, repoManipulationPoint, fileName) + } + val retrievedBookFile = kotlin.io.path.createTempFile().toFile() + // When + val originalRook = syncRepo.retrieveBook("Original.org", retrievedBookFile) + try { + syncRepo.renameBook(originalRook.uri, "Renamed") + } catch (e: IOException) { + // Then + assertTrue(e.message!!.contains("Renamed.org already exists")) + throw e + } finally { + retrievedBookFile.delete() + } + } + + fun testRenameBook_fromRootToSubfolder(syncRepo: SyncRepo) { + // Given + val tmpFile = kotlin.io.path.createTempFile().toFile() + MiscUtils.writeStringToFile("...", tmpFile) + // When + val originalRook = syncRepo.storeBook(tmpFile, "Original book.org") + tmpFile.delete() + val renamedRook = syncRepo.renameBook(originalRook.uri, "A folder/Renamed book") + // Then + val expectedRookUri = when (syncRepo) { + is GitRepo -> "/A folder/Renamed book.org" + is DocumentRepo -> syncRepo.uri.toString() + treeDocumentFileExtraSegment + "A%20folder%2FRenamed%20book.org" + else -> syncRepo.uri.toString() + "/A%20folder/Renamed%20book.org" + } + assertEquals(expectedRookUri, renamedRook.uri.toString()) + } + + fun testRenameBook_fromSubfolderToRoot(syncRepo: SyncRepo) { + // Given + val tmpFile = kotlin.io.path.createTempFile().toFile() + MiscUtils.writeStringToFile("...", tmpFile) + // When + val originalRook = syncRepo.storeBook(tmpFile, "A folder/Original book.org") + tmpFile.delete() + val renamedRook = syncRepo.renameBook(originalRook.uri, "Renamed book") + // Then + val expectedRookUri = when (syncRepo) { + is GitRepo -> "/Renamed book.org" + is DocumentRepo -> syncRepo.uri.toString() + treeDocumentFileExtraSegment + "Renamed%20book.org" + else -> syncRepo.uri.toString() + "/Renamed%20book.org" + } + assertEquals(expectedRookUri, renamedRook.uri.toString()) + } + + fun testRenameBook_newSubfolderSameLeafName(syncRepo: SyncRepo) { + // Given + val tmpFile = kotlin.io.path.createTempFile().toFile() + MiscUtils.writeStringToFile("...", tmpFile) + // When + val originalRook = syncRepo.storeBook(tmpFile, "Old folder/Original book.org") + tmpFile.delete() + val renamedRook = syncRepo.renameBook(originalRook.uri, "New folder/Original book") + // Then + val expectedRookUri = when (syncRepo) { + is GitRepo -> "/New folder/Original book.org" + is DocumentRepo -> syncRepo.uri.toString() + treeDocumentFileExtraSegment + "New%20folder%2FOriginal%20book.org" + else -> syncRepo.uri.toString() + "/New%20folder/Original%20book.org" + } + assertEquals(expectedRookUri, renamedRook.uri.toString()) + } + + fun testRenameBook_newSubfolderAndLeafName(syncRepo: SyncRepo) { + // Given + val tmpFile = kotlin.io.path.createTempFile().toFile() + MiscUtils.writeStringToFile("...", tmpFile) + // When + val originalRook = syncRepo.storeBook(tmpFile, "old folder/Original book.org") + tmpFile.delete() + val renamedRook = syncRepo.renameBook(originalRook.uri, "new folder/New book") + // Then + val expectedRookUri = when (syncRepo) { + is GitRepo -> "/new folder/New book.org" + is DocumentRepo -> syncRepo.uri.toString() + treeDocumentFileExtraSegment + "new%20folder%2FNew%20book.org" + else -> syncRepo.uri.toString() + "/new%20folder/New%20book.org" + } + assertEquals(expectedRookUri, renamedRook.uri.toString()) + } + + fun testRenameBook_sameSubfolderNewLeafName(syncRepo: SyncRepo) { + // Given + val tmpFile = kotlin.io.path.createTempFile().toFile() + MiscUtils.writeStringToFile("...", tmpFile) + // When + val originalRook = syncRepo.storeBook(tmpFile, "old folder/Original book.org") + tmpFile.delete() + val renamedRook = syncRepo.renameBook(originalRook.uri, "old folder/New book") + // Then + val expectedRookUri = when (syncRepo) { + is GitRepo -> "/old folder/New book.org" + is DocumentRepo -> syncRepo.uri.toString() + treeDocumentFileExtraSegment + "old%20folder%2FNew%20book.org" + else -> syncRepo.uri.toString() + "/old%20folder/New%20book.org" + } + assertEquals(expectedRookUri, renamedRook.uri.toString()) + } + + private fun writeFileToRepo( + content: String, + repo: SyncRepo, + repoManipulationPoint: Any, + fileName: String, + folderName: String? = null + ): String { + var expectedRookUri = repo.uri.toString() + "/" + Uri.encode(fileName) + when (repo) { + is WebdavRepo -> { + var targetDir = repoManipulationPoint as File + if (folderName != null) { + targetDir = File(targetDir.absolutePath + "/$folderName") + targetDir.mkdir() + expectedRookUri = repo.uri.toString() + "/" + Uri.encode("$folderName/$fileName", "/") + } + val remoteBookFile = File(targetDir.absolutePath + "/$fileName") + MiscUtils.writeStringToFile(content, remoteBookFile) + } + is GitRepo -> { + expectedRookUri = "/$fileName" + var targetDir = repoManipulationPoint as File + if (folderName != null) { + expectedRookUri = "/$folderName/$fileName" + targetDir = File(targetDir.absolutePath + "/$folderName") + targetDir.mkdir() + } + MiscUtils.writeStringToFile( + content, + File(targetDir.absolutePath + "/$fileName") + ) + updateGitRepo(repoManipulationPoint) + } + is DocumentRepo -> { + expectedRookUri = repo.uri.toString() + treeDocumentFileExtraSegment + Uri.encode(fileName) + var targetDir = repoManipulationPoint as DocumentFile + if (folderName != null) { + targetDir = targetDir.createDirectory(folderName)!! + expectedRookUri = repo.uri.toString() + treeDocumentFileExtraSegment + Uri.encode("$folderName/$fileName") + } + MiscUtils.writeStringToDocumentFile(content, fileName, targetDir.uri) + } + is DropboxRepo -> { + repoManipulationPoint as DropboxClient + val tmpFile = kotlin.io.path.createTempFile().toFile() + MiscUtils.writeStringToFile(content, tmpFile) + var targetPath = fileName + if (folderName != null) { + targetPath = "$folderName/$fileName" + expectedRookUri = repo.uri.toString() + "/" + Uri.encode("$folderName/$fileName", "/") + } + repoManipulationPoint.upload(tmpFile, repo.uri, targetPath) + tmpFile.delete() + } + } + return expectedRookUri + } + + private fun updateGitRepo(workdir: File) { + val git = Git( + FileRepositoryBuilder() + .addCeilingDirectory(workdir) + .findGitDir(workdir) + .build() + ) + git.add().addFilepattern(".").call() + git.commit().setMessage("").call() + git.push().call() + } + } +} \ No newline at end of file