From 204b38564e3cc658a942052f5c445e8287de4f5e Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Mon, 17 Feb 2025 12:14:52 +0100 Subject: [PATCH 1/4] Issue #128: Handle all currenty used data types when parsing preference values. --- .../main/java/com/orgzly/android/prefs/AppPreferences.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java b/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java index 861e2abf..b54989b5 100644 --- a/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java +++ b/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java @@ -20,7 +20,9 @@ import java.io.File; import java.net.URISyntaxException; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashSet; @@ -92,6 +94,10 @@ private static void setPrefsFromValues(SharedPreferences prefs, Map v } else if (value instanceof Set) { edit.putStringSet(key, (Set) value); + + } else if (value instanceof ArrayList) { + HashSet set = new HashSet<>((Collection) value); + edit.putStringSet(key, set); } } From c1db381db37a10f8131adc57a9fb740a3ef6c709 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Mon, 17 Feb 2025 12:22:58 +0100 Subject: [PATCH 2/4] Multiple books and notes can match a given property value Throw an error when this happens, instead of silently pretending that there was only one match. (When using property ID's to store app settings, the previous behavior would be dangerous. We would risk overwriting the wrong note with user settings data.) --- .../orgzly/android/misc/BookParsingTest.java | 43 ++++++++++--------- .../com/orgzly/android/data/DataRepository.kt | 12 +++--- .../java/com/orgzly/android/db/dao/BookDao.kt | 3 +- .../java/com/orgzly/android/db/dao/NoteDao.kt | 3 +- .../android/ui/main/MainActivityViewModel.kt | 15 ++++--- .../usecase/NoteOrBookFindWithProperty.kt | 4 +- 6 files changed, 42 insertions(+), 38 deletions(-) diff --git a/app/src/androidTest/java/com/orgzly/android/misc/BookParsingTest.java b/app/src/androidTest/java/com/orgzly/android/misc/BookParsingTest.java index 999b4d77..7217862a 100644 --- a/app/src/androidTest/java/com/orgzly/android/misc/BookParsingTest.java +++ b/app/src/androidTest/java/com/orgzly/android/misc/BookParsingTest.java @@ -14,10 +14,10 @@ import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.util.ArrayList; +import java.util.Collections; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; import static org.junit.Assume.assumeTrue; @@ -176,7 +176,8 @@ public void testBookSinglePropertyIsParsed() { """; TestedBook testedBook = onBook(content); testedBook.onLoad().isWhenSaved(content); - assertEquals(testedBook.book, dataRepository.findNoteOrBookHavingProperty("foo", "bar")); + assertEquals(new ArrayList<>(Collections.singleton(testedBook.book)), + dataRepository.findNotesOrBooksHavingProperty("foo", "bar")); } /** @@ -197,8 +198,8 @@ public void testBookMultiplePropertiesAreParsed() { """; TestedBook testedBook = onBook(content); testedBook.onLoad().isWhenSaved(content); - assertEquals(testedBook.book, dataRepository.findNoteOrBookHavingProperty("foo", "bar")); - assertEquals(testedBook.book, dataRepository.findNoteOrBookHavingProperty("bar", "foo")); + assertEquals(new ArrayList<>(Collections.singleton(testedBook.book)), dataRepository.findNotesOrBooksHavingProperty("foo", "bar")); + assertEquals(new ArrayList<>(Collections.singleton(testedBook.book)), dataRepository.findNotesOrBooksHavingProperty("bar", "foo")); } /** @@ -220,12 +221,12 @@ public void testBookDuplicateProperties() { """; TestedBook testedBook = onBook(content); testedBook.onLoad().isWhenSaved(content); - assertEquals(testedBook.book, dataRepository.findNoteOrBookHavingProperty("foo", - "secondvalue")); - assertEquals(testedBook.book, dataRepository.findNoteOrBookHavingProperty("bar", - "secondvalue")); - assertNull(dataRepository.findNoteOrBookHavingProperty("foo", "firstvalue")); - assertNull(dataRepository.findNoteOrBookHavingProperty("bar", "firstvalue")); + assertEquals(new ArrayList<>(Collections.singleton(testedBook.book)), + dataRepository.findNotesOrBooksHavingProperty("foo", "secondvalue")); + assertEquals(new ArrayList<>(Collections.singleton(testedBook.book)), + dataRepository.findNotesOrBooksHavingProperty("bar", "secondvalue")); + assertEquals(0, dataRepository.findNotesOrBooksHavingProperty("foo", "firstvalue").size()); + assertEquals(0, dataRepository.findNotesOrBooksHavingProperty("bar", "firstvalue").size()); } @Test @@ -243,16 +244,16 @@ public void testBookDuplicatePropertiesDifferentCase() { """; TestedBook testedBook = onBook(content); testedBook.onLoad().isWhenSaved(content); - assertEquals(testedBook.book, dataRepository.findNoteOrBookHavingProperty("foo", - "secondvalue")); - assertEquals(testedBook.book, dataRepository.findNoteOrBookHavingProperty("bar", - "secondvalue")); - assertEquals(testedBook.book, dataRepository.findNoteOrBookHavingProperty("FOO", - "secondvalue")); - assertEquals(testedBook.book, dataRepository.findNoteOrBookHavingProperty("BAR", - "secondvalue")); - assertNull(dataRepository.findNoteOrBookHavingProperty("foo", "firstvalue")); - assertNull(dataRepository.findNoteOrBookHavingProperty("bar", "firstvalue")); + assertEquals(new ArrayList<>(Collections.singleton(testedBook.book)), + dataRepository.findNotesOrBooksHavingProperty("foo", "secondvalue")); + assertEquals(new ArrayList<>(Collections.singleton(testedBook.book)), + dataRepository.findNotesOrBooksHavingProperty("bar", "secondvalue")); + assertEquals(new ArrayList<>(Collections.singleton(testedBook.book)), + dataRepository.findNotesOrBooksHavingProperty("FOO", "secondvalue")); + assertEquals(new ArrayList<>(Collections.singleton(testedBook.book)), + dataRepository.findNotesOrBooksHavingProperty("BAR", "secondvalue")); + assertEquals(0, dataRepository.findNotesOrBooksHavingProperty("foo", "firstvalue").size()); + assertEquals(0, dataRepository.findNotesOrBooksHavingProperty("bar", "firstvalue").size()); } /* 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 f384fc8e..34087ed8 100644 --- a/app/src/main/java/com/orgzly/android/data/DataRepository.kt +++ b/app/src/main/java/com/orgzly/android/data/DataRepository.kt @@ -1987,15 +1987,15 @@ class DataRepository @Inject constructor( NotesOrgExporter(this).exportBook(book, writer) } - fun findNoteHavingProperty(name: String, value: String): NoteDao.NoteIdBookId? { - return db.note().firstNoteHavingPropertyLowerCase(name.lowercase(), value.lowercase()) + fun findNotesHavingProperty(name: String, value: String): List { + return db.note().allNotesHavingPropertyLowerCase(name.lowercase(), value.lowercase()) } - fun findNoteOrBookHavingProperty(name: String, value: String): Any? { - val foundNote = findNoteHavingProperty(name, value) - if (foundNote != null) + fun findNotesOrBooksHavingProperty(name: String, value: String): List { + val foundNote = findNotesHavingProperty(name, value) + if (foundNote.isNotEmpty()) return foundNote - return db.book().firstBookHavingPropertyLowerCase(name.lowercase(), value.lowercase()) + return db.book().allBooksHavingPropertyLowerCase(name.lowercase(), value.lowercase()) } /* diff --git a/app/src/main/java/com/orgzly/android/db/dao/BookDao.kt b/app/src/main/java/com/orgzly/android/db/dao/BookDao.kt index 899e0c30..27bc1921 100644 --- a/app/src/main/java/com/orgzly/android/db/dao/BookDao.kt +++ b/app/src/main/java/com/orgzly/android/db/dao/BookDao.kt @@ -58,9 +58,8 @@ abstract class BookDao : BaseDao { FROM book_properties LEFT JOIN books ON (books.id = book_properties.book_id) WHERE LOWER(book_properties.name) = :name AND LOWER(book_properties.value) = :value AND books.id IS NOT NULL - LIMIT 1 """) - abstract fun firstBookHavingPropertyLowerCase(name: String, value: String): Book? + abstract fun allBooksHavingPropertyLowerCase(name: String, value: String): List fun getOrInsert(name: String): Long = get(name).let { diff --git a/app/src/main/java/com/orgzly/android/db/dao/NoteDao.kt b/app/src/main/java/com/orgzly/android/db/dao/NoteDao.kt index 9be88ef1..655c1822 100644 --- a/app/src/main/java/com/orgzly/android/db/dao/NoteDao.kt +++ b/app/src/main/java/com/orgzly/android/db/dao/NoteDao.kt @@ -306,9 +306,8 @@ abstract class NoteDao : BaseDao { LEFT JOIN notes ON (notes.id = note_properties.note_id) WHERE LOWER(note_properties.name) = :name AND LOWER(note_properties.value) = :value AND notes.id IS NOT NULL ORDER BY notes.lft - LIMIT 1 """) - abstract fun firstNoteHavingPropertyLowerCase(name: String, value: String): NoteIdBookId? + abstract fun allNotesHavingPropertyLowerCase(name: String, value: String): List @Query(""" UPDATE notes diff --git a/app/src/main/java/com/orgzly/android/ui/main/MainActivityViewModel.kt b/app/src/main/java/com/orgzly/android/ui/main/MainActivityViewModel.kt index 2ead4f34..404e3654 100644 --- a/app/src/main/java/com/orgzly/android/ui/main/MainActivityViewModel.kt +++ b/app/src/main/java/com/orgzly/android/ui/main/MainActivityViewModel.kt @@ -81,15 +81,19 @@ class MainActivityViewModel(private val dataRepository: DataRepository) : Common catchAndPostError { val result = UseCaseRunner.run(useCase) + val data = result.userData as List<*> - if (result.userData == null) { + if (data.isEmpty()) { val msg = App.getAppContext().getString(R.string.no_such_link_target, name, value) errorEvent.postValue(Throwable(msg)) - } else { - when (result.userData) { + if (data.size > 1) { + val msg = App.getAppContext().getString(R.string.error_multiple_notes_with_matching_property_value, name, value) + errorEvent.postValue(Throwable(msg)) + } + when (data[0]) { is NoteDao.NoteIdBookId -> { - val noteIdBookId = result.userData + val noteIdBookId = data[0] as NoteDao.NoteIdBookId when (AppPreferences.linkTarget(App.getAppContext())) { "note_details" -> @@ -104,7 +108,8 @@ class MainActivityViewModel(private val dataRepository: DataRepository) : Common } } is Book -> { - navigationActions.postValue(MainNavigationAction.OpenBook(result.userData.id)) + val book = data[0] as Book + navigationActions.postValue(MainNavigationAction.OpenBook(book.id)) } } } diff --git a/app/src/main/java/com/orgzly/android/usecase/NoteOrBookFindWithProperty.kt b/app/src/main/java/com/orgzly/android/usecase/NoteOrBookFindWithProperty.kt index 501d2783..7113ee3d 100644 --- a/app/src/main/java/com/orgzly/android/usecase/NoteOrBookFindWithProperty.kt +++ b/app/src/main/java/com/orgzly/android/usecase/NoteOrBookFindWithProperty.kt @@ -4,10 +4,10 @@ import com.orgzly.android.data.DataRepository class NoteOrBookFindWithProperty(val name: String, val value: String) : UseCase() { override fun run(dataRepository: DataRepository): UseCaseResult { - val noteOrBook = dataRepository.findNoteOrBookHavingProperty(name, value) + val notesOrBooks = dataRepository.findNotesOrBooksHavingProperty(name, value) return UseCaseResult( - userData = noteOrBook + userData = notesOrBooks ) } } \ No newline at end of file From 6f16e23b79d2551b48925c5d32367744d50cba1a Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Mon, 17 Feb 2025 12:27:48 +0100 Subject: [PATCH 3/4] Issue #128: Add feature to export/import settings to a chosen note The UI code has mostly been copied from ui.refile. If I were better at Android, I would refactor to reduce the amount of duplicated code. But this is easier, and I doubt it will create total chaos. --- .../com/orgzly/android/data/DataRepository.kt | 52 +++++ .../com/orgzly/android/di/AppComponent.kt | 8 +- .../orgzly/android/prefs/AppPreferences.java | 19 ++ .../android/ui/settings/SettingsActivity.kt | 6 +- .../android/ui/settings/SettingsFragment.kt | 16 ++ .../exporting/SettingsExportAdapter.kt | 103 +++++++++ .../exporting/SettingsExportFragment.kt | 210 ++++++++++++++++++ .../exporting/SettingsExportViewModel.kt | 178 +++++++++++++++ .../SettingsExportViewModelFactory.kt | 26 +++ .../importing/SettingsImportAdapter.kt | 103 +++++++++ .../importing/SettingsImportFragment.kt | 201 +++++++++++++++++ .../importing/SettingsImportViewModel.kt | 178 +++++++++++++++ .../SettingsImportViewModelFactory.kt | 26 +++ app/src/main/res/drawable-anydpi/ic_sync.xml | 2 +- app/src/main/res/drawable/ic_export.xml | 10 + app/src/main/res/drawable/ic_import.xml | 10 + .../res/layout/dialog_export_settings.xml | 86 +++++++ .../res/layout/dialog_import_settings.xml | 73 ++++++ .../main/res/layout/item_settings_export.xml | 51 +++++ .../main/res/layout/item_settings_import.xml | 51 +++++ app/src/main/res/values/prefs_keys.xml | 4 + app/src/main/res/values/strings.xml | 12 + app/src/main/res/xml/prefs_screen_app.xml | 10 + 23 files changed, 1431 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/orgzly/android/ui/settings/exporting/SettingsExportAdapter.kt create mode 100644 app/src/main/java/com/orgzly/android/ui/settings/exporting/SettingsExportFragment.kt create mode 100644 app/src/main/java/com/orgzly/android/ui/settings/exporting/SettingsExportViewModel.kt create mode 100644 app/src/main/java/com/orgzly/android/ui/settings/exporting/SettingsExportViewModelFactory.kt create mode 100644 app/src/main/java/com/orgzly/android/ui/settings/importing/SettingsImportAdapter.kt create mode 100644 app/src/main/java/com/orgzly/android/ui/settings/importing/SettingsImportFragment.kt create mode 100644 app/src/main/java/com/orgzly/android/ui/settings/importing/SettingsImportViewModel.kt create mode 100644 app/src/main/java/com/orgzly/android/ui/settings/importing/SettingsImportViewModelFactory.kt create mode 100644 app/src/main/res/drawable/ic_export.xml create mode 100644 app/src/main/res/drawable/ic_import.xml create mode 100644 app/src/main/res/layout/dialog_export_settings.xml create mode 100644 app/src/main/res/layout/dialog_import_settings.xml create mode 100644 app/src/main/res/layout/item_settings_export.xml create mode 100644 app/src/main/res/layout/item_settings_import.xml 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 34087ed8..d47f568f 100644 --- a/app/src/main/java/com/orgzly/android/data/DataRepository.kt +++ b/app/src/main/java/com/orgzly/android/data/DataRepository.kt @@ -13,8 +13,11 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.map import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.preference.PreferenceManager import androidx.sqlite.db.SupportSQLiteQuery import androidx.sqlite.db.SupportSQLiteQueryBuilder +import com.google.gson.Gson +import com.google.gson.JsonObject import com.orgzly.BuildConfig import com.orgzly.R import com.orgzly.android.* @@ -22,6 +25,7 @@ import com.orgzly.android.data.mappers.OrgMapper import com.orgzly.android.db.NotesClipboard import com.orgzly.android.db.OrgzlyDatabase import com.orgzly.android.db.dao.NoteDao +import com.orgzly.android.db.dao.NoteDao.NoteIdBookId import com.orgzly.android.db.dao.NoteViewDao import com.orgzly.android.db.dao.ReminderTimeDao import com.orgzly.android.db.entity.* @@ -1998,6 +2002,54 @@ class DataRepository @Inject constructor( return db.book().allBooksHavingPropertyLowerCase(name.lowercase(), value.lowercase()) } + fun findUniqueNoteHavingProperty(name: String, value: String): NoteIdBookId? { + val foundNotes = db.note().allNotesHavingPropertyLowerCase(name.lowercase(), value.lowercase()) + return if (foundNotes.isEmpty()) null + else if (foundNotes.size == 1) foundNotes[0] + else { + val msg = App.getAppContext().getString(R.string.error_multiple_notes_with_matching_property_value, name, value) + throw RuntimeException(msg) + } + } + + fun exportSettingsAndSearchesToSelectedNote() { + val targetNote = findUniqueNoteHavingProperty("ID", AppPreferences.settingsExportAndImportNoteId(context)) + if (targetNote != null) { + val gson = Gson() + // Get settings as JSON + val settingsJsonObject = gson.toJsonTree(PreferenceManager.getDefaultSharedPreferences(context).all) + // Get saved searches as JSON + val savedSearchesJsonObject = gson.fromJson("{}", JsonObject::class.java) + getSavedSearches().forEach { + savedSearchesJsonObject.addProperty(it.name, it.query) + } + // Put them together + val finalMap = mapOf("settings" to settingsJsonObject, "saved_searches" to savedSearchesJsonObject) + val finalJsonString = gson.toJson(finalMap) + updateNoteContent(targetNote.bookId, targetNote.noteId, finalJsonString) + } + } + + fun importSettingsAndSearchesFromSelectedNote() { + val sourceNote = findUniqueNoteHavingProperty("ID", AppPreferences.settingsExportAndImportNoteId(context)) + if (sourceNote != null) { + val notePayload = getNotePayload(sourceNote.noteId) + if (notePayload != null) { + val gson = Gson().fromJson(notePayload.content, Map::class.java) + val settings = gson["settings"] as Map + if (settings.isNotEmpty()) + AppPreferences.setDefaultPrefsFromJsonMap(context, settings) + val savedSearches: List = (gson["saved_searches"] as Map) + .entries + .mapIndexed { index, entry -> + SavedSearch(0, entry.key, entry.value, index + 1) + } + if (savedSearches.isNotEmpty()) + replaceSavedSearches(savedSearches) + } + } + } + /* * Saved search */ diff --git a/app/src/main/java/com/orgzly/android/di/AppComponent.kt b/app/src/main/java/com/orgzly/android/di/AppComponent.kt index d89a48fa..4cbcc7b9 100644 --- a/app/src/main/java/com/orgzly/android/di/AppComponent.kt +++ b/app/src/main/java/com/orgzly/android/di/AppComponent.kt @@ -12,11 +12,10 @@ import com.orgzly.android.reminders.NoteReminders import com.orgzly.android.reminders.RemindersBroadcastReceiver import com.orgzly.android.sync.SyncWorker import com.orgzly.android.ui.BookChooserActivity -import com.orgzly.android.ui.logs.AppLogsActivity import com.orgzly.android.ui.TemplateChooserActivity import com.orgzly.android.ui.books.BooksFragment +import com.orgzly.android.ui.logs.AppLogsActivity import com.orgzly.android.ui.main.MainActivity -import com.orgzly.android.ui.sync.SyncFragment import com.orgzly.android.ui.note.NoteFragment import com.orgzly.android.ui.notes.NotesFragment import com.orgzly.android.ui.notes.book.BookFragment @@ -33,7 +32,10 @@ import com.orgzly.android.ui.repos.ReposActivity import com.orgzly.android.ui.savedsearch.SavedSearchFragment import com.orgzly.android.ui.savedsearches.SavedSearchesFragment import com.orgzly.android.ui.settings.SettingsActivity +import com.orgzly.android.ui.settings.exporting.SettingsExportFragment +import com.orgzly.android.ui.settings.importing.SettingsImportFragment import com.orgzly.android.ui.share.ShareActivity +import com.orgzly.android.ui.sync.SyncFragment import com.orgzly.android.usecase.UseCaseRunner import com.orgzly.android.usecase.UseCaseWorker import com.orgzly.android.widgets.ListWidgetProvider @@ -74,6 +76,8 @@ interface AppComponent { fun inject(arg: SavedSearchesFragment) fun inject(arg: SavedSearchFragment) fun inject(arg: RefileFragment) + fun inject(arg: SettingsExportFragment) + fun inject(arg: SettingsImportFragment) fun inject(arg: SyncFragment) fun inject(arg: SyncWorker) diff --git a/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java b/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java index b54989b5..170c4cb4 100644 --- a/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java +++ b/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java @@ -62,6 +62,11 @@ public static AppPreferencesValues getAllValues(Context context) { return values; } + public static void setDefaultPrefsFromJsonMap(Context context, Map parsedMap) { + SharedPreferences prefs = getDefaultSharedPreferences(context); + setPrefsFromValues(prefs, parsedMap); + } + public static void setAllFromValues(Context context, AppPreferencesValues values) { AppPreferences.clearAllSharedPreferences(context); @@ -1149,6 +1154,20 @@ public static void subfolderSupport(Context context, boolean value) { getDefaultSharedPreferences(context).edit().putBoolean(key, value).apply(); } + /* + * Export and import of user settings + */ + + public static String settingsExportAndImportNoteId(Context context) { + return getDefaultSharedPreferences(context).getString( + context.getResources().getString(R.string.pref_key_note_id_for_settings_export_and_import), ""); + } + + public static void settingsExportAndImportNoteId(Context context, String value) { + String key = context.getResources().getString(R.string.pref_key_note_id_for_settings_export_and_import); + getDefaultSharedPreferences(context).edit().putString(key, value).apply(); + } + /* * Repository properties map */ diff --git a/app/src/main/java/com/orgzly/android/ui/settings/SettingsActivity.kt b/app/src/main/java/com/orgzly/android/ui/settings/SettingsActivity.kt index 346dbd69..3dcf7c4b 100644 --- a/app/src/main/java/com/orgzly/android/ui/settings/SettingsActivity.kt +++ b/app/src/main/java/com/orgzly/android/ui/settings/SettingsActivity.kt @@ -7,7 +7,11 @@ import com.orgzly.android.App import com.orgzly.android.ui.CommonActivity import com.orgzly.android.ui.settings.SettingsFragment.Listener import com.orgzly.android.ui.showSnackbar -import com.orgzly.android.usecase.* +import com.orgzly.android.usecase.BookImportGettingStarted +import com.orgzly.android.usecase.DatabaseClear +import com.orgzly.android.usecase.UseCase +import com.orgzly.android.usecase.UseCaseRunner +import com.orgzly.android.usecase.UseCaseWorker import com.orgzly.databinding.ActivitySettingsBinding class SettingsActivity : CommonActivity(), Listener { diff --git a/app/src/main/java/com/orgzly/android/ui/settings/SettingsFragment.kt b/app/src/main/java/com/orgzly/android/ui/settings/SettingsFragment.kt index 96ce0a1d..2f3810ec 100644 --- a/app/src/main/java/com/orgzly/android/ui/settings/SettingsFragment.kt +++ b/app/src/main/java/com/orgzly/android/ui/settings/SettingsFragment.kt @@ -26,6 +26,8 @@ import com.orgzly.android.usecase.UseCase import com.orgzly.android.util.AppPermissions import com.orgzly.android.util.LogUtils import com.orgzly.android.widgets.ListWidgetProvider +import com.orgzly.android.ui.settings.exporting.SettingsExportFragment +import com.orgzly.android.ui.settings.importing.SettingsImportFragment /** * Displays settings. @@ -68,6 +70,20 @@ class SettingsFragment : PreferenceFragmentCompat(), SharedPreferences.OnSharedP } } + preference(R.string.pref_key_export_settings)?.let { + it.setOnPreferenceClickListener { + SettingsExportFragment().show(childFragmentManager, SettingsExportFragment.FRAGMENT_TAG) + true + } + } + + preference(R.string.pref_key_import_settings)?.let { + it.setOnPreferenceClickListener { + SettingsImportFragment().show(childFragmentManager, SettingsImportFragment.FRAGMENT_TAG) + true + } + } + preference(R.string.pref_key_reload_getting_started)?.let { it.setOnPreferenceClickListener { listener?.onGettingStartedNotebookReloadRequest() diff --git a/app/src/main/java/com/orgzly/android/ui/settings/exporting/SettingsExportAdapter.kt b/app/src/main/java/com/orgzly/android/ui/settings/exporting/SettingsExportAdapter.kt new file mode 100644 index 00000000..3eee0b71 --- /dev/null +++ b/app/src/main/java/com/orgzly/android/ui/settings/exporting/SettingsExportAdapter.kt @@ -0,0 +1,103 @@ +package com.orgzly.android.ui.settings.exporting + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.DrawableRes +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.orgzly.R +import com.orgzly.android.db.entity.Book +import com.orgzly.android.db.entity.Note +import com.orgzly.android.db.entity.NoteView +import com.orgzly.android.ui.notes.NoteItemViewBinder +import com.orgzly.databinding.ItemSettingsExportBinding + +class SettingsExportAdapter(val context: Context, val listener: OnClickListener) : + ListAdapter( + DIFF_CALLBACK + ) { + + data class Icons(@DrawableRes val up: Int, @DrawableRes val book: Int) + + private var icons: Icons? = null + + private val noteItemViewBinder = NoteItemViewBinder(context, true) + + interface OnClickListener { + fun onItem(item: SettingsExportViewModel.Item) + fun onButton(item: SettingsExportViewModel.Item) + } + + class SettingsExportViewHolder(val binding: ItemSettingsExportBinding) : RecyclerView.ViewHolder(binding.root) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SettingsExportViewHolder { + val holder = SettingsExportViewHolder(ItemSettingsExportBinding.inflate( + LayoutInflater.from(parent.context), parent, false)) + + holder.binding.itemSettingsExportPayload.setOnClickListener { + val position = holder.bindingAdapterPosition + if (position != RecyclerView.NO_POSITION) { + listener.onItem(getItem(holder.bindingAdapterPosition)) + } + } + + holder.binding.itemSettingsExportButton.setOnClickListener { + val position = holder.bindingAdapterPosition + if (position != RecyclerView.NO_POSITION) { + listener.onButton(getItem(holder.bindingAdapterPosition)) + } + } + + return holder + } + + override fun onBindViewHolder(holder: SettingsExportViewHolder, position: Int) { + + if (icons == null) { + icons = Icons(R.drawable.ic_keyboard_arrow_up, R.drawable.ic_library_books) + } + + val item = getItem(position) + + when (val payload = item.payload) { + is Book -> { + holder.binding.itemSettingsExportName.text = payload.title ?: payload.name + + holder.binding.itemSettingsExportButton.visibility = View.GONE + + holder.binding.itemSettingsExportIcon.visibility = View.VISIBLE + } + + is Note -> { + holder.binding.itemSettingsExportName.text = noteItemViewBinder.generateTitle( + NoteView(note = payload, bookName = "")) + icons?.let { + if (payload.position.descendantsCount > 0) { + holder.binding.itemSettingsExportIcon.setImageResource(R.drawable.bullet_folded) + holder.binding.itemSettingsExportButton.visibility = View.GONE + } else { + holder.binding.itemSettingsExportIcon.setImageResource(R.drawable.bullet) + holder.binding.itemSettingsExportButton.visibility = View.VISIBLE + } + holder.binding.itemSettingsExportIcon.visibility = View.VISIBLE + } + } + } + } + + companion object { + private val DIFF_CALLBACK: DiffUtil.ItemCallback = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: SettingsExportViewModel.Item, newItem: SettingsExportViewModel.Item): Boolean { + return oldItem == newItem + } + + override fun areContentsTheSame(oldItem: SettingsExportViewModel.Item, newItem: SettingsExportViewModel.Item): Boolean { + return oldItem == newItem + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/ui/settings/exporting/SettingsExportFragment.kt b/app/src/main/java/com/orgzly/android/ui/settings/exporting/SettingsExportFragment.kt new file mode 100644 index 00000000..7ac5312f --- /dev/null +++ b/app/src/main/java/com/orgzly/android/ui/settings/exporting/SettingsExportFragment.kt @@ -0,0 +1,210 @@ +package com.orgzly.android.ui.settings.exporting + +import android.app.Dialog +import android.content.Context +import android.os.Bundle +import android.text.method.LinkMovementMethod +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.orgzly.BuildConfig +import com.orgzly.R +import com.orgzly.android.App +import com.orgzly.android.data.DataRepository +import com.orgzly.android.db.entity.Book +import com.orgzly.android.db.entity.Note +import com.orgzly.android.ui.Breadcrumbs +import com.orgzly.android.ui.note.NotePayload +import com.orgzly.android.ui.showSnackbar +import com.orgzly.android.ui.util.styledAttributes +import com.orgzly.android.util.LogUtils +import com.orgzly.databinding.DialogExportSettingsBinding +import javax.inject.Inject + +class SettingsExportFragment : DialogFragment() { + + private lateinit var binding: DialogExportSettingsBinding + + @Inject + lateinit var dataRepository: DataRepository + + lateinit var viewModel: SettingsExportViewModel + + override fun onAttach(context: Context) { + super.onAttach(context) + + App.appComponent.inject(this) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (BuildConfig.LOG_DEBUG) LogUtils.d(TAG) + + val noteIds = arguments?.getLongArray(ARG_NOTE_IDS)?.toSet() ?: emptySet() + val count = arguments?.getInt(ARG_COUNT) ?: 0 + + val factory = SettingsExportViewModelFactory.forNotes(dataRepository, noteIds, count) + + viewModel = ViewModelProvider(this, factory)[SettingsExportViewModel::class.java] + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + if (BuildConfig.LOG_DEBUG) LogUtils.d(TAG) + + val dialog = MaterialAlertDialogBuilder(requireContext(), theme) + + return dialog.show() + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + if (BuildConfig.LOG_DEBUG) LogUtils.d(TAG, savedInstanceState) + + binding = DialogExportSettingsBinding.inflate(inflater, container, false) + + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.dialogExportSettingsToolbar.apply { + title = resources.getString(R.string.export_settings_to_note) + + setNavigationOnClickListener { + dismiss() + } + } + + binding.dialogExportSettingsWarning.apply { + /* Get error color attribute. */ + setTextColor(context.styledAttributes(intArrayOf(R.attr.colorError)) { typedArray -> + typedArray.getColor(0, 0) + }) + text = context.getString(R.string.export_settings_warning_text) + } + + val adapter = SettingsExportAdapter(binding.root.context, object: SettingsExportAdapter.OnClickListener { + override fun onItem(item: SettingsExportViewModel.Item) { + viewModel.open(item) + } + + override fun onButton(item: SettingsExportViewModel.Item) { + viewModel.export(item) + } + }) + + binding.dialogExportSettingsTargets.let { + it.layoutManager = LinearLayoutManager(context) + it.adapter = adapter + } + + binding.dialogExportSettingsBreadcrumbs.movementMethod = LinkMovementMethod.getInstance() + + viewModel.data.observe(viewLifecycleOwner) { data -> + val breadcrumbs = data.first + val list = data.second + + adapter.submitList(list) + + // Update and scroll breadcrumbs to the end + binding.dialogExportSettingsBreadcrumbs.text = generateBreadcrumbs(breadcrumbs) + binding.dialogExportSettingsBreadcrumbsScrollView.apply { + post { + fullScroll(View.FOCUS_RIGHT) + } + } + } + + viewModel.exportedEvent.observeSingle(viewLifecycleOwner) { result -> + if (result.userData is RuntimeException) { + (result.userData).let { + activity?.showSnackbar(it.localizedMessage) + } + } else { + dismiss() + + (result.userData as NotePayload).let { + activity?.showSnackbar( + getString( + R.string.settings_exported_to, it.title + ) + ) + } + } + } + + viewModel.errorEvent.observeSingle(viewLifecycleOwner) { error -> + binding.dialogExportSettingsToolbar.subtitle = (error.cause ?: error).localizedMessage + } + + viewModel.openForTheFirstTime() + } + + private fun generateBreadcrumbs(path: List): CharSequence { + val breadcrumbs = Breadcrumbs() + + path.forEachIndexed { index, item -> + val onClick = if (index != path.size - 1) { // Not last + fun() { + viewModel.onBreadcrumbClick(item) + } + } else { + null + } + + when (val payload = item.payload) { + is SettingsExportViewModel.Home -> + breadcrumbs.add(getString(R.string.notebooks), 0, onClick = onClick) + is Book -> + breadcrumbs.add(payload.title ?: payload.name, 0, onClick = onClick) + is Note -> + breadcrumbs.add(payload.title, onClick = onClick) + } + } + + return breadcrumbs.toCharSequence() + } + + override fun onResume() { + super.onResume() + + dialog?.apply { + + val w = resources.displayMetrics.widthPixels + val h = resources.displayMetrics.heightPixels + + requireDialog().window?.apply { + if (h > w) { // Portrait + setLayout(ViewGroup.LayoutParams.MATCH_PARENT, (h * 0.90).toInt()) + } else { + setLayout((w * 0.90).toInt(), ViewGroup.LayoutParams.MATCH_PARENT) + } + } + + } + + } + + companion object { + fun getInstance(noteIds: Set, count: Int): SettingsExportFragment { + return SettingsExportFragment().also { fragment -> + fragment.arguments = Bundle().apply { + putLongArray(ARG_NOTE_IDS, noteIds.toLongArray()) + putInt(ARG_COUNT, count) + } + } + } + + private const val ARG_NOTE_IDS = "note_ids" + private const val ARG_COUNT = "count" + + private val TAG = SettingsExportFragment::class.java.name + + val FRAGMENT_TAG: String = SettingsExportFragment::class.java.name + } +} \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/ui/settings/exporting/SettingsExportViewModel.kt b/app/src/main/java/com/orgzly/android/ui/settings/exporting/SettingsExportViewModel.kt new file mode 100644 index 00000000..ebb27a44 --- /dev/null +++ b/app/src/main/java/com/orgzly/android/ui/settings/exporting/SettingsExportViewModel.kt @@ -0,0 +1,178 @@ +package com.orgzly.android.ui.settings.exporting + +import androidx.lifecycle.MutableLiveData +import com.orgzly.BuildConfig +import com.orgzly.R +import com.orgzly.android.App +import com.orgzly.android.data.DataRepository +import com.orgzly.android.db.dao.NoteDao.NoteIdBookId +import com.orgzly.android.db.entity.Book +import com.orgzly.android.db.entity.Note +import com.orgzly.android.prefs.AppPreferences +import com.orgzly.android.ui.CommonViewModel +import com.orgzly.android.ui.SingleLiveEvent +import com.orgzly.android.usecase.UseCase.Companion.SYNC_DATA_MODIFIED +import com.orgzly.android.usecase.UseCaseResult +import com.orgzly.android.util.LogUtils +import java.util.Stack +import java.util.UUID + +class SettingsExportViewModel( + val dataRepository: DataRepository, + val noteIds: Set, + val count: Int) : CommonViewModel() { + + class Home + class Parent + + data class Item(val payload: Any? = null, val name: String? = null) + + private val breadcrumbs = Stack() + + val data = MutableLiveData, List>>() + + val exportedEvent: SingleLiveEvent = SingleLiveEvent() + + fun openForTheFirstTime() { + val targetNote: NoteIdBookId? = dataRepository.findUniqueNoteHavingProperty( + "ID", AppPreferences.settingsExportAndImportNoteId(App.getAppContext())) + var item: Item? = null + if (targetNote != null) { + val note = dataRepository.getNote(targetNote.noteId) + if (note != null) { + val parentNote = dataRepository.getNoteAncestors(note.id).last() + item = replayUntilNoteId(parentNote.id) + } + } + if (item == null) + item = HOME + open(item) + } + + fun open(item: Item) { + val payload = item.payload + + if (BuildConfig.LOG_DEBUG) LogUtils.d(TAG, payload) + + when (payload) { + is Parent -> { + breadcrumbs.pop() + + open(breadcrumbs.pop()) + } + + is Home -> { + App.EXECUTORS.diskIO().execute { + val items = dataRepository.getBooks().map { book -> + Item(book.book, book.book.name) + } + + breadcrumbs.clear() + breadcrumbs.push(HOME) + + data.postValue(Pair(breadcrumbs, items)) + } + } + + is Book -> { + App.EXECUTORS.diskIO().execute { + val items = dataRepository.getTopLevelNotes(payload.id).map { note -> + Item(note, note.title) + } + + breadcrumbs.push(item) + + data.postValue(Pair(breadcrumbs, items)) + } + } + + is Note -> { + App.EXECUTORS.diskIO().execute { + val items = dataRepository.getNoteChildren(payload.id).map { note -> + Item(note, note.title) + } + + if (BuildConfig.LOG_DEBUG) LogUtils.d(TAG, "Items for $payload: $items") + + if (items.isNotEmpty()) { + breadcrumbs.push(item) + + data.postValue(Pair(breadcrumbs, items)) + } + } + } + } + } + + fun export(item: Item) { + val payload = item.payload as Note + val notePayload = dataRepository.getNotePayload(payload.id) ?: throw RuntimeException( + App.getAppContext().getString(R.string.failed_to_get_note_payload)) + if (notePayload.properties.get("ID") == null) { + // Note has no "ID" property - let's add one + notePayload.properties.put("ID", UUID.randomUUID().toString()) + dataRepository.updateNote(payload.id, notePayload) + } else { + // Check that the note's "ID" property value is unique + if (dataRepository.findNotesHavingProperty("ID", notePayload.properties.get("ID")).size > 1) { + exportedEvent.postValue( + UseCaseResult( + modifiesLocalData = true, + triggersSync = SYNC_DATA_MODIFIED, + userData = RuntimeException(App.getAppContext().getString(R.string.selected_notes_id_property_value_is_not_unique)) + ) + ) + return + } + } + AppPreferences.settingsExportAndImportNoteId(App.getAppContext(), notePayload.properties.get("ID")) + dataRepository.exportSettingsAndSearchesToSelectedNote() + exportedEvent.postValue(UseCaseResult( + modifiesLocalData = true, + triggersSync = SYNC_DATA_MODIFIED, + userData = notePayload + )) + } + + private fun replayUntilNoteId(noteId: Long): Item? { + val notes = dataRepository.getNoteAndAncestors(noteId) + + if (notes.isNotEmpty()) { + val lastNote = notes.last() + + val book = dataRepository.getBook(lastNote.position.bookId) + + if (book != null) { + breadcrumbs.clear() + breadcrumbs.add(HOME) + breadcrumbs.add(Item(book, book.name)) + + for (i in 0 until notes.count() - 1) { + val note = notes[i] + + val item = Item(note, note.title) + + breadcrumbs.push(item) + } + + + return Item(lastNote, lastNote.title) + } + } + + return null + } + + fun onBreadcrumbClick(item: Item) { + if (BuildConfig.LOG_DEBUG) LogUtils.d(TAG, item) + while (breadcrumbs.pop() != item) { + // Pop up to and including clicked item + } + open(item) + } + + companion object { + val HOME = Item(Home()) + private val TAG = SettingsExportViewModel::class.java.name + } +} \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/ui/settings/exporting/SettingsExportViewModelFactory.kt b/app/src/main/java/com/orgzly/android/ui/settings/exporting/SettingsExportViewModelFactory.kt new file mode 100644 index 00000000..8accf249 --- /dev/null +++ b/app/src/main/java/com/orgzly/android/ui/settings/exporting/SettingsExportViewModelFactory.kt @@ -0,0 +1,26 @@ +package com.orgzly.android.ui.settings.exporting + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.orgzly.android.data.DataRepository + +class SettingsExportViewModelFactory( + private val dataRepository: DataRepository, + private val noteIds: Set, + private val count: Int) : ViewModelProvider.Factory { + + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return SettingsExportViewModel(dataRepository, noteIds, count) as T + } + + companion object { + fun forNotes( + dataRepository: DataRepository, + noteIds: Set, + count: Int): ViewModelProvider.Factory { + + return SettingsExportViewModelFactory(dataRepository, noteIds, count) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/ui/settings/importing/SettingsImportAdapter.kt b/app/src/main/java/com/orgzly/android/ui/settings/importing/SettingsImportAdapter.kt new file mode 100644 index 00000000..8a5dc420 --- /dev/null +++ b/app/src/main/java/com/orgzly/android/ui/settings/importing/SettingsImportAdapter.kt @@ -0,0 +1,103 @@ +package com.orgzly.android.ui.settings.importing + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.DrawableRes +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.orgzly.R +import com.orgzly.android.db.entity.Book +import com.orgzly.android.db.entity.Note +import com.orgzly.android.db.entity.NoteView +import com.orgzly.android.ui.notes.NoteItemViewBinder +import com.orgzly.databinding.ItemSettingsImportBinding + +class SettingsImportAdapter(val context: Context, val listener: OnClickListener) : + ListAdapter( + DIFF_CALLBACK + ) { + + data class Icons(@DrawableRes val up: Int, @DrawableRes val book: Int) + + private var icons: Icons? = null + + private val noteItemViewBinder = NoteItemViewBinder(context, true) + + interface OnClickListener { + fun onItem(item: SettingsImportViewModel.Item) + fun onButton(item: SettingsImportViewModel.Item) + } + + class SettingsImportViewHolder(val binding: ItemSettingsImportBinding) : RecyclerView.ViewHolder(binding.root) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SettingsImportViewHolder { + val holder = SettingsImportViewHolder(ItemSettingsImportBinding.inflate( + LayoutInflater.from(parent.context), parent, false)) + + holder.binding.itemSettingsImportPayload.setOnClickListener { + val position = holder.bindingAdapterPosition + if (position != RecyclerView.NO_POSITION) { + listener.onItem(getItem(holder.bindingAdapterPosition)) + } + } + + holder.binding.itemSettingsImportButton.setOnClickListener { + val position = holder.bindingAdapterPosition + if (position != RecyclerView.NO_POSITION) { + listener.onButton(getItem(holder.bindingAdapterPosition)) + } + } + + return holder + } + + override fun onBindViewHolder(holder: SettingsImportViewHolder, position: Int) { + + if (icons == null) { + icons = Icons(R.drawable.ic_keyboard_arrow_up, R.drawable.ic_library_books) + } + + val item = getItem(position) + + when (val payload = item.payload) { + is Book -> { + holder.binding.itemSettingsImportName.text = payload.title ?: payload.name + + holder.binding.itemSettingsImportButton.visibility = View.GONE + + holder.binding.itemSettingsImportIcon.visibility = View.VISIBLE + } + + is Note -> { + holder.binding.itemSettingsImportName.text = noteItemViewBinder.generateTitle( + NoteView(note = payload, bookName = "")) + icons?.let { + if (payload.position.descendantsCount > 0) { + holder.binding.itemSettingsImportIcon.setImageResource(R.drawable.bullet_folded) + holder.binding.itemSettingsImportButton.visibility = View.GONE + } else { + holder.binding.itemSettingsImportIcon.setImageResource(R.drawable.bullet) + holder.binding.itemSettingsImportButton.visibility = View.VISIBLE + } + holder.binding.itemSettingsImportIcon.visibility = View.VISIBLE + } + } + } + } + + companion object { + private val DIFF_CALLBACK: DiffUtil.ItemCallback = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: SettingsImportViewModel.Item, newItem: SettingsImportViewModel.Item): Boolean { + return oldItem == newItem + } + + override fun areContentsTheSame(oldItem: SettingsImportViewModel.Item, newItem: SettingsImportViewModel.Item): Boolean { + return oldItem == newItem + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/ui/settings/importing/SettingsImportFragment.kt b/app/src/main/java/com/orgzly/android/ui/settings/importing/SettingsImportFragment.kt new file mode 100644 index 00000000..514aeff4 --- /dev/null +++ b/app/src/main/java/com/orgzly/android/ui/settings/importing/SettingsImportFragment.kt @@ -0,0 +1,201 @@ +package com.orgzly.android.ui.settings.importing + +import android.app.Dialog +import android.content.Context +import android.os.Bundle +import android.text.method.LinkMovementMethod +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.orgzly.BuildConfig +import com.orgzly.R +import com.orgzly.android.App +import com.orgzly.android.data.DataRepository +import com.orgzly.android.db.entity.Book +import com.orgzly.android.db.entity.Note +import com.orgzly.android.ui.Breadcrumbs +import com.orgzly.android.ui.note.NotePayload +import com.orgzly.android.ui.showSnackbar +import com.orgzly.android.util.LogUtils +import com.orgzly.databinding.DialogImportSettingsBinding +import javax.inject.Inject + +class SettingsImportFragment : DialogFragment() { + + private lateinit var binding: DialogImportSettingsBinding + + @Inject + lateinit var dataRepository: DataRepository + + lateinit var viewModel: SettingsImportViewModel + + override fun onAttach(context: Context) { + super.onAttach(context) + + App.appComponent.inject(this) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (BuildConfig.LOG_DEBUG) LogUtils.d(TAG) + + val noteIds = arguments?.getLongArray(ARG_NOTE_IDS)?.toSet() ?: emptySet() + val count = arguments?.getInt(ARG_COUNT) ?: 0 + + val factory = SettingsImportViewModelFactory.forNotes(dataRepository, noteIds, count) + + viewModel = ViewModelProvider(this, factory)[SettingsImportViewModel::class.java] + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + if (BuildConfig.LOG_DEBUG) LogUtils.d(TAG) + + val dialog = MaterialAlertDialogBuilder(requireContext(), theme) + + return dialog.show() + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + if (BuildConfig.LOG_DEBUG) LogUtils.d(TAG, savedInstanceState) + + binding = DialogImportSettingsBinding.inflate(inflater, container, false) + + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.dialogImportSettingsToolbar.apply { + title = resources.getString(R.string.import_settings_from_note) + + setNavigationOnClickListener { + dismiss() + } + } + + val adapter = SettingsImportAdapter(binding.root.context, object: SettingsImportAdapter.OnClickListener { + override fun onItem(item: SettingsImportViewModel.Item) { + viewModel.open(item) + } + + override fun onButton(item: SettingsImportViewModel.Item) { + viewModel.import(item) + } + }) + + binding.dialogImportSettingsTargets.let { + it.layoutManager = LinearLayoutManager(context) + it.adapter = adapter + } + + binding.dialogImportSettingsBreadcrumbs.movementMethod = LinkMovementMethod.getInstance() + + viewModel.data.observe(viewLifecycleOwner) { data -> + val breadcrumbs = data.first + val list = data.second + + adapter.submitList(list) + + // Update and scroll breadcrumbs to the end + binding.dialogImportSettingsBreadcrumbs.text = generateBreadcrumbs(breadcrumbs) + binding.dialogImportSettingsBreadcrumbsScrollView.apply { + post { + fullScroll(View.FOCUS_RIGHT) + } + } + } + + viewModel.importedEvent.observeSingle(viewLifecycleOwner) { result -> + if (result.userData is RuntimeException) { + (result.userData).let { + activity?.showSnackbar(it.localizedMessage) + } + } else { + dismiss() + + (result.userData as NotePayload).let { + activity?.showSnackbar( + getString( + R.string.settings_imported_from, it.title + ) + ) + } + } + } + + viewModel.errorEvent.observeSingle(viewLifecycleOwner) { error -> + binding.dialogImportSettingsToolbar.subtitle = (error.cause ?: error).localizedMessage + } + + viewModel.openForTheFirstTime() + } + + private fun generateBreadcrumbs(path: List): CharSequence { + val breadcrumbs = Breadcrumbs() + + path.forEachIndexed { index, item -> + val onClick = if (index != path.size - 1) { // Not last + fun() { + viewModel.onBreadcrumbClick(item) + } + } else { + null + } + + when (val payload = item.payload) { + is SettingsImportViewModel.Home -> + breadcrumbs.add(getString(R.string.notebooks), 0, onClick = onClick) + is Book -> + breadcrumbs.add(payload.title ?: payload.name, 0, onClick = onClick) + is Note -> + breadcrumbs.add(payload.title, onClick = onClick) + } + } + + return breadcrumbs.toCharSequence() + } + + override fun onResume() { + super.onResume() + + dialog?.apply { + + val w = resources.displayMetrics.widthPixels + val h = resources.displayMetrics.heightPixels + + requireDialog().window?.apply { + if (h > w) { // Portrait + setLayout(ViewGroup.LayoutParams.MATCH_PARENT, (h * 0.90).toInt()) + } else { + setLayout((w * 0.90).toInt(), ViewGroup.LayoutParams.MATCH_PARENT) + } + } + + } + + } + + companion object { + fun getInstance(noteIds: Set, count: Int): SettingsImportFragment { + return SettingsImportFragment().also { fragment -> + fragment.arguments = Bundle().apply { + putLongArray(ARG_NOTE_IDS, noteIds.toLongArray()) + putInt(ARG_COUNT, count) + } + } + } + + private const val ARG_NOTE_IDS = "note_ids" + private const val ARG_COUNT = "count" + + private val TAG = SettingsImportFragment::class.java.name + + val FRAGMENT_TAG: String = SettingsImportFragment::class.java.name + } +} \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/ui/settings/importing/SettingsImportViewModel.kt b/app/src/main/java/com/orgzly/android/ui/settings/importing/SettingsImportViewModel.kt new file mode 100644 index 00000000..ee72e262 --- /dev/null +++ b/app/src/main/java/com/orgzly/android/ui/settings/importing/SettingsImportViewModel.kt @@ -0,0 +1,178 @@ +package com.orgzly.android.ui.settings.importing + +import androidx.lifecycle.MutableLiveData +import com.orgzly.BuildConfig +import com.orgzly.R +import com.orgzly.android.App +import com.orgzly.android.data.DataRepository +import com.orgzly.android.db.dao.NoteDao.NoteIdBookId +import com.orgzly.android.db.entity.Book +import com.orgzly.android.db.entity.Note +import com.orgzly.android.prefs.AppPreferences +import com.orgzly.android.ui.CommonViewModel +import com.orgzly.android.ui.SingleLiveEvent +import com.orgzly.android.usecase.UseCase.Companion.SYNC_DATA_MODIFIED +import com.orgzly.android.usecase.UseCaseResult +import com.orgzly.android.util.LogUtils +import java.util.Stack +import java.util.UUID + +class SettingsImportViewModel( + val dataRepository: DataRepository, + val noteIds: Set, + val count: Int) : CommonViewModel() { + + class Home + class Parent + + data class Item(val payload: Any? = null, val name: String? = null) + + private val breadcrumbs = Stack() + + val data = MutableLiveData, List>>() + + val importedEvent: SingleLiveEvent = SingleLiveEvent() + + fun openForTheFirstTime() { + val targetNote: NoteIdBookId? = dataRepository.findUniqueNoteHavingProperty( + "ID", AppPreferences.settingsExportAndImportNoteId(App.getAppContext())) + var item: Item? = null + if (targetNote != null) { + val note = dataRepository.getNote(targetNote.noteId) + if (note != null) { + val parentNote = dataRepository.getNoteAncestors(note.id).last() + item = replayUntilNoteId(parentNote.id) + } + } + if (item == null) + item = HOME + open(item) + } + + fun open(item: Item) { + val payload = item.payload + + if (BuildConfig.LOG_DEBUG) LogUtils.d(TAG, payload) + + when (payload) { + is Parent -> { + breadcrumbs.pop() + + open(breadcrumbs.pop()) + } + + is Home -> { + App.EXECUTORS.diskIO().execute { + val items = dataRepository.getBooks().map { book -> + Item(book.book, book.book.name) + } + + breadcrumbs.clear() + breadcrumbs.push(HOME) + + data.postValue(Pair(breadcrumbs, items)) + } + } + + is Book -> { + App.EXECUTORS.diskIO().execute { + val items = dataRepository.getTopLevelNotes(payload.id).map { note -> + Item(note, note.title) + } + + breadcrumbs.push(item) + + data.postValue(Pair(breadcrumbs, items)) + } + } + + is Note -> { + App.EXECUTORS.diskIO().execute { + val items = dataRepository.getNoteChildren(payload.id).map { note -> + Item(note, note.title) + } + + if (BuildConfig.LOG_DEBUG) LogUtils.d(TAG, "Items for $payload: $items") + + if (items.isNotEmpty()) { + breadcrumbs.push(item) + + data.postValue(Pair(breadcrumbs, items)) + } + } + } + } + } + + fun import(item: Item) { + val payload = item.payload as Note + val notePayload = dataRepository.getNotePayload(payload.id) ?: throw RuntimeException( + App.getAppContext().getString(R.string.failed_to_get_note_payload)) + if (notePayload.properties.get("ID") == null) { + // Note has no "ID" property - let's add one + notePayload.properties.put("ID", UUID.randomUUID().toString()) + dataRepository.updateNote(payload.id, notePayload) + } else { + // Check that the note's "ID" property value is unique + if (dataRepository.findNotesHavingProperty("ID", notePayload.properties.get("ID")).size > 1) { + importedEvent.postValue( + UseCaseResult( + modifiesLocalData = true, + triggersSync = SYNC_DATA_MODIFIED, + userData = RuntimeException(App.getAppContext().getString(R.string.selected_notes_id_property_value_is_not_unique)) + ) + ) + return + } + } + AppPreferences.settingsExportAndImportNoteId(App.getAppContext(), notePayload.properties.get("ID")) + dataRepository.importSettingsAndSearchesFromSelectedNote() + importedEvent.postValue(UseCaseResult( + modifiesLocalData = true, + triggersSync = SYNC_DATA_MODIFIED, + userData = notePayload + )) + } + + private fun replayUntilNoteId(noteId: Long): Item? { + val notes = dataRepository.getNoteAndAncestors(noteId) + + if (notes.isNotEmpty()) { + val lastNote = notes.last() + + val book = dataRepository.getBook(lastNote.position.bookId) + + if (book != null) { + breadcrumbs.clear() + breadcrumbs.add(HOME) + breadcrumbs.add(Item(book, book.name)) + + for (i in 0 until notes.count() - 1) { + val note = notes[i] + + val item = Item(note, note.title) + + breadcrumbs.push(item) + } + + + return Item(lastNote, lastNote.title) + } + } + + return null + } + + fun onBreadcrumbClick(item: Item) { + if (BuildConfig.LOG_DEBUG) LogUtils.d(TAG, item) + while (breadcrumbs.pop() != item) { + // Pop up to and including clicked item + } + open(item) + } + + companion object { + val HOME = Item(Home()) + private val TAG = SettingsImportViewModel::class.java.name + } +} \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/ui/settings/importing/SettingsImportViewModelFactory.kt b/app/src/main/java/com/orgzly/android/ui/settings/importing/SettingsImportViewModelFactory.kt new file mode 100644 index 00000000..8313b9da --- /dev/null +++ b/app/src/main/java/com/orgzly/android/ui/settings/importing/SettingsImportViewModelFactory.kt @@ -0,0 +1,26 @@ +package com.orgzly.android.ui.settings.importing + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.orgzly.android.data.DataRepository + +class SettingsImportViewModelFactory( + private val dataRepository: DataRepository, + private val noteIds: Set, + private val count: Int) : ViewModelProvider.Factory { + + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return SettingsImportViewModel(dataRepository, noteIds, count) as T + } + + companion object { + fun forNotes( + dataRepository: DataRepository, + noteIds: Set, + count: Int): ViewModelProvider.Factory { + + return SettingsImportViewModelFactory(dataRepository, noteIds, count) + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable-anydpi/ic_sync.xml b/app/src/main/res/drawable-anydpi/ic_sync.xml index 4069d8f3..ae8c1e60 100644 --- a/app/src/main/res/drawable-anydpi/ic_sync.xml +++ b/app/src/main/res/drawable-anydpi/ic_sync.xml @@ -7,4 +7,4 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_export.xml b/app/src/main/res/drawable/ic_export.xml new file mode 100644 index 00000000..de0965b2 --- /dev/null +++ b/app/src/main/res/drawable/ic_export.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_import.xml b/app/src/main/res/drawable/ic_import.xml new file mode 100644 index 00000000..0dd2db47 --- /dev/null +++ b/app/src/main/res/drawable/ic_import.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/dialog_export_settings.xml b/app/src/main/res/layout/dialog_export_settings.xml new file mode 100644 index 00000000..dc11d422 --- /dev/null +++ b/app/src/main/res/layout/dialog_export_settings.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_import_settings.xml b/app/src/main/res/layout/dialog_import_settings.xml new file mode 100644 index 00000000..774175b5 --- /dev/null +++ b/app/src/main/res/layout/dialog_import_settings.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_settings_export.xml b/app/src/main/res/layout/item_settings_export.xml new file mode 100644 index 00000000..996ffb55 --- /dev/null +++ b/app/src/main/res/layout/item_settings_export.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_settings_import.xml b/app/src/main/res/layout/item_settings_import.xml new file mode 100644 index 00000000..324c5869 --- /dev/null +++ b/app/src/main/res/layout/item_settings_import.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/prefs_keys.xml b/app/src/main/res/values/prefs_keys.xml index 3267fd97..42ea2e4a 100644 --- a/app/src/main/res/values/prefs_keys.xml +++ b/app/src/main/res/values/prefs_keys.xml @@ -595,6 +595,8 @@ pref_key_major_events_logs + pref_key_settings_export_note_id + pref_key_repos pref_key_ssh_keygen @@ -603,6 +605,8 @@ pref_key_git_commit pref_key_reload_getting_started pref_key_clear_database + pref_key_export_settings + pref_key_import_settings pref_key_dropbox_token diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5281ac4d..adcb3967 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -591,6 +591,7 @@ Keep marks displayed (e.g. *bold*) No note or book found with the “%1$s” property set to “%2$s” + Found multiple notes with the “%1$s” property set to “%2$s” Imported one search @@ -797,4 +798,15 @@ Renaming notebook to a different subdirectory requires Android 7 or higher Support for subfolders is disabled + Export settings to note + Import settings from note + Exported settings to note “%1$s” + Imported settings from note “%1$s” + Failed to get note payload + Export app settings + Write a JSON representation of all settings to a note of your choice + Import app settings + Import settings as JSON from a note of your choice + WARNING! The selected note will be completely overwritten! + The selected note\'s “ID” property value is not unique (consider changing it) diff --git a/app/src/main/res/xml/prefs_screen_app.xml b/app/src/main/res/xml/prefs_screen_app.xml index a0c352e0..06d6418b 100644 --- a/app/src/main/res/xml/prefs_screen_app.xml +++ b/app/src/main/res/xml/prefs_screen_app.xml @@ -10,6 +10,16 @@ android:title="@string/reload_getting_started" android:summary="@string/reload_getting_started_summary" /> + + + + Date: Mon, 17 Feb 2025 23:15:04 +0100 Subject: [PATCH 4/4] Add test for dataRepository.exportSettingsAndSearchesToSelectedNote() --- .../orgzly/android/data/DataRepositoryTest.kt | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 app/src/androidTest/java/com/orgzly/android/data/DataRepositoryTest.kt diff --git a/app/src/androidTest/java/com/orgzly/android/data/DataRepositoryTest.kt b/app/src/androidTest/java/com/orgzly/android/data/DataRepositoryTest.kt new file mode 100644 index 00000000..7807bba0 --- /dev/null +++ b/app/src/androidTest/java/com/orgzly/android/data/DataRepositoryTest.kt @@ -0,0 +1,50 @@ +package com.orgzly.android.data + +import com.orgzly.android.OrgzlyTest +import com.orgzly.android.prefs.AppPreferences +import junit.framework.TestCase +import org.junit.Assert +import org.junit.Test +import java.io.IOException + +class DataRepositoryTest : OrgzlyTest() { + + /** + * If the user attempts to export app settings to a note with a non-unique "ID" value, then + * - no export should happen + * - a runtime exception should be thrown + */ + @Test(expected = RuntimeException::class) + fun testExportSettingsAndSearchesToSelectedNote() { + testUtils.setupBook( + "book1", + """ + * Note 1 + :PROPERTIES: + :ID: not-unique-value + :END: + + content + + * Note 2 + :PROPERTIES: + :ID: not-unique-value + :END: + + content + + """.trimIndent() + ) + TestCase.assertEquals(2, dataRepository.getNotes("book1").size) + AppPreferences.settingsExportAndImportNoteId(context, "not-unique-value") + try { + dataRepository.exportSettingsAndSearchesToSelectedNote() + } catch (e: IOException) { + Assert.assertTrue(e.message!!.contains("Found multiple")) + throw e + } finally { + TestCase.assertEquals("content", dataRepository.getNotes("book1")[0].note.content) + TestCase.assertEquals("content", dataRepository.getNotes("book1")[1].note.content) + } + } +} \ No newline at end of file